Skip to content

feat(desktop): let the agent read another app's window as named elements, approved per app (#21096, PR 1) - #21120

Open
sauranshbhardwaj wants to merge 19 commits into
BasedHardware:mainfrom
sauranshbhardwaj:feat/mac-ui-snapshot
Open

sauranshbhardwaj wants to merge 19 commits into
BasedHardware:mainfrom
sauranshbhardwaj:feat/mac-ui-snapshot

Conversation

@sauranshbhardwaj

@sauranshbhardwaj sauranshbhardwaj commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #20973 → #20975 → #20997 → #20998 (the approval stack of #20938, which also gates capture_screen behind the same card, Allow Once only). This branch is stacked on feat/device-tool-descriptions (#20998), so the diff shows those PRs' commits too until they merge; I will rebase onto main as each lands. The ui_snapshot change is the last ten commits, including the UI evidence commit. Merge order: #20973 → #20975 → #20997 → #20998 → this.

Invariants carried from the stack (their changes show in this diff until they merge; see each PR's body): INV-AGENT-* and INV-CHAT-1 for the kernel parking, relay and tool descriptions in desktop/macos/agent/src/runtime/** (#20973, #20975, #20998); INV-AUTH-1 (product/invariants/auth-session.md), INV-CHAT-2 (product/invariants/chat-scroll-placement.md), INV-CHAT-1, INV-UI-1 and INV-UI-2 for the approval card, its post-handshake clearing, the card list in ChatMessagesView.swift and the waitingApproval status in ChatProvider.swift (#20997).

Line-count exceptions carried from #20997 (this branch is stacked on it, so its file growth shows in this diff until it merges):

Line-Count-Exception: desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift | 4373 -> 4384 | two kinds in the closed message switch, the post-handshake clear and conditional capability send, a two-line routing case, the owner-revoke case that clears cards, and one clear on daemon exit; the statics, wire message, routing body and clearing live in AgentRuntimeProcess+ToolApprovals.swift, and messageOwnerIsCurrentlyAuthorized drops private so that extension can call it
Line-Count-Exception: desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift | 5172 -> 5173 | one registerToolApprovalActions() call in the builtin registration list; the actions live in Automation/DesktopAutomationToolApprovalActions.swift
Line-Count-Exception: desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift | 3018 -> 3020 | the approvalSurface argument at the one AIResponseView call site
Line-Count-Exception: desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift | 2162 -> 2172 | the "Waiting for your approval" caption under the tool row and its accessibility value; the status itself and the icon live in ChatProvider's enum and ToolActivityTimelineLayout
Line-Count-Exception: desktop/macos/Desktop/Sources/Providers/ChatProvider.swift | 6824 -> 6834 | the waitingApproval case of ToolCallStatus with its doc line, its detector mapping, the turn's session id captured for the stall clocks, and the tick-loop lines that ask the approval store about that session; the lookup lives in ChatProvider+ToolApprovalWait.swift

What changed and why

Phase 1 of #21096 adds ui_snapshot, a read-only tool that lets the Mac agent read one window of another app as named elements: "a button called Save", "a text area called Document, value 'hello'". It is approved per app on the existing approval card. It never clicks, types, raises a window or moves the cursor.

This PR is stacked on feat/device-tool-descriptions (#20998, now at 703fd95), the top of the approval stack #20973 → #20975 → #20997 → #20998. The stack beneath also gates capture_screen behind the same card, Allow Once only (no chat grant can cover a screenshot), and holds every relay-callable tool in a sensitive bundle to the approval set with a drift test; ui_snapshot sits in that set beside it. The ui_snapshot change is the last ten commits: four for the tool, a fifth that makes capture_screen leave out the same refused apps, the UI evidence for the card, a seventh that drops the harmless invisible marks apps put around their own words (found in phase 3's packaged-app test, below), and two from phase 3's second review and live retest: the right-to-left and Arabic letter marks now count as bidirectional controls and the card's app name shows zero-width marks again, and the Messages tools are kept to Apple Messages so a named app like WhatsApp is read with ui_snapshot; and one from the third review and the live retest: a read the app did not answer is tried again (below), and one direction mark at the very start of a label is the label's own.

This is deliberately not "let Omi control your Mac" (#12465 was closed over consent breadth). It is one typed read tool behind a kernel capability bundle, granted per app and per chat. Apps that hold credentials, show them or run code are refused before any card appears.

Kernel

  • A new sensitive bundle, desktop.automation.observe, carries the tool. It is separate from desktop.automation.act, so a grant to read an app never counts as a grant to act in it.
  • ui_snapshot joins the enforced approval set. Its input is held to the manifest schema and it parks on the card.
  • The grant resource is the trimmed, lowercased bundle_id. Allow for This Chat covers that one app for 1 h; another app asks again. Observe grants must name an app.
  • These are hard denies, with no card and no dispatch row:
    • a malformed bundle id and every com.omi.* build;
    • system sign-in, SSO, authorization and iCloud Keychain prompts;
    • UserNotificationCenter alerts and notification banners (they carry one-time codes);
    • Keychain Access, Passwords and its menu-bar extra, the AutoFill panel, and the AutoFill and passkey sign-in view services (com.apple.SafariPlatformSupport.Helper, com.apple.AuthenticationServices.Helper);
    • terminals and password managers;
    • every sensitive System Settings pane's own extension.

One refused list

agent/src/runtime/ui-automation-safety-floor.ts declares the refused bundle ids and the System Settings pane lists once. generate-tool-surfaces.mjs writes them into GeneratedUIAutomationSafetyFloor.swift, so the kernel and Swift cannot drift. A vitest test checks that every entry is lowercase and well formed.

Swift

ChatToolExecutor+UISnapshot.swift runs the generated uiSnapshot case behind the owner bind. It:

  • treats bundle_id as untrusted text;
  • asks for Accessibility through request_permission when it is missing;
  • resolves the one running copy without launching anything (pid only picks between copies and must belong to the bundle);
  • refuses Omi's own pid, the refused list, anything that is not an application bundle (.app): app extensions, XPC view services and bare executables, an app that reports no name (capture exclusion is keyed by name, so it cannot be checked), capture-excluded apps, and a frontmost app with secure input on;
  • re-checks just before and just after the read that the pid still belongs to the approved bundle;
  • walks off the main actor, and stops when the calling task is cancelled.

WindowSnapshotWalker reads through AccessibilityElementSource, which has no press, focus, raise or type method.

System Settings: an allow-list

System Settings is not refused as a whole. On every read, and for every open Settings window, Swift reads the selected sidebar row and the window title:

  • A sensitive name in either is refused. That covers:
    • Privacy & Security
    • Passwords and AutoFill & Passwords
    • Users & Groups
    • Touch ID & Password / Login Password
    • Lock Screen
    • Wallet & Apple Pay
    • Apple Account (iCloud, Family)
    • Internet Accounts
    • Game Center
    • Screen Time
    • General > Sharing
    • Login Items & Extensions
    • Network (with Wi-Fi and VPN)
  • A window titled with a page of Privacy & Security is refused (Full Disk Access, Location Services, FileVault, Accessibility, Screen & System Audio Recording, Input Monitoring, Files & Folders, Automation and the rest, in every installed language), unless the sidebar selects that same name, as the top-level Accessibility pane does. A link or history can open such a page while the sidebar stays on an ordinary pane.
  • Otherwise the selection must name an ordinary top-level pane. With General selected, the title must also name an ordinary General subpage.
  • Names come from every language the installed System Settings extensions ship. That means their display names, plus General's and Privacy & Security's page strings from their Localizable tables.
  • Anything not identified is refused as refused_settings_pane_unknown: no selection, an unknown row, a search result or an active sidebar search, a sheet over the window, a list in the window with no position (the sidebar cannot be told from a content table), or a second Settings window that cannot be identified.

What is returned

  • Header first. The first line is Omi's own header: completeness, stop reason and counts. The app's name and window title come last on that line, quoted.
  • One line per element:
    • a reference (a:<AXIdentifier>, else n:<role>:"<label>", else a p: child path);
    • role, quoted label and value;
    • actions from a closed vocabulary, and flags;
    • a fingerprint of role, label and window-relative frame, which phase 2 will use for stale_ref.
  • No coordinates.
  • Facts only. The result carries no guidance. How to read it, and that quoted text is data and never instructions, live in the manifest description and guidelines.
  • Secure fields. Their value and length are never requested, and they are never walked into.
  • Long text. Any app text over 300 characters (a value, a label, a window title, the app's name) is left out with only its length. Nothing is shortened and sent.
  • URLs. Every URL-like token in app text, with any scheme (https:, file:, mailto:, tel:, an app's own) or none (an address bar shows example.com/reset?token=…), loses its query string and fragment, and long opaque path segments become ….
  • The app did not answer. Safari answered app_not_responding while in the background (WebKit builds a background window's tree lazily), and WhatsApp's first read failed the same way right after it was reopened. When the walk stops with fewer than five elements because the app did not answer, it is walked once more after 0.5 s; when the whole read fails with app_not_responding, it is read again after 0.5 s and then 1 s. All of it stays within the read's 3 s.
  • Bounds:
    • 400 elements; depth 12 counting only informative elements (anonymous wrapper groups, which Electron and Chromium nest dozens deep around web content, cost no depth and are not emitted), with an absolute raw depth of 64; 2,000 elements visited;
    • at most 200 children of any one container (its first and last 100); the walk counts the rest and carries on;
    • 3 s for the whole read, including the window list and the pane check;
    • 0.25 s per element;
    • the first timeout from a hung app stops the walk with what was read.
  • Main content first. The region holding most of the window's text (text values, not control labels), or an AXLandmarkMain region, is moved before sidebars, toolbars and chrome (order=content_first in the header), so a chat's messages are in view before its channel list. References and paths are unchanged.
  • Omission notice. When the kernel cuts the result to the model budget, the projection opens with what was left out and points at search_tool_output. This is a generic result-contract option (omissionNoticeFirst) that only ui_snapshot sets, and the guidelines tell the model to search before saying something is not in the window.
  • Sparse. The header says sparse=true and a reason when there are fewer than five elements, none actionable, or a depth-cut walk that found almost nothing beyond window chrome. With the Electron or Chromium switch on, a sparse first pass is read once more. The log records the switch, whether it took, and the retry (assistive_tree=electron enabled=… retried=…), shape only.
  • Budget. The result fits the 8 KB model budget; the rest is reachable with search_tool_output.

Electron and Chromium

These apps expose their elements only to an assistive client. After the person has approved the app, and only then, ui_snapshot sets:

  • AXManualAccessibility on an Electron app;
  • AXEnhancedUserInterface on a Chromium app.

The switch is the only write the tool makes, and it does not outlast the read. On every exit (success, failure, timeout, cancellation) it is turned back off, but only if Omi turned it on and it is still on, so another assistive tool's switch is left alone and a read leaves no lasting change in the other app. A sparse first pass after turning it on is read once more. The app is recognised from its own bundle layout.

Card

The card reads "Read an app window" and asks "Let Omi read the window of TextEdit?", with an App row "TextEdit · com.apple.TextEdit".

  • The name comes from Omi's own LaunchServices lookup, never the model. It is made display safe and kept to letters, digits, spaces and plain punctuation, so ·, look-alike separators (•, ∙, ⋅, ‧, ・) and invisible format characters become spaces, and it is bounded to 40 characters. U+200E LEFT-TO-RIGHT MARK, which an app may start its own name with, and one U+200F at the very start of the name (a right-to-left locale's labels start with it, as an English one's start with U+200E), are dropped first (WhatsApp's display name does, and the card showed it as ⟨U+200E⟩WhatsApp); every other invisible or bidirectional character (a zero-width space, a word joiner, a byte-order mark, an override, an isolate, a right-to-left mark) is still shown as its code point, so a look-alike name cannot hide one. InvisibleFormatMarks.labelContainsBidiControl applies the same rule to labels for phase 3's card: one leading direction mark is the label's own, any other right-to-left mark is a bidirectional control. Snapshot lines drop the harmless marks (U+200E, U+200B, U+2060–U+2064, U+FEFF, the soft hyphen, U+180E) and the bidirectional controls, as before, now through one shared InvisibleFormatMarks type that phases 2 and 3 use too. That type counts the right-to-left mark (U+200F) and the Arabic letter mark (U+061C) as bidirectional controls, not harmless marks: each is a strong right-to-left character that moves the digits and spaces beside it ("Acct 1234 \u200F5678" can read "5678 1234"), so phase 3's commit card offers only Deny for text holding one.
  • The bundle id stays beside the name, so a look-alike name cannot hide which app is read.
  • The preview shows only the window title and window number; the pid and element limit stay off the card.
  • The note under the answers names the answer it is about, in one pattern for every tool that offers a chat grant: "Allow for This Chat also allows reading any window of TextEdit for the next hour." (send_message: "… also allows any message or attachment to this recipient …"). capture_screen offers Allow Once only and shows no note.

capture_screen leaves out the same apps

The fifth commit (fix(desktop): …) makes capture_screen leave out Omi itself, the apps on the same generated safety floor and the person's capture-excluded apps. It takes the image through a ScreenCaptureKit filter that removes those apps' windows, so a terminal that is frontmost or fills the screen is simply not in the picture. If the filter cannot be used, the plain display capture is taken only when none of those apps has a window on screen; otherwise nothing is captured. The ChatToolExecutor.swift call site changes in place, so the file does not grow.

What the floor does not cover

The floor refuses apps, not screens. Credential UI hosted inside an app the person approved is read like any other content, apart from secure fields. Examples are a sign-in sheet, or a browser's saved-password page once it is revealed. The per-app card is the control there.

Open questions for maintainers

The issue's Q3 and Q4 bear directly on this PR.

  1. Is per-app the right scope for Allow for This Chat, or should it be per window?
  2. Is the refused list right, especially refusing terminals outright?
  3. Is requiring bundle_id acceptable, given the kernel cannot scope a pid?
  4. Is 300 characters the right cut for long text?
  5. Should capture-excluded apps be refused before the card? Today Swift refuses them after Allow, because exclusions are keyed by display name and live in Swift.

Known interactions (not changed by this PR):

  • A System Settings pane opened by deep link (x-apple.systempreferences:…) can open with no sidebar row selected. The allow-list then refuses it as refused_settings_pane_unknown until a sidebar row is selected. This is deliberate: a title alone can't tell the top-level Accessibility pane from Privacy & Security → Accessibility.
  • A typed message that mentions "my screen" can still attach a recent screen-history frame through the existing Screen Sharing in Chat setting (ScreenContextFallbackPolicy). That path predates this stack and is separate from capture_screen, which now always asks first.

Product invariants affected

INV-AGENT-* (product/invariants/agent-control-plane.md) is named because desktop/macos/agent/src/runtime/** changes.

  • Authority. The kernel authorizes and parks every ui_snapshot call through the existing approval set. Swift runs it only as authorized_tool_execution, behind the owner bind.
  • Lifecycle. The tool is read_only / safe_retry, so a restart has no write to replay.
  • No Swift prompt fragments. The Swift result carries facts only. All guidance is in the manifest description and guidelines.
  • Guard tests:
    • device-tool-policy.test.ts:
      • dispatch is required although the tool only reads;
      • a grant covers one app;
      • unscoped, expired, act and messaging grants never cover a read;
      • refused and malformed ids are hard denies;
      • System Settings reaches the card.
    • ui-automation-safety-floor.test.ts:
      • every refused id is lowercase and well formed;
      • Apple's credential surfaces, the AutoFill and passkey view services and the sensitive panes' own extensions are refused;
      • the projected header comes before app text, and a forged title stays inside its quotes.
    • run-tool-capability.test.ts:
      • the call parks with desktop.automation.observe and the lowercased id;
      • a session grant covers that app only; wildcard and act grants do not;
      • a refused app writes no dispatch or ledger row;
      • an unknown key is invalid_tool_input;
      • every parked tool is timeoutClass: "long".
    • control-tools.test.ts: allow for this chat mints an observe grant for exactly that app.
    • desktop-tool-policy.test.ts: the card copy.

INV-CHAT-1 (product/invariants/chat-continuity.md) is named because desktop/macos/agent/src/runtime/** and desktop/macos/Desktop/Sources/Chat/** match its globs. It is unaffected: nothing writes to the transcript, and the approval store change only resolves an app name when a frame arrives.

INV-INT-1 (product/invariants/integrations.md) is named deliberately, though no glob matches.

  • This is a general harness over Accessibility with no per-app click logic.
  • The refused list and the System Settings pane lists are a safety floor. The pane check is an allow-list built from the installed extensions' own localized names, so a rename or an unknown pane is refused rather than let through.
  • The Electron/Chromium check reads the app's runtime from its own bundle layout.
  • CloudConnectorFormAutomation is untouched.
  • The tool's guidelines tell the model to prefer a dedicated tool, an API or the browser tools, and to act in the app the person names: send_message, read_message_history and list_message_chats are for Apple Messages only (their own guidelines say so too), and WhatsApp and other Catalyst apps have no dedicated tool, so they are read with ui_snapshot. In phase 3's retest, "Send the draft in WhatsApp" read Messages history first; omi-tool-manifest.test.ts now holds this wording.

INV-UI-1 and INV-UI-2 (product/invariants/brand-ui.md, product/invariants/desktop-ux-contract.md): the change is card copy and one target row only. It uses the existing Ink, OmiType, OmiSpacing and OmiButtonStyle tokens and no new component. check_desktop_ux_contract.py reports no hand-rolled UX increase.

INV-BETA-1, in passing: every com.omi.* bundle is refused, so Beta never reads stable or a dev build.

How it was verified

All commands were run from desktop/macos at the branch head, on macOS 27 with Xcode 27.

  • Swift build. xcrun swift build -c debug --package-path Desktop: clean.
  • Swift tests. xcrun swift test --package-path Desktop --filter '<suites>': 278 tests, 0 failures (at cd8227a). After the invisible-marks commit, the whole suite at this head, xcrun swift test --package-path Desktop --skip SubscriptionEntitlementServiceTests (that suite posts a global sign-out notification and traps the test process in a whole-suite run, here and at cd8227a; alone it passes): 7,961 tests, 10 skipped, 3 failures, none from this change: RecentScreensMenuContentTests.testDayOldFrameShowsACalendarDateNotAClockTime (this Mac's date locale), DesktopAutomationWindowPresentationTests.testQuietPresentationAlsoParksNonactivatingOverlayPanels (display-dependent) and MeetingScreenEvidencePassTests.testRetryAfterFinalizeJoinsTheRunTheDeadlineLeftInFlight (timing-dependent) fail the same way at cd8227a, built and run in the same session. After the second review's two commits, the whole suite at this head (265095a), with the same skip: 7,963 tests, 10 skipped, 2 failures, the first two above (the meeting test passed this time). After the third review's commit (this head, 768c15e), the focused suites pass, 113 tests with 0 failures (WindowSnapshotWalkerTests 24, two new for the read again after the app did not answer, a walk that stopped and a read that failed, with a hung app read at most twice and an app reopened a moment late caught by the second wait; ApprovalCardTests 34, one new for a Hebrew and an Arabic label starting with U+200F; WindowSnapshotSafetyTests, ChatToolExecutorUISnapshotTests, SettingsPaneGuardTests, UIAutomationTargetPolicyTests, CaptureScreen*), and the whole suite ran at the top of the stack, phase 3, which holds this branch's Swift unchanged.
    • New suites:
      • WindowSnapshotWalkerTests 22 (one for the harmless marks)
      • WindowSnapshotSafetyTests 18
      • CaptureScreenExclusionTests 3
      • UIAutomationTargetPolicyTests 10
      • SettingsPaneGuardTests 9 (including one check against this Mac's installed Settings extensions)
      • ChatToolExecutorUISnapshotTests 11
    • Changed: ApprovalCardTests 33 (9 new for ui_snapshot, three of them for the marks in an app's name and the right-to-left and Arabic letter marks, plus the stack's Allow-Once-only capture_screen card test and its Clear Chat seam test).
    • Existing, rerun: CaptureScreenToolTests 4, ScreenCaptureRetentionTests 2, AXAttributeCastingTests 4, AccessibilityProcessBoundaryTests 4, AuthorizedToolExecutionTests 15, ToolSurfaceLiteralsTests 1, ChatDiscoverabilityTests 25, ToolCallStatusTests 31, ChatStallWatchdogTests 10, AgentRuntimeProcessTests 74, ChatToolExecutorPolicyTests 5, ScreenCaptureTargetPolicyTests 2.
  • Live pane-guard probe. A probe built from these sources ran the live pane guard against this Mac's /System/Library/ExtensionKit/Extensions:
    • refused: German "Anmeldeobjekte & Erweiterungen" under Allgemein, French "Ouverture et extensions" under Général, Finnish "Jakaminen" under Yleiset, a Full Disk Access search result, an unknown pane, and AutoFill & Passwords;
    • refused with Sound or German Ton selected in the sidebar: the titles Full Disk Access, Location Services, FileVault, Accessibility, Festplattenvollzugriff and Bedienungshilfen;
    • allowed: Sound, German Ton, General > About, German Allgemein > Info, Battery, Displays, and the top-level Accessibility, Bluetooth and Focus panes.
  • Tool surfaces. ./scripts/test-tool-surfaces.sh at this head: agent build, generate-tool-surfaces.mjs --check ("all outputs match"), npx vitest run 74 files / 981 tests passed (one new for the Messages-only routing wording), pi-mono-extension 143/143; the same at 768c15e.
  • Lint and contract checks:
    • check_desktop_ux_contract.py: no hand-rolled UX increase.
    • scripts/swiftlint-wrapper.sh lint: 0 violations.
    • OK: check_desktop_test_quality.py, check_agent_doc_references.py, check_agents_md_lean.py, desktop-flow-lint.py, desktop-changelog.py validate.
    • The new Swift files are covered by the manual-tier flow e2e/flows/ui-snapshot-approval.yaml.
  • Preflight. Against the stack parent with the stack body: scripts/pr-preflight --base feat/device-tool-descriptions -> "PR preflight passed: 35 checks" (64 files, base 703fd95; 65 files at this head). Against main with this body: scripts/pr-preflight --base upstream/main (d3208a0) -> "PR preflight passed: 40 checks" (120 files, the stack included). At a1640d6, and again at 265095a and at this head (768c15e), the pre-push gate against upstream with this body: OMI_PR_BODY_FILE=<this body> PRE_PUSH_BASE_REMOTE=upstream scripts/pre-push-singleflight upstream -> "PR preflight passed: 40 checks" (121 files, merge base d3208a0) and "Bounded pre-push gate passed"; against the stack parent with the stack body at this head, 35 checks (65 files).

Packaged-app manual pass (the person's live run on the packaged test build, 2026-10-09/10, production servers, macOS 27). No app came to the front and the cursor never moved on any read.

Check Result
TextEdit Read (25 nodes) after Allow Once; Allow for This Chat, then a third read ran with no card
Notes Read (48 nodes); a second app in the same chat asked again
Messages (Catalyst) Read (68 nodes)
Slack (Electron) First read was window chrome only (26 nodes, depth_limit); after wrapper groups stopped costing depth, 234 nodes with the channels; after content-first ordering the model quoted recent messages
Terminal, Passwords Refused before any card
System Settings, readable Sound, General > About, top-level Accessibility
System Settings, refused Privacy & Security, its Screen & System Audio Recording page, General > Sharing, General > Login Items (refused_settings_pane); Full Disk Access via search (refused_settings_pane_unknown)
Deny Ran nothing
capture_screen Card offered Allow Once and Deny only; Deny saved no file; with Terminal on screen the image left Terminal out (capture_screen: leaving out 12 app(s))

The Electron switch restore (turned back off after every read) came after this pass and is covered by unit tests.

Not yet verified:

  • System Settings in a language other than English on the packaged build; covered only by the unit tests and the probe against this Mac's installed extensions.
  • Some third-party refused bundle ids come from each app's Homebrew cask. Two are unverified: Dashlane (no current cask; its commonly documented id) and WezTerm (its cask names no domain). Apple's ids, Termius and cmux were read from the installed bundles.

Tests

Core path. WindowSnapshotWalkerTests and WindowSnapshotSafetyTests run against fixture trees shaped like real apps:

  • an AppKit document window;
  • a Notes-like outline with a long body;
  • a Catalyst messaging window with buttons in groups and the body in AXDescription;
  • an Electron window that is empty until its assistive tree is on;
  • a sign-in form with a secure field;
  • a web area holding a 2,100-line group between two buttons;
  • a 20-deep chain;
  • duplicate identifiers and names;
  • hostile labels and window titles;
  • a hung element.

They check:

  • each cap stops with its own stop_reason, and a huge group no longer ends the walk;
  • a Slack-shaped window (toolbar, 40 channels, 30 messages) puts the messages first with order=content_first, and a short document keeps document order;
  • a Slack-shaped tree with channels and messages under 25+ anonymous wrapper levels is read in full, the absolute raw depth cap still holds, and a depth-cut, chrome-only result is marked sparse;
  • a secure field's value and length are never requested;
  • the harmless invisible marks apps put around their labels (WhatsApp's U+200E, and U+200B, U+2060, U+FEFF) are dropped from snapshot lines, as bidirectional controls (U+200F and U+061C among them) are; an app's own name on the card drops only U+200E, while a zero-width space, a byte-order mark, a right-to-left mark, an override or an isolate in it still shows as its code point; the right-to-left and Arabic letter marks are bidirectional controls and never dropped as harmless;
  • long labels and window titles are left out;
  • URL-like tokens in values, labels and window titles lose their queries, with any scheme (including mailto:, file:, tel:) or none;
  • cancellation stops the read;
  • the deadline covers window listing;
  • the Electron and Chromium switches are turned back off after a successful, failed or cancelled read, and left alone when another client had turned them on;
  • only the assistive switch is ever written.

Refusals.

  • UIAutomationTargetPolicyTests covers each refusal: own pid, com.omi.*, each refused category, an app extension, an XPC service, a bare executable, an unnamed app, a capture-excluded name, and secure input.
  • SettingsPaneGuardTests covers:
    • allowed panes in English and German;
    • Privacy subpages refused by the sidebar, and by their title whatever the sidebar selects, in English and German;
    • a sidebar with no position refused;
    • General > Sharing / Login Items / AutoFill & Passwords refused in English, German, French and Finnish;
    • unknown General subpages, search results, active searches, unknown rows, no selection and sheets refused;
    • a second, unidentifiable Settings window refused;
    • the catalog read from extension bundles.

capture_screen. CaptureScreenExclusionTests covers which apps are left out (Omi, com.omi.*, refused ids, capture-excluded names), the filter's application list, and the rule that the plain capture stands in only when no left-out app is on screen.

Executor. ChatToolExecutorUISnapshotTests covers:

  • a missing permission asks for Accessibility;
  • the result shape;
  • refused and excluded apps are never read;
  • ambiguous and mismatched targets;
  • a pid that changed owner;
  • cancellation reaching the detached read;
  • window choice;
  • NaN and 1e100 are clamped;
  • arguments are logged by shape;
  • the chat dispatch runs behind the owner bind.

No test touches a real app.

UI evidence

Captured in the packaged named bundle built from f6951e094a, signed in to a fresh account with an empty chat, through the bridge's in-process window capture (capture_main_window_png) while driving the cards with omi-ctl; captured_by: agent, source: visual-audit in .agent-artifacts/ui-evidence/desktop-ui-snapshot-card/evidence.json. The TextEdit window was a scratch document containing only hello snapshot test; Notes was never opened and its card was denied.

These captures predate a fix in #20997 that moved the card's field labels, covers note and pending status line, and the tool row's "Waiting for your approval" caption, from Ink.tertiary (under WCAG AA on the glass panel) to Ink.secondary; those runs now draw one step darker, and the images have not been recaptured.

  • TextEdit card pending: app row, Allow Once, Allow for This Chat (1 h), Deny, the covers note and the expiry line: .agent-artifacts/ui-evidence/desktop-ui-snapshot-card/001-desktop-macos-ui-snapshot-textedit-pending.png
  • The same card after Deny, "Denied, not run": .agent-artifacts/ui-evidence/desktop-ui-snapshot-card/002-desktop-macos-ui-snapshot-textedit-denied.png
  • A second TextEdit card after Allow for This Chat, "Allowed for this chat until …", with the reply quoting hello snapshot test: .agent-artifacts/ui-evidence/desktop-ui-snapshot-card/003-desktop-macos-ui-snapshot-textedit-allowed-for-chat.png
  • A third TextEdit read in the same chat runs with no new card: .agent-artifacts/ui-evidence/desktop-ui-snapshot-card/004-desktop-macos-ui-snapshot-textedit-no-card-in-chat.png
  • Notes in the same chat asks again with its own card: .agent-artifacts/ui-evidence/desktop-ui-snapshot-card/005-desktop-macos-ui-snapshot-notes-pending.png
  • Terminal refused with no card: .agent-artifacts/ui-evidence/desktop-ui-snapshot-card/006-desktop-macos-ui-snapshot-terminal-refused.png
  • System Settings card pending: .agent-artifacts/ui-evidence/desktop-ui-snapshot-card/007-desktop-macos-ui-snapshot-settings-pending.png
  • System Settings allowed for the chat (the Sound read that followed is above the card list, out of frame): .agent-artifacts/ui-evidence/desktop-ui-snapshot-card/008-desktop-macos-ui-snapshot-settings-allowed-for-chat.png
  • capture_screen card with only Allow Once and Deny: .agent-artifacts/ui-evidence/desktop-ui-snapshot-card/009-desktop-macos-capture-screen-pending.png

Not captured: the Privacy & Security, Sharing and Login Items refusals. Resolved cards stay at the live edge of the thread, so by then the card list filled the window and pushed each reply out of view.

Failure class (fixes)

Failure-Class: none

Line-Count-Exception: desktop/macos/Desktop/Sources/Providers/ChatToolExecutor.swift | 3961 -> 3971 | the ui_snapshot dispatch case with its owner bind and two-line comment; the executor lives in ChatToolExecutor+UISnapshot.swift

…ool policy

A dispatch_required decision for a sensitive on-device tool now has a policy-owned shape for what the user is asked: the capability, operation and resource the grant would be matched against, a bounded display preview of the input (attachment paths reduced to a file name, text and scripts capped at the 4 KiB card bound with a truncation flag the broker fails closed on), and the answers. Deny is the default; allow_once is always offered; allow_session is offered only when there is an exact resource for a grant to cover and carries plain words for what it would cover, so a script grant is shown as covering only that exact script. The 180s expiry matches the mobile device-tool transport and stays under the pi-mono long tool wait minus the Swift executor timeout, so a late answer cannot run an effect the model already reported as failed. Only this module decides the question and the options; adapters and the model never choose allow or deny.
@Git-on-my-level

Copy link
Copy Markdown
Collaborator

Reviewed this statically against the diff. This is careful work — the single-source refusal floor (ui-automation-safety-floor.ts code-generated into GeneratedUIAutomationSafetyFloor.swift so the kernel and Swift can't drift), the pid re-verification before and after the read in ChatToolExecutor+UISnapshot.swift, the allow-list SettingsPaneGuard re-run on every read, never requesting secure-field values or lengths, omitting rather than truncating long text, stripping URL query strings, and keeping coordinates out of the model-facing payload are all the right shape for this surface. The kernel-side split (desktop.automation.observe as its own sensitive bundle in desktop-tool-policy.ts, grants resource-scoped to one bundle id, model unable to name the resource ref in run-tool-capability.ts) also looks sound. No concerns found in relay-tool-approvals.ts's restart/replay handling.

Leaving this for human maintainer review rather than approving — it's stacked on the approval chain (#20973 → #20975 → #20997 → #20998), it's a first PR from this account, and it's a privacy-sensitive design decision that has real open questions, several called out in the PR body:

  1. Product scope: per-app vs per-window for "Allow for This Chat"; whether refusing terminals outright is right; bundle_id-only targeting; the 300-character omission cut; capture-excluded apps being refused after Allow rather than before the card. These are rulings, not code fixes.
  2. Floor boundary (disclosed in the PR body, worth an explicit sign-off): the floor refuses apps, not credential UI inside an approved app — a revealed passwords page in an approved browser is read like any other content. The per-app card is the only control there.
  3. Persistent side effect in a read tool: AXManualAccessibility stays on for an Electron app's lifetime after one approved read (AssistiveTreeMode.electron in WindowSnapshotWalker.swift; Chromium's switch is restored, Electron's is not). Documented, but it's a behavior change to another app that a maintainer should consciously accept.
  4. Private API: _AXUIElementGetWindow via @_silgen_name — this PR actually improves things by consolidating the declaration that ScreenCaptureService.swift already carried into AXWindowIdentity.swift, but it remains a private-API dependency with fallbacks.
  5. Verification is author-local: the extensive test/verification write-up is from a local macOS 27 / Xcode 27 environment; CI here shows no executable checks. The promised packaged-app manual pass (TextEdit/Notes/Catalyst/Electron, Terminal and Passwords refusals, System Settings matrix, Deny, session-grant re-read) should land in the PR description before merge.

One more thing maintainers should look at directly: this PR changes agent-facing guidance and model-facing tool copy — desktop/macos/AGENTS.md (observe added to the dispatch-gated surface line), .github/agent-docs/agent-grants-approval-ux.mdx (status moves from design-only to describing the live resolve_desktop_dispatch grant path), desktop/macos/docs/device-tool-surface.md (+244 lines on the approval lifecycle and refusal floor), and GeneratedToolCapabilities.swift, where the copy flips from "requires an approval dispatch" to "Call it directly: Omi asks the person to approve it in the app". That last change alters when the live agent decides to call sensitive tools; it's consistent with the new parking flow, but it deserves conscious sign-off together with the security surface. The guidance I sampled matches the implemented behavior.

Thanks for the exceptionally thorough PR description and evidence artifacts — it made review much easier.


by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with need human response.

@Git-on-my-level Git-on-my-level added needs-maintainer-review Needs a human maintainer to sign off before merge security-review Touches auth, provider routing, secrets, or security-sensitive surfaces macOS labels Oct 10, 2026
@sauranshbhardwaj

Copy link
Copy Markdown
Contributor Author

Thanks for the careful read. Taking your points in order:

  1. Product scope. Agreed, these are maintainer rulings, so I haven't treated any of them as settled. The PR ships one default for each, and every one is a small change if you'd rather go the other way:

    • "Allow for This Chat" is per app, not per window.
    • Terminals and shells are refused outright.
    • Targets are named by bundle id only.
    • Any text over 300 characters is replaced by its length.
    • A capture-excluded app is refused after Allow rather than before the card. The exclusion list is keyed by display name, so the kernel can't check it before the card yet.

    The same questions are open on Desktop: let the Mac agent use apps by their named buttons and fields: scoped per app, approved per send, verified before "done" #21096.

  2. Floor boundary. Yes, that's deliberate, and the PR body says so. The floor refuses whole apps and secure fields. Credential UI inside an app the person approved, such as a revealed passwords page in a browser, is read like any other content, and the per-app card is the control there. If you'd like a stricter default, one option is refusing a read while the focused window is a known password or account page. I'd add it as its own follow-up.

  3. Electron side effect. Changed in this update. AXManualAccessibility is now put back after every read, matching what we already do for Chromium's switch. Omi only resets it if it set the switch itself and the switch still holds Omi's value, so another assistive tool's setting is left alone. It's restored on every exit path, including errors, timeouts and cancellation, and the new unit tests cover each case. The read tool no longer leaves a lasting change in another app.

  4. Private API. Agreed. This PR adds no new private API. It moves the _AXUIElementGetWindow declaration that ScreenCaptureService.swift already used into AXWindowIdentity.swift, with the same fallbacks.

  5. Manual pass. It's now in the description: the packaged-app run on macOS 27 against production servers.

    • Read: TextEdit, Notes, Messages (Catalyst) and Slack (Electron).
    • Refused before any card: Terminal and Passwords.
    • System Settings: each ordinary and sensitive pane behaved as expected, including Full Disk Access reached through search.
    • Approvals: Deny ran nothing, and a session grant covered a re-read with no card.
    • Screenshots: capture_screen showed Allow Once and Deny only, and left a Terminal window out of the capture.
    • Background: no app came to the front, and the cursor never moved.

    The Electron restore change came after that pass and is covered by unit tests.

On the agent-facing guidance and docs: agreed that it needs a human read together with the security surface. The new copy ("Call it directly: Omi asks the person to approve it in the app") is only true once the parking flow in #20973 → #20997 is live. If you'd prefer, I can keep agent-grants-approval-ux.mdx marked design-only until the whole stack has merged, as I offered on #20997.

@sauranshbhardwaj
sauranshbhardwaj force-pushed the feat/mac-ui-snapshot branch 2 times, most recently from cc5d706 to 169d868 Compare October 10, 2026 21:03
… approval.requested

send_message, run_applescript, read_message_history, list_message_chats and list_mail_messages used to fail with approval_required whenever no scoped grant existed, and nothing in the app could create one. capture_screen did not fail at all: it took and shared a full-screen screenshot with no approval, although its own description promised one, so it now parks the same way under the stable resource "screen". A screenshot holds whatever is on screen, including windows the person keeps out of capture elsewhere, so each one is allowed once: its card offers no session grant, no grant row covers a capture, and the kernel refuses to mint a grant the card did not offer. The realtime voice screenshot tool is no longer advertised to pi-mono, where it sat ungated in every typed chat, pill and workstream run; realtime voice runs still get it through their surface projection. The capability broker now prepares the ledger row and inserts one approval dispatch bound to that exact invocation (id, input hash, daemon boot epoch) in the same transaction, the kernel moves the run and attempt to waiting_approval and appends run.waiting_approval plus approval.requested, and the broker refuses to dispatch the invocation until the dispatch is resolved. resolve_desktop_dispatch gains the two user answers: decision allow without a grant admits exactly this invocation once and mints nothing; allow with a grant whose runId is null mints a session-scoped grant through the existing insertGrant path, capped at 24 hours. Deny, cancellation, expiry, run termination and owner change fail the prepared invocation closed as failed, never outcome_unknown, each with its own approval.resolved, and the run resumes so the model receives approval_denied. A new kernel operation, endChatApprovalScope, ends what a chat granted when the person clears it: the main chat keeps one kernel session across clears, so it closes every approval still parked in that session and revokes every grant the person minted for it, with one grants.revoked audit event. Startup reconciliation expires any pending bound approval and records approval.resolved, so a restart never replays a parked call.

Every relay-callable tool is now classified on purpose (named in a policy set or declared unbundled, never by the read-only default), and a test holds the approval set to every relay-callable tool in a sensitive bundle, with each deliberate exception named and justified, so a new tool cannot ship unclassified or ungated by omission. Grants for desktop.context.screenshot_image must name their exact resource. The gated tools are held to their manifest schema at the kernel boundary and the resource a grant covers is derived only from the fields the tool acts on, so a model-supplied resource_ref can no longer become the grant pattern. A dispatch created through create_desktop_dispatch has its invocation payload key stripped and can never pose as a parked call. Authority is revalidated before a resolution is admitted and that check runs before the resolving transaction, so a revocation it triggers commits on its own; a rolled-back decision restores the broker's memory. The model still cannot resolve its own dispatch: the relay path keeps returning policy_denied without trustedUserControl.

Parking sits behind a kernel switch, desktopToolApprovalsEnabled, that defaults to off and is not reachable from the daemon in this change, so runtime behavior is unchanged until a client that renders approval cards turns it on. The Swift card PR flips the default and deletes the switch.
…ol calls

The stdio relay no longer answers a parked device tool call immediately. A new relay-tool-approvals module, taking the kernel and the frame and result writers, sends Swift an approval_requested frame carrying the dispatch id, the bound invocation, the policy preview, the options and the expiry, keeps the relay call pending, and settles it from the kernel's approval.resolved event: an allow decision hands the invocation to Swift through the ordinary authorized_tool_execution path exactly once, every other outcome returns approval_denied to the model. Every resolution is mirrored to Swift as approval_resolved so a card can close whatever ended it, including owner changes. Expiry fires from the runtime, a disconnected relay client cancels its own dispatch, and terminal run events fail parked calls like any other pending tool call. The Swift hand-off itself moves into the same module so the whole relay side is covered by hermetic tests with injected timers.

Clearing a chat through journal_clear_turns now ends what that chat granted before the journal is cleared: the kernel closes the session's parked approvals and revokes the grants the person minted for it, because the main chat keeps one kernel session across clears and an "Allow for This Chat" grant would otherwise cover the new conversation. No tool reaches this; only the app's own clear message does.

Parking is on only after a client declares desktop_tool_approval_cards through the new client_capabilities message; until the Swift card ships no client does, and the relay keeps today's immediate approval_required. The runtime advertises desktop_tool_approval_requests so that client can require it first. Realtime voice keeps today's behavior because its Swift tool wait is 180s and would race the approval TTL.
The pi-mono extension waits 30s for a normal Swift tool result and gives up with a timeout error. A human answering an approval card needs longer than that, and a late approval would then run a send the model had already reported as failed. The six device tools that park behind an approval, capture_screen among them, now declare the long timeout class, which the extension maps to its 10 minute wait, and a test holds every gated tool to it; the approval TTL of 180s plus the 120s Swift executor timeout stays well inside it. Swift does not consume timeoutClass outside the generated surfaces, so the regenerated files only carry the new manifest digests.
The approval UX design doc keeps its design status and points at the device tool surface doc for the runtime half that now exists; the Swift card, the Claude Code lane and the voice surface stay deferred and section 24 still applies. The device tool surface doc gains the ask-and-answer table, the schema and resource rules that stop the model naming what a grant covers, the session-grant options and their 24 hour cap, the restart and expiry behavior, and the interim client-capability gate. The changelog fragment is kind none because nothing is user-visible until the Swift card lands.
Render the kernel's approval_requested frame as a card in the main chat, workstream and agent pill threads, matched to the asking session, with the exact target, the content preview, the expiry and the answers the runtime offered; answer through signed direct control (allow once, allow for this chat with a 1 h session grant, deny) and close the card on approval_resolved whoever ended it. Declare desktop_tool_approval_cards after the handshake so the relay parks a sensitive call instead of failing it, require desktop_tool_approval_requests from the runtime, and add tool_approval_snapshot and answer_tool_approval bridge actions so omi-ctl can drive the card without Accessibility permission.
… stall

In the live run the chat's 90 s tool no-progress guard revoked the turn and interrupted the bridge while a send_message card was waiting for the person, so the kernel cancelled the run and the card read Cancelled, not run. Teach StallDetector a waitingOnUser state the turn's tick loop sets from the approval store: while a card for the session is pending nothing promotes to slow or stalled, no tool is ever overdue, the tool row reads Waiting for your approval, and when the card closes every clock restarts from that moment; the stop button still cancels. Give the hermes and openclaw ACP adapters the same rule, treating a run the kernel holds in waiting_approval as progress for their 150 s no-progress cancel.
Six in-process captures from the packaged named bundle: a parked send_message card, the same card after Deny, parked list_message_chats and list_mail_messages cards, and the Allowed once state after an answer, with evidence.json recording agent capture through the automation bridge.
The five sensitive device tools described themselves as always requiring an approval dispatch, so the in-app model answered a send request by calling create_desktop_dispatch instead of send_message; that dispatch has no card, nothing renders it, and it never expires. Now that the kernel parks a direct call behind the approval card, say so in the descriptions and capability guidance and tell the model to call the tool directly, including capture_screen, which is now gated the same way, and regenerate the Swift tool surfaces for the changed manifest digest.
Add the Swift core of ui_snapshot, not yet wired to any tool. WindowSnapshotWalker reads one window through an AccessibilityElementSource that has no press, focus, raise or type method, so it can only read; its one write is the app-level switch that makes an Electron (AXManualAccessibility) or Chromium (AXEnhancedUserInterface) app expose its elements; the switch is turned back off on every exit, only if Omi turned it on and it is still on, so a read leaves no lasting change in the other app. The walk keeps every labelled, valued or actionable element and walks through unlabelled containers; anonymous wrapper groups (Electron and Chromium nest dozens around web content) cost no depth, so the depth limit of 12 counts informative elements, with an absolute raw depth of 64; it stops at 400 emitted elements, 2,000 visited, a 3 s deadline that also covers the window list and the pane check, the calling task's cancellation, or the first timeout from a hung app, keeping what it read, and it reads at most 200 children of any one container (its first and last 100) and carries on with the rest of the window. It reads role and subrole first and never requests a secure field's value or length or walks into it, leaves out any app text over 300 characters (value, label, window title) with only its length, strips the query string and fragment of every URL-like token in app text (any scheme, including file: and mailto:, or none) and blanks long opaque path segments, prefers a list's visible rows, sanitizes and quotes app text so it cannot forge another line or field, and gives each element an a:/n:/p: reference plus a fingerprint from its role, label and window-relative frame. A result with fewer than five elements, none actionable, or a depth-cut walk that found almost nothing beyond window chrome is marked sparse with its reason, and with the assistive switch on a sparse first pass is read once more. WindowSnapshotOrdering then moves the window's main content first: the region holding most of its text (text values, not control labels), or an AXLandmarkMain region, goes before sidebars, toolbars and chrome, with references and paths unchanged, and the header says order=content_first. The result is Omi's header line first, with the app's name and window title last and quoted, then one line per element, and carries facts only. UIAutomationTargetPolicy resolves the one running copy of an approved bundle id without launching anything and refuses Omi itself, every com.omi.* build, the refused apps, anything that is not an .app bundle (app extensions, XPC view services, bare executables), an app with no name, capture-excluded apps and a frontmost app with secure input on. SettingsPaneGuard checks every open System Settings window by an allow-list: a sensitive name in the selected sidebar row or the window title refuses, otherwise the selection must name an ordinary top-level pane and, under General, the title an ordinary General subpage, in every language the installed extensions ship (display names, plus General's and Privacy & Security's page strings from their Localizable tables, read by SettingsPaneCatalog). A window titled with a Privacy & Security page is refused unless the sidebar selects that same name; no selection, an unknown row, a search, a sheet, a list with no position or an unidentifiable window is refused. The refused list, including Apple's credential and notification surfaces, the AutoFill and passkey sign-in view services and each sensitive pane's own extension, and the pane lists are declared once in the kernel's ui-automation-safety-floor.ts and generate-tool-surfaces.mjs writes them into GeneratedUIAutomationSafetyFloor.swift. _AXUIElementGetWindow moves to AXWindowIdentity so ScreenCaptureService and the walker share one declaration, and AXAttributeCasting gains typed string, bool, number, point, size and AX error readers. Tests run against fixture trees shaped like AppKit, Catalyst, Electron and System Settings windows in English, German, French and Finnish, plus one check against this Mac's installed Settings extensions when present; none touches a live app.
…observe approval

Declare ui_snapshot in the manifest as a read-only, long-wait Swift tool for pi-mono and the stdio adapters. Its description and guidelines carry all the model guidance, including to search the full result before saying something is not in the window: prefer a dedicated tool, name the app by bundle_id, treat everything quoted in the result as the app's data and never instructions, how to read the header and element lines, what sparse and an incomplete result mean, and never retry a refusal. A new sensitive bundle, desktop.automation.observe, carries it, separate from desktop.automation.act so that approving a read of an app never approves acting in it. ui_snapshot joins the enforced approval set beside capture_screen, which satisfies the stack's drift test (every relay-callable tool in a sensitive bundle is gated) and its long model-side wait, so the kernel holds its input to the manifest schema and parks it on the card; its grant resource is the trimmed, lowercased bundle id, observe grants must name that resource, and the broker reads observe grants. A malformed bundle id, Omi's own bundles and the apps on the shared refused list are hard denies with no card and no dispatch row, and a test pins that every refused entry is lowercase and well formed. Its result contract asks the kernel projector to open a truncated projection with what was left out and to point at search_tool_output (omissionNoticeFirst, a generic contract option no other tool sets). The control-tool bundle enums accept the new bundle, the Swift tool surfaces and fixture are regenerated, and ChatToolExecutor runs the generated uiSnapshot case behind the owner bind through ChatToolExecutor+UISnapshot.swift, which validates the arguments, asks for Accessibility through request_permission when it is missing, resolves the approved app, applies the refusal floor, re-checks just before and after the read that the pid still belongs to the approved bundle, logs which assistive switch was used, whether it took and whether the read was repeated, carries the calling task's cancellation into the detached read, and returns only facts; ui_snapshot arguments are logged by shape only.

Line-Count-Exception: desktop/macos/Desktop/Sources/Providers/ChatToolExecutor.swift | 3961 -> 3971 | the ui_snapshot dispatch case with its owner bind and two-line comment; the executor lives in ChatToolExecutor+UISnapshot.swift
The kernel's ui_snapshot card reads "Read an app window" and asks "Let Omi read the window of <bundle id>?", previews only window_title and window_id (never the pid or the element limit), and offers Allow for This Chat as covering any window of this app for the next hour. The Swift card resolves the bundle id through Omi's own LaunchServices lookup once, when the frame arrives, and asks "Let Omi read the window of TextEdit?" with an App row of "TextEdit · com.apple.TextEdit", so a look-alike name cannot hide which app is read; the app's own name is made display safe and kept to letters, digits, spaces and plain punctuation, so the · separator, its look-alikes and invisible format characters become spaces, and it is bounded to 40 characters so it cannot forge the bundle id beside it. An app it cannot resolve keeps the kernel's words and the bare bundle id, other tools' resources are never looked up as apps, and the window title passes through the card's display-safe rendering. The note under the answers now names the answer it is about, in one pattern for every tool that offers it ("Allow for This Chat also allows reading any window of TextEdit for the next hour.", and the same "Allow for This Chat also allows … for the next hour." for the message, script and read tools; capture_screen offers Allow Once only and shows no note), so the hour can no longer read as part of Allow Once. Window fields get plain labels. The card reuses the existing Ink, OmiType, OmiSpacing and OmiButtonStyle tokens and adds no component.
The device tool surface doc gains the ui_snapshot row and a Reading app windows section: the per-app desktop.automation.observe grant and why it is separate from act, why bundle_id is required, the shared refusal floor and where each half checks it, what the floor does not cover (credential screens inside an approved app), the System Settings allow-list that refuses sensitive and unidentified panes and Privacy & Security pages by title on every read in every installed language, the long-text, secure-field and URL-token rules, the facts-only result with its content-first order and the kernel's omission notice, the walk's bounds, and the Electron and Chromium assistive switch as the tool's only write, set only after the person approved the app. The macOS agent guide's on-device bullet and the approval UX design doc's list of parked tools name the new tool, a manual-tier e2e flow (ui-snapshot-approval) records the packaged-app check, including a non-English System Settings pass and a Privacy deep link, and covers the new Swift files, and a changelog fragment describes the change for people.
…screen

capture_screen grabbed the whole display, so a screenshot the person approved could still show Terminal, a password manager, Keychain, a sign-in prompt, Omi itself or an app they had excluded from capture, the same apps ui_snapshot refuses to read. CaptureScreenExclusion now works out those apps from the shared generated safety floor plus the person's capture exclusions and Omi's own process, and capture_screen takes its image through a ScreenCaptureKit filter that removes their windows, so one that is frontmost or fills the screen is simply absent. When the filter cannot be used, the plain display capture is taken only if none of those apps has a window on screen, and otherwise nothing is captured. The selection, the filter's application list and the fallback rule are tested without a display; the device tool surface doc, the manual flow and a changelog fragment describe it. The ChatToolExecutor call site swaps the synchronous capture for the async one in the same four lines, so the file does not grow.
Nine in-process captures of the main chat from the named bundle built from f6951e094a, signed in to a fresh account: the TextEdit card pending, denied, and allowed for the chat with the read quoting the scratch document, a third read running with no card, a Notes card asking again in the same chat, the Terminal refusal with no card, the System Settings card pending and allowed for the chat, and the capture_screen card offering only Allow Once and Deny, with evidence.json recording agent capture through the automation bridge.
@Git-on-my-level

Copy link
Copy Markdown
Collaborator

Reviewed the ui_snapshot work on head cd8227a. This is a careful, defensively-designed implementation; notes from the pass, then the items a maintainer should weigh.

What checked out:

  • desktop/macos/agent/src/runtime/ui-automation-safety-floor.ts and GeneratedUIAutomationSafetyFloor.swift: one source of truth for the refusal floor (Omi builds, terminals, password managers, credential and keychain prompts), generated into Swift and enforced twice - the kernel refuses before a card appears, and the executor checks the same floor against the process it actually reads.
  • desktop-tool-policy.ts: ui_snapshot gets its own desktop.automation.observe bundle, so a read grant never counts as an act grant; grants are resource-scoped; capture_screen is allow-once-only with no session grant; and relay tools must now be explicitly classified rather than falling into local_read unnoticed.
  • kernel-coordinator.ts: the invocation-bound approval dispatch, the waiting_approval run/attempt transition and the approval.requested event commit together; a forged "invocation" payload key is stripped from model-created dispatches; resolution requires an explicit allow/deny; dispatch-minted grants are capped at 24h.
  • relay-tool-approvals.ts: parked calls fail closed on expiry, relay-client disconnect, owner change, run termination, and daemon restart (startup reconciliation expires the dispatch rather than replaying it).
  • ChatToolExecutor+UISnapshot.swift: the approved bundle id is treated as untrusted text, the one running copy is resolved without launching anything, the pid's bundle id is re-verified immediately before and after the read (pid-reuse guard), model-supplied numbers are clamped, and the walk runs off the main actor with cancellation.
  • WindowSnapshotWalker.swift: secure fields expose neither value nor length; text over the limit is omitted with only a character count; element coordinates feed the fingerprint only and never reach the model.
  • SettingsPaneGuard.swift: System Settings reads use an allow-list - a pane is read only when positively identified as ordinary, in any shipped language; unknown panes, search results, sheets and General's sensitive subpages are refused, and the check reruns on every read.

For a maintainer to weigh:

  1. AXWindowIdentity.swift binds _AXUIElementGetWindow via @_silgen_name. The fallback path exists and the comment explains why, but private-API use is a distribution-policy decision that should be made deliberately.
  2. WindowSnapshotWalker.swift temporarily turns on AXManualAccessibility / AXEnhancedUserInterface in the target app (Electron/Chromium) and restores it on every exit only if Omi set it. The logic looks right; toggling another app's accessibility state still deserves owner eyes.
  3. On this head, Desktop Swift Build & Tests and Agent Runtime are green, but Desktop Swift Release Compile, Static Contracts and Generated Files are skipped - while GeneratedToolCapabilities.swift / GeneratedToolExecutors.swift are regenerated here. A run with those checks enabled would be worth having before merge.

Agent-guidance docs: this PR updates .github/agent-docs/agent-grants-approval-ux.mdx (its status flips from "design-only, do not treat as implementation scope" to describing the now-live approval dispatch), desktop/macos/AGENTS.md (observe joins the dispatch-or-grant rule), desktop/macos/docs/device-tool-surface.md, and the generated tool bullets now tell the model to call sensitive tools directly because the app asks the person to approve. That guidance matches the implemented card behavior as verified in the kernel and relay, but since these files steer AI coding/review agents in this repo, maintainer confirmation of the copy is warranted.

The committed UI-evidence PNGs under .agent-artifacts/ui-evidence/ follow the existing convention; I could not verify image contents.

Leaving for human maintainer review: this opens a privacy-sensitive surface (Accessibility reads of other apps' windows, with content sent to the model) that needs maintainer sign-off, and it is PR 1 of a stacked sequence (#20973 -> #20975 -> #20997 -> #20998 -> this), so merge-order decisions belong to a human.


by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with need human response.

@Git-on-my-level Git-on-my-level added the positive-signal Automation verified a genuine fix/quality contribution label Oct 11, 2026
… own words

WhatsApp starts its display name and nearly every label with U+200E LEFT-TO-RIGHT MARK, so the approval card showed the app as ⟨U+200E⟩WhatsApp. One InvisibleFormatMarks type now names the harmless marks (U+200E, U+200F, U+061C, U+200B, U+2060-U+2064, U+FEFF, the soft hyphen, U+180E) and the bidirectional controls apart; the card's app name drops the harmless ones and still shows an override or isolate as its code point, and snapshot lines drop both as before.
… controls and show zero-width marks in the card's app name

U+200F and U+061C are strong right-to-left characters, so in a left-to-right line they move the digits and spaces beside them ("Acct 1234 <RLM>5678" can read as "5678 1234"); InvisibleFormatMarks now counts them with the embeddings, overrides and isolates instead of dropping them as harmless. The card's App row drops only U+200E, the mark WhatsApp starts its own name with, and again shows U+200B, U+2060-U+2064, U+FEFF and the rest as code points, so a look-alike app name cannot hide one. Snapshot lines drop the same set of characters as before.
…d app with ui_snapshot

In a live test "Send the draft in WhatsApp" made the model list Messages conversations and read Messages history first. The ui_snapshot guideline now says to act in the app the person names, that send_message, read_message_history and list_message_chats are for Apple Messages only, and that WhatsApp and other Catalyst apps have no dedicated tool; each Messages tool's own guidelines say the same. The docs and the ui_snapshot flow gain a step for it.
…ep one leading direction mark as a label's own

Safari answered ui_snapshot with app_not_responding while in the background because WebKit builds a background window's Accessibility tree lazily, and WhatsApp's first read failed the same way right after it was reopened. When the walk stops with fewer than five elements because the app did not answer, it is walked once more after 0.5 s; when the whole read fails with app_not_responding, it is read again after 0.5 s and then 1 s; all within the same 3 s deadline. A right-to-left locale starts its labels with U+200F the way an English one starts them with U+200E, so InvisibleFormatMarks treats one U+200E or U+200F at the very start of a label as the label's own (strippingLeadingDirectionMark, labelContainsBidiControl) and the card's app name drops it; any other right-to-left or Arabic letter mark, embedding, override or isolate is still a bidi control and shows as its code point.
@sauranshbhardwaj

Copy link
Copy Markdown
Contributor Author

Pushed four small fixes from a packaged-app test with WhatsApp: hide the invisible direction marks apps put on their own labels (WhatsApp prefixes almost every label with U+200E), refuse the bidi marks that could disguise a label, keep the Messages tools to Apple Messages, and retry a read once when an app is slow to answer. Details are in the updated description.

@Git-on-my-level

Copy link
Copy Markdown
Collaborator

Follow-up review of the four commits since my earlier pass (a1640d6, 4c348e9, 265095a, 768c15e) — the feedback from that pass is addressed, and the additions hold the same standard as the rest of the work.

What the new commits get right:

  • WindowSnapshot/InvisibleFormatMarks.swift + WindowSnapshot.swift: the split between harmless invisible marks (LRM, ZWSP, word joiner — stripped wherever text is shown or compared) and the bidi controls (embeddings, overrides, isolates, RLM, ALM — shown as code points, never silently allowed to reorder a line) is exactly the right call, and DesktopToolApprovalStore.cardAppName now strips LRM/leading-RTL from app display names so a look-alike name cannot hide a direction mark next to the bundle id. The walker keeping bidi marks un-dropped where a person decides (Deny-only previews) is the correct conservative default.
  • WindowSnapshotWalker.swift: the not-responding retry is bounded (two pauses, 0.5 s / 1.0 s, all inside the single existing deadline, cancellation checked each time) and only fires on whole-read app_not_responding or a sub-5-node walk that stopped answering — a real WebKit/WhatsApp behavior, handled without widening the read budget.
  • omi-tool-manifest.ts + omi-tool-manifest.test.ts: keeping the Messages tools to Apple Messages and routing a named app to ui_snapshot fixes the live misrouting the packaged-app test found, and the guidance is test-asserted rather than advisory. Keeping the realtime screenshot tool off the chat adapters is also asserted now.
  • device-tool-surface.md: the walker-limit, floor, and pane-guard documentation matches what the code does (I cross-checked the bundle table, refusal lists, and limits against the implementations).

Still open for a maintainer (unchanged from the stack, now concrete in this PR):

  • The privacy defaults are product decisions this PR ships: "Allow for This Chat" is per app for 1 h (any window of that app, not the one the person saw), text over 300 characters is replaced by its length, targets are bundle ids only, and a capture-excluded app is refused after an Allow rather than before the card. Each is a small change if ruled differently, but they should be ruled.
  • The refusal floor (terminals, password managers, credential prompts) is generated from one source into both the kernel and the Swift executor, which prevents drift — but its composition is now a security boundary, so the security-review label should stay until a maintainer has looked at the list itself (GeneratedUIAutomationSafetyFloor.swift / ui-automation-safety-floor.ts).

Note for maintainers: this PR also changes agent-facing guidance, not just code — the tool descriptions the shipping model reads now tell it to call capture_screen/send_message directly (the app asks for approval itself) and to use ui_snapshot when the person names another app, and desktop/macos/AGENTS.md marks desktop.automation.observe as dispatch-gated. The guidance is accurate against the implementation and test-asserted, but it redirects live model behavior on a privacy-sensitive surface, which is another reason the product sign-off above should be explicit.

No action requested from the contributor — leaving this for human maintainer review on the product defaults and the refusal-floor composition.


by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with need human response.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

macOS needs-maintainer-review Needs a human maintainer to sign off before merge positive-signal Automation verified a genuine fix/quality contribution security-review Touches auth, provider routing, secrets, or security-sensitive surfaces

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants