Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -267,8 +267,6 @@ actor SuggestionAssistant: ProactiveAssistant {
return grounding
}

/// Describe a commitment the way a person would say it out loud.
///
/// Fire-and-forget goal refresh. Never awaited by grounding: the next evaluation gets the
/// fresher list, this one is not delayed for it.
///
Expand Down
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.
Expand All @@ -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,
Expand All @@ -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(

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>

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"]
}
Expand All @@ -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)

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>

}

/// 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
}
}
89 changes: 89 additions & 0 deletions desktop/macos/Desktop/Tests/SuggestionAssistantTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -694,3 +694,92 @@ 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>

}
}

/// 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"
}
Loading