Skip to content

fix(desktop): probe fallback must honour app privacy exclusions - #11403

Open
kodjima33 wants to merge 3 commits into
mainfrom
kodjima33/probe-privacy-gate
Open

fix(desktop): probe fallback must honour app privacy exclusions#11403
kodjima33 wants to merge 3 commits into
mainfrom
kodjima33/probe-privacy-gate

Conversation

@kodjima33

@kodjima33 kodjima33 commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

The bug

probeSuggestionNudge falls back to capturing the active window when no frame is pending. That fallback called ScreenCaptureService directly with no exclusion check.

The sharp edge: latestCapturedFrame is nil precisely when the frontmost app is privacy-excluded, because the capture gate refuses those apps. So the fallback fired exactly in the apps the user told Omi never to look at — and sent the screenshot to a model.

Introduced in #11392 (merged), which is why this is a follow-up rather than an amend.

The fix

  • Exclusion is resolved before anything is captured, not filtered afterwards.
  • An excluded app returns excluded_app, not no_frame_captured — a privacy decision and a capture hiccup are different answers and shouldn't read the same in automation output.
  • SuggestionProbePrivacy.isExcluded consults the same RewindSettings list the normal capture path uses, factored out so it is testable without a running app or a real screen.
  • Removes a duplicated doc line a previous comment split left above refreshGoalsIfStale.

Verification

  • SuggestionProbePrivacyTests: excluded app refused, non-excluded allowed, exact-name matching (Messages excluded ≠ Messages Beta).
  • 122 focused tests pass, swift-format clean.
  • Exercised on the running named bundle — delivery still works with the gate in place: "Discord onboarding is fine - but you said you'd recruit people for Omi by Saturday".

Failure-Class: none

Review in cubic

`probeSuggestionNudge` falls back to capturing the active window when no frame is
pending. That fallback called ScreenCaptureService directly, with no exclusion
check — and `latestCapturedFrame` is nil *precisely* when the frontmost app is
excluded, because the capture gate refuses those apps. So the fallback reached for
the shutter exactly in the apps the user asked Omi never to look at, and sent the
result to a model.

The exclusion is now decided before anything is captured, not filtered afterwards,
and an excluded app returns `excluded_app` rather than `no_frame_captured` — a
privacy decision and a capture hiccup are different answers and should not read
the same in automation output.

Also removes a duplicated doc line that a previous comment split left above
refreshGoalsIfStale.

Tests: SuggestionProbePrivacyTests covers excluded, non-excluded, and exact-name
matching against the real RewindSettings list. 122 focused tests pass.

Verified on the running named bundle: delivery still works with the gate in place —
"Discord onboarding is fine - but you said you'd recruit people for Omi by Saturday".

Failure-Class: none

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 4 files

Confidence score: 2/5

  • In desktop/macos/Desktop/Sources/ProactiveAssistants/ProactiveAssistantsPlugin+SuggestionProbe.swift, the exclusion check appears gated on latestCapturedFrame == nil, so the probe may keep evaluating/delivering after the frontmost app becomes excluded, creating a concrete privacy regression; re-run exclusion gating on app-activation/exclusion changes (or clear captured frame state) so delivery stops immediately.
  • In desktop/macos/Desktop/Tests/SuggestionAssistantTests.swift, current tests validate only SuggestionProbePrivacy.isExcluded (a direct settings read) and do not cover the end-to-end probe lifecycle where this regression occurs, so future breaks could slip through; add integration-style tests that simulate app switching/exclusion updates and assert no suggestion delivery while excluded.
  • In desktop/macos/Desktop/Sources/ProactiveAssistants/ProactiveAssistantsPlugin+SuggestionProbe.swift, the redundant || branch in SuggestionProbePrivacy.isExcluded duplicates the same exclusion source, which adds noise and can obscure logic during future fixes; remove the duplicate operand to keep the privacy path easier to reason about.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="desktop/macos/Desktop/Tests/SuggestionAssistantTests.swift">

<violation number="1" location="desktop/macos/Desktop/Tests/SuggestionAssistantTests.swift:704">
P2: These tests only exercise SuggestionProbePrivacy.isExcluded, which is a trivial Set.contains read of the same RewindSettings.excludedApps list the test itself mutates, so the actual regression the PR fixes — the probe fallback refusing to capture the excluded app and returning "excluded_app" instead of photographing it and sending it to a model — is never asserted. A revert of the capture-path guard in ProactiveAssistantsPlugin+SuggestionProbe would pass all three tests. Consider adding a test that drives the probe/decision through the production API and asserts the excluded outcome.</violation>
</file>

<file name="desktop/macos/Desktop/Sources/ProactiveAssistants/ProactiveAssistantsPlugin+SuggestionProbe.swift">

<violation number="1" location="desktop/macos/Desktop/Sources/ProactiveAssistants/ProactiveAssistantsPlugin+SuggestionProbe.swift:36">
P1: The probe can still evaluate and deliver while the frontmost app is excluded because this new exclusion branch runs only when `latestCapturedFrame` is nil, but app activation and exclusion changes do not clear that frame. Resolve the active-app exclusion before accepting the latest frame, or invalidate the cached frame when its app becomes excluded.</violation>

<violation number="2" location="desktop/macos/Desktop/Sources/ProactiveAssistants/ProactiveAssistantsPlugin+SuggestionProbe.swift:92">
P3: In `SuggestionProbePrivacy.isExcluded`, the second operand of the `||` is redundant: `SuggestionAssistantSettings.isAppExcluded(_:)` simply delegates to `RewindSettings.shared.isAppExcluded(_:)`, so this evaluates the same RewindSettings lookup twice (`X || X`). It doesn't change behavior, but it reads as though the suggestion assistant has a separate exclusion list when it deliberately shares the single Rewind one. Simplify to just the RewindSettings check (the PR's own description confirms that is the intended single source of truth).</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

windowTitle: windowTitleOverride ?? activeTitle,
frameNumber: 0
)
} else if let fallback = await captureActiveWindowRespectingExclusions(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The probe can still evaluate and deliver while the frontmost app is excluded because this new exclusion branch runs only when latestCapturedFrame is nil, but app activation and exclusion changes do not clear that frame. Resolve the active-app exclusion before accepting the latest frame, or invalidate the cached frame when its app becomes excluded.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At desktop/macos/Desktop/Sources/ProactiveAssistants/ProactiveAssistantsPlugin+SuggestionProbe.swift, line 36:

<comment>The probe can still evaluate and deliver while the frontmost app is excluded because this new exclusion branch runs only when `latestCapturedFrame` is nil, but app activation and exclusion changes do not clear that frame. Resolve the active-app exclusion before accepting the latest frame, or invalidate the cached frame when its app becomes excluded.</comment>

<file context>
@@ -32,14 +33,15 @@ extension ProactiveAssistantsPlugin {
-        windowTitle: windowTitleOverride ?? activeTitle,
-        frameNumber: 0
-      )
+    } else if let fallback = await captureActiveWindowRespectingExclusions(
+      appOverride: appOverride,
+      windowTitleOverride: windowTitleOverride
</file context>

@@ -694,3 +694,42 @@ final class SuggestionGoalOwnerScopingTests: XCTestCase {
XCTAssertFalse(authority.isCurrent(snapshot, ownerID: "owner-a"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: These tests only exercise SuggestionProbePrivacy.isExcluded, which is a trivial Set.contains read of the same RewindSettings.excludedApps list the test itself mutates, so the actual regression the PR fixes — the probe fallback refusing to capture the excluded app and returning "excluded_app" instead of photographing it and sending it to a model — is never asserted. A revert of the capture-path guard in ProactiveAssistantsPlugin+SuggestionProbe would pass all three tests. Consider adding a test that drives the probe/decision through the production API and asserts the excluded outcome.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At desktop/macos/Desktop/Tests/SuggestionAssistantTests.swift, line 704:

<comment>These tests only exercise SuggestionProbePrivacy.isExcluded, which is a trivial Set.contains read of the same RewindSettings.excludedApps list the test itself mutates, so the actual regression the PR fixes — the probe fallback refusing to capture the excluded app and returning "excluded_app" instead of photographing it and sending it to a model — is never asserted. A revert of the capture-path guard in ProactiveAssistantsPlugin+SuggestionProbe would pass all three tests. Consider adding a test that drives the probe/decision through the production API and asserts the excluded outcome.</comment>

<file context>
@@ -694,3 +694,42 @@ final class SuggestionGoalOwnerScopingTests: XCTestCase {
+/// those. Without an exclusion check the probe would photograph exactly the apps the user
+/// told Omi never to look at and send them to a model.
+@MainActor
+final class SuggestionProbePrivacyTests: XCTestCase {
+  /// Exclusions live in shared settings, so each test restores what it found. Done inline
+  /// rather than in setUp/tearDown, which are nonisolated and cannot touch MainActor state.
</file context>

@MainActor
static func isExcluded(_ appName: String) -> Bool {
RewindSettings.shared.isAppExcluded(appName)
|| SuggestionAssistantSettings.shared.isAppExcluded(appName)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: In SuggestionProbePrivacy.isExcluded, the second operand of the || is redundant: SuggestionAssistantSettings.isAppExcluded(_:) simply delegates to RewindSettings.shared.isAppExcluded(_:), so this evaluates the same RewindSettings lookup twice (X || X). It doesn't change behavior, but it reads as though the suggestion assistant has a separate exclusion list when it deliberately shares the single Rewind one. Simplify to just the RewindSettings check (the PR's own description confirms that is the intended single source of truth).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At desktop/macos/Desktop/Sources/ProactiveAssistants/ProactiveAssistantsPlugin+SuggestionProbe.swift, line 92:

<comment>In `SuggestionProbePrivacy.isExcluded`, the second operand of the `||` is redundant: `SuggestionAssistantSettings.isAppExcluded(_:)` simply delegates to `RewindSettings.shared.isAppExcluded(_:)`, so this evaluates the same RewindSettings lookup twice (`X || X`). It doesn't change behavior, but it reads as though the suggestion assistant has a separate exclusion list when it deliberately shares the single Rewind one. Simplify to just the RewindSettings check (the PR's own description confirms that is the intended single source of truth).</comment>

<file context>
@@ -48,4 +50,45 @@ extension ProactiveAssistantsPlugin {
+  @MainActor
+  static func isExcluded(_ appName: String) -> Bool {
+    RewindSettings.shared.isAppExcluded(appName)
+      || SuggestionAssistantSettings.shared.isAppExcluded(appName)
+  }
 }
</file context>

The first pass checked exclusions only before the shutter. `captureActiveWindowAsync()`
is async, so a user who switches into an excluded app while it runs still had those
pixels come back and go to a model.

- Re-resolve the frontmost app after the capture; refuse if either end is excluded.
- Refuse when the app changed across the capture at all, even between two permitted
  apps: the frame cannot be attributed with confidence, and an unattributable frame is
  what must not reach a model.
- Re-check `latestCapturedFrame` against the exclusion list as it stands now. A frame
  cached before the user excluded that app would otherwise be replayed, leaking exactly
  what they just asked to be forgotten.

The decision is a pure function so the race is covered without a screen:
SuggestionProbeCaptureRaceTests pins allowed/allowed, excluded-before, excluded-after,
changed-mid-capture, and unresolvable names. 110 focused tests pass.

Verified on the running named bundle: excluded frontmost -> excluded_app; exclusions
cleared -> delivered card.

Failure-Class: none
`captureActiveWindowAsync()` re-resolves the frontmost window internally, so an
allowed -> excluded -> allowed flicker around the call photographs the excluded app
while a before/after app comparison sees "allowed" at both ends. No amount of
additional checking around an unbound capture closes that; the capture target itself
has to be the window that was authorised.

The fallback now resolves the active window once, checks exclusions against it, and
captures that windowID via captureWindowCGImage — which exists precisely for this and
documents itself as avoiding the re-resolve. The before/after app check remains as a
second layer, but it is no longer what holds the line.

Regression pins that the app-name comparison passes by construction on an
allowed/excluded/allowed flicker, so the window-ID binding is what prevents the leak.
111 focused tests pass.

Verified on the running named bundle: excluded frontmost -> excluded_app; exclusions
cleared -> delivered card.

Failure-Class: none
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant