-
Notifications
You must be signed in to change notification settings - Fork 2.2k
fix(desktop): probe fallback must honour app privacy exclusions #11403
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| import AppKit | ||
| import Foundation | ||
|
|
||
| /// Automation entry point for the suggestion nudge. | ||
|
|
@@ -23,7 +24,12 @@ extension ProactiveAssistantsPlugin { | |
| // with the user 18 times running. Fall back to capturing the active window here so the | ||
| // probe tests the suggestion path rather than the capture pipeline's timing. | ||
| let frame: CapturedFrame | ||
| if let latest = latestCapturedFrame { | ||
| // A cached frame predates the current exclusion list: the user can add an app to it | ||
| // after that app's frame was captured, and replaying it would leak what they just asked | ||
| // to be forgotten. Re-check against the list as it stands now, not as it stood then. | ||
| if let latest = latestCapturedFrame, SuggestionProbePrivacy.isExcluded(latest.appName) { | ||
| return ["outcome": "excluded_app"] | ||
| } else if let latest = latestCapturedFrame { | ||
| frame = CapturedFrame( | ||
| jpegData: latest.jpegData, | ||
| appName: appOverride ?? latest.appName, | ||
|
|
@@ -32,14 +38,15 @@ extension ProactiveAssistantsPlugin { | |
| captureTime: latest.captureTime, | ||
| screenshotId: latest.screenshotId | ||
| ) | ||
| } else if let jpeg = await ScreenCaptureService().captureActiveWindowAsync() { | ||
| let (activeApp, activeTitle, _) = await WindowMonitor.getActiveWindowInfoAsync() | ||
| frame = CapturedFrame( | ||
| jpegData: jpeg, | ||
| appName: appOverride ?? activeApp ?? "Unknown", | ||
| windowTitle: windowTitleOverride ?? activeTitle, | ||
| frameNumber: 0 | ||
| ) | ||
| } else if let fallback = await captureActiveWindowRespectingExclusions( | ||
| appOverride: appOverride, | ||
| windowTitleOverride: windowTitleOverride | ||
| ) { | ||
| frame = fallback | ||
| } else if activeAppIsExcluded { | ||
| // Refuse rather than fall through to "no frame": the two are different answers and | ||
| // conflating them would read as a capture hiccup instead of a privacy decision. | ||
| return ["outcome": "excluded_app"] | ||
| } else { | ||
| return ["outcome": "no_frame_captured"] | ||
| } | ||
|
|
@@ -48,4 +55,87 @@ extension ProactiveAssistantsPlugin { | |
| // result is the notification itself plus the returned outcome. | ||
| return await assistant.probeEvaluateAndDeliver(frame: frame) { _, _ in } | ||
| } | ||
|
|
||
| /// Whether the frontmost app is one the user has excluded from capture. | ||
| private var activeAppIsExcluded: Bool { | ||
| guard let app = NSWorkspace.shared.frontmostApplication?.localizedName else { return false } | ||
| return SuggestionProbePrivacy.isExcluded(app) | ||
| } | ||
|
|
||
| /// Capture the active window for the probe, honouring the same privacy exclusions the | ||
| /// normal capture path does. | ||
| /// | ||
| /// This fallback exists because `latestCapturedFrame` is nil whenever the user is moving | ||
| /// around — but it is *also* nil precisely when the frontmost app is excluded, since the | ||
| /// capture gate refuses those. Without this check the probe would reach for the shutter | ||
| /// exactly in the apps the user asked Omi never to look at, and send the result to a | ||
| /// model. The excluded case must be decided before anything is captured, not filtered | ||
| /// afterwards. | ||
| private func captureActiveWindowRespectingExclusions( | ||
| appOverride: String?, | ||
| windowTitleOverride: String? | ||
| ) async -> CapturedFrame? { | ||
| let (beforeApp, beforeTitle, beforeWindowID) = await WindowMonitor.getActiveWindowInfoAsync() | ||
| if let beforeApp, SuggestionProbePrivacy.isExcluded(beforeApp) { return nil } | ||
| if activeAppIsExcluded { return nil } | ||
|
|
||
| // Capture *this* window, not "whatever is active when the shutter opens". | ||
| // `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. Binding the capture | ||
| // to the window ID that was authorised removes the race rather than narrowing it. | ||
| guard let windowID = beforeWindowID else { return nil } | ||
| let service = ScreenCaptureService() | ||
| guard case .success(let image) = await service.captureWindowCGImage(windowID: windowID), | ||
| let jpeg = service.encodeJPEG(from: image) | ||
| else { return nil } | ||
|
|
||
| // Belt and braces: the window ID binds the pixels, and this rejects the case where the | ||
| // app that owns it became excluded while the capture ran. | ||
| let (afterApp, afterTitle, _) = await WindowMonitor.getActiveWindowInfoAsync() | ||
| guard | ||
| SuggestionProbePrivacy.allowsCapture( | ||
| before: beforeApp, | ||
| after: afterApp, | ||
| isExcluded: SuggestionProbePrivacy.isExcluded | ||
| ) | ||
| else { return nil } | ||
|
|
||
| return CapturedFrame( | ||
| jpegData: jpeg, | ||
| appName: appOverride ?? beforeApp ?? "Unknown", | ||
| windowTitle: windowTitleOverride ?? beforeTitle ?? afterTitle, | ||
| frameNumber: 0 | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| /// The exclusion predicate the probe's fallback capture must satisfy, factored out so it can | ||
| /// be exercised without a running app or a real screen. | ||
| enum SuggestionProbePrivacy { | ||
| @MainActor | ||
| static func isExcluded(_ appName: String) -> Bool { | ||
| RewindSettings.shared.isAppExcluded(appName) | ||
| || SuggestionAssistantSettings.shared.isAppExcluded(appName) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: In Prompt for AI agents |
||
| } | ||
|
|
||
| /// Whether a fallback capture may be used, given which app was frontmost before the | ||
| /// shutter and which was frontmost after it. | ||
| /// | ||
| /// Checking only before the capture leaves a window: `captureActiveWindowAsync()` is | ||
| /// async, and the user can cmd-tab into an excluded app while it runs, so the pixels that | ||
| /// come back can belong to an app that was never allowed. Both ends must be clear, and a | ||
| /// change of app across the capture is refused outright — at that point the frame cannot | ||
| /// be attributed to either app with confidence, and an unattributable frame is exactly | ||
| /// what must not reach a model. | ||
| static func allowsCapture( | ||
| before: String?, | ||
| after: String?, | ||
| isExcluded: (String) -> Bool | ||
| ) -> Bool { | ||
| if let before, isExcluded(before) { return false } | ||
| if let after, isExcluded(after) { return false } | ||
| if let before, let after, before != after { return false } | ||
| return true | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -694,3 +694,92 @@ final class SuggestionGoalOwnerScopingTests: XCTestCase { | |
| XCTAssertFalse(authority.isCurrent(snapshot, ownerID: "owner-a")) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| } | ||
| } | ||
|
|
||
| /// The automation probe falls back to capturing the active window when no frame is pending. | ||
| /// `latestCapturedFrame` is nil whenever the user is moving around — but it is *also* nil | ||
| /// precisely when the frontmost app is privacy-excluded, because the capture gate refuses | ||
| /// 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. | ||
| private func withExclusions(_ apps: Set<String>, _ body: () -> Void) { | ||
| let saved = RewindSettings.shared.excludedApps | ||
| defer { RewindSettings.shared.excludedApps = saved } | ||
| RewindSettings.shared.excludedApps = apps | ||
| body() | ||
| } | ||
|
|
||
| func testExcludedAppIsRefusedByTheProbePredicate() { | ||
| withExclusions(["1Password"]) { | ||
| XCTAssertTrue( | ||
| SuggestionProbePrivacy.isExcluded("1Password"), | ||
| "the probe must not capture an app the user excluded from recording") | ||
| } | ||
| } | ||
|
|
||
| func testNonExcludedAppIsAllowed() { | ||
| withExclusions(["1Password"]) { | ||
| XCTAssertFalse(SuggestionProbePrivacy.isExcluded("Google Chrome")) | ||
| } | ||
| } | ||
|
|
||
| /// Exclusion is exact-name, so the predicate must not be fooled by a near-miss either way. | ||
| func testExclusionIsExactName() { | ||
| withExclusions(["Messages"]) { | ||
| XCTAssertTrue(SuggestionProbePrivacy.isExcluded("Messages")) | ||
| XCTAssertFalse(SuggestionProbePrivacy.isExcluded("Messages Beta")) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// `captureActiveWindowAsync()` is async, so checking exclusions only before the shutter | ||
| /// leaves a window in which the user cmd-tabs into an excluded app and its pixels come back | ||
| /// anyway. Both ends must be clear. | ||
| final class SuggestionProbeCaptureRaceTests: XCTestCase { | ||
| private func allows(before: String?, after: String?, excluded: Set<String> = ["1Password"]) -> Bool { | ||
| SuggestionProbePrivacy.allowsCapture( | ||
| before: before, after: after, isExcluded: { excluded.contains($0) }) | ||
| } | ||
|
|
||
| func testAllowsWhenBothEndsAreTheSameAllowedApp() { | ||
| XCTAssertTrue(allows(before: "Google Chrome", after: "Google Chrome")) | ||
| } | ||
|
|
||
| func testRefusesWhenTheAppWasExcludedBeforeTheCapture() { | ||
| XCTAssertFalse(allows(before: "1Password", after: "1Password")) | ||
| } | ||
|
|
||
| /// The race the reviewer caught: allowed at the shutter, excluded by the time the pixels | ||
| /// arrived. | ||
| func testRefusesWhenAnExcludedAppBecameFrontmostDuringTheCapture() { | ||
| XCTAssertFalse(allows(before: "Google Chrome", after: "1Password")) | ||
| } | ||
|
|
||
| /// Even between two permitted apps, a switch mid-capture means the frame cannot be | ||
| /// attributed with confidence — and an unattributable frame must not reach a model. | ||
| func testRefusesWhenTheAppChangedMidCaptureEvenIfBothAreAllowed() { | ||
| XCTAssertFalse(allows(before: "Google Chrome", after: "Warp")) | ||
| } | ||
|
|
||
| /// The flicker the window-ID binding exists for: allowed at both ends, excluded in the | ||
| /// middle. An app-name comparison cannot see this, which is why the probe captures the | ||
| /// window ID it authorised rather than "whatever is active now" — this test pins that the | ||
| /// before/after check alone is NOT treated as sufficient. | ||
| func testBeforeAfterCheckAloneCannotSeeAnAllowedExcludedAllowedFlicker() { | ||
| // Both observations say Chrome; an excluded app was frontmost only between them. | ||
| XCTAssertTrue( | ||
| allows(before: "Google Chrome", after: "Google Chrome"), | ||
| "the app-name check passes here by construction — the capture must therefore be bound " | ||
| + "to the pre-authorised window ID, which is what actually prevents the leak") | ||
| } | ||
|
|
||
| /// An unresolvable app name is not evidence of permission on the excluded side, but it | ||
| /// must not block an otherwise clean capture either. | ||
| func testUnknownAppNamesDoNotFabricateAMismatch() { | ||
| XCTAssertTrue(allows(before: nil, after: nil)) | ||
| XCTAssertTrue(allows(before: "Google Chrome", after: nil)) | ||
| XCTAssertFalse(allows(before: nil, after: "1Password")) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| { | ||
| "change": "Excluded apps stay excluded — the suggestion automation probe can no longer capture a window from an app you told Omi not to record" | ||
| } |
There was a problem hiding this comment.
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
latestCapturedFrameis 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