Repository navigation
feat(desktop): park sensitive tool calls in waiting_approval and emit approval.requested (#20938, PR 1a) - #20973
Conversation
9c87c22 to
c283e92
Compare
|
Thanks @sauranshbhardwaj — this is carefully executed security work. Reviewing against the current head:
Two non-blocking notes: (1) after a user answer the run/attempt go straight back to running — I could not find an intermediate post-approval status in this diff, so whether that is the intended UX is a call for the surface owner; (2) everything sits behind desktopToolApprovalsEnabled (kernel-types.ts), default off until the Swift card ships — good interim containment, but it also means none of this is exercised in production until PR 2 lands. Because this reshapes the desktop agent's permission architecture for sensitive device tools, it needs maintainer sign-off on the approval semantics before merge rather than an automated approval — leaving for human maintainer review. by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with |
|
Thanks, appreciate the careful read against the head.
|
…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.
c283e92 to
9714518
Compare
|
One update since your review, from live-testing the next phase (#21096) on a packaged build:
Bodies are updated with the new heads and test counts. The merge order is unchanged: #20973 → #20975 → #20997 → #20998. |
|
Reviewed the full diff of this head. This is careful, security-improving kernel work — detailed findings below. Thanks for the exceptionally thorough test suite. What this gets right (verified against the diff):
Non-blocking observations for the maintainers:
No security concerns found in static review; the design is consistently fail-closed. Because this reshapes the device-tool permission surface (screen capture, messaging, AppleScript) and is the first PR of a stacked sequence, it needs maintainer sign-off before merge rather than automated action. by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with |
… 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.
9714518 to
10ef09e
Compare
|
Kernel half of the device tool approval flow (#20938, PR 1a). Reviewed the full diff statically; this is a well-built, security-tightening change, and it fixes two real gaps: What stands out, verified against the code:
One question, not blocking: the 180s approval TTL is enforced by Test coverage is unusually thorough (~1,600 new test lines across control-tools, run-tool-capability, desktop-tool-policy, sqlite-store and omi-tool-manifest), including the forged-binding, owner-change, tampered-payload, replay, rollback-restore and daemon-restart paths. Thanks for the careful work. Because this PR sets the security semantics of user approval for sensitive device tools (grant scoping, TTL caps, the allow-once-only screenshot rule, the fail-closed lifecycle), it needs a maintainer's sign-off on those policy choices before merge despite the clean implementation. by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with |
|
Checked this one. The race can't happen, because nothing expires pending dispatches while the daemon is running.
I confirmed both cases with a throwaway test (clock 10 minutes past the TTL with no timer fired, then the timer firing, then a restart 10 minutes after the TTL). Each card ended |
What changed and why
Kernel half of #20938 (PR 1a of the runtime work; PR 1b adds the relay and wire frames, PR 2 the Swift card). A sensitive on-device tool call (
send_message,run_applescript,read_message_history,list_message_chats,list_mail_messages) without a covering scoped grant fails today withapproval_required, and nothing in the app can grant one, so the tools shipped in 0.12.444 cannot run for a user.capture_screenhas the opposite gap: its bundle (desktop.context.screenshot_image) is already classified sensitive and its description says it requires approval, but it was never in the broker's approval set, so today it takes and shares a full-screen screenshot with no approval at all; this PR adds it to the set with the stable resourcescreen. A capture is the whole display, including windows the person keeps out of capture elsewhere, so each screenshot is allowed once: its card offers no "Allow for this chat", no grant row covers a capture, and the kernel refuses to mint a grant the card did not offer. This PR gives the kernel the ability to park such a call instead: the capability broker prepares the ledger row and inserts oneapprovaldispatch bound to that exact invocation (id, input hash, daemon boot epoch) in the same transaction, the run and attempt move towaiting_approval, andrun.waiting_approval+approval.requestedare appended together.resolve_desktop_dispatchgains the two user answers:resolution.decision: "allow"without a grant runs exactly this invocation once and mints nothing;allowwith agrantwhoserunIdisnullmints a session-scoped grant through the existinginsertGrantpath, capped at 24 hours. Deny, cancellation, expiry, run termination and owner change fail the prepared invocation closed (failed, neveroutcome_unknown), each with its ownapproval.resolved, and the run resumes. A daemon restart turns the prepared invocation intofailed, expires the dispatch and recordsapproval.resolved; nothing is replayed.The six gated tools are validated against their manifest schema at the kernel boundary (unknown keys such as a caller-supplied
resource_ref, missing required fields and wrong types are refused asinvalid_tool_input), and the resource a grant covers is derived only fromto/script/chat_id/handle, so one approval can never cover a different recipient or script. An input the card cannot show in full (over the 4 KiB bound) is refused asinput_too_large_to_approvewith nothing prepared.allow_sessionis offered only when there is an exact resource to cover and says what it covers (covers). A dispatch created throughcreate_desktop_dispatchhas itsinvocationpayload key stripped, so a model cannot forge a parked tool call. Every relay-callable tool is now classified on purpose (named in a policy set, or declared unbundled with a reason), never by the read-only default, and a test enforces it. A second test holds the approval set to every relay-callable tool in a sensitive bundle, with each deliberate exception named and justified (request_permission: the native macOS prompt plus current-turn consent;fill_cloud_connector_form: not one of this issue's device tools, and it only types into a connector form already open in the signed-in browser, though it takes an arbitraryserver_urland can press submit). Screen-image tools that are not gated must stay off every chat relay:get_screenshotand itslook_at_framealias are local-agent-API only, and the realtime voicescreenshot, which was advertised to pi-mono with no condition and so sat ungated in every typed chat, pill and workstream run, is now offered only to realtime voice runs through their surface projection.show_rewind_evidenceis classed with the screen-history reads: the model gets back the stored frame's title, app and OCR excerpt, and the pixels go only to the person's own Chat turn. Grants fordesktop.context.screenshot_imagemust name an exact resource. 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 (cancelled, never run) and revokes every grant the person minted for it (revoked_at_ms, onegrants.revokedaudit event); PR 1b calls it from the app's journal clear. Authority is revalidated before an approval is admitted, that check runs before the resolving transaction so a revocation it triggers commits on its own, and a rolled-back decision restores the broker's memory. The model still cannot resolve its own dispatch:resolve_desktop_dispatchwithouttrustedUserControlreturnspolicy_denied.Interim behavior: parking sits behind a kernel switch,
AgentRuntimeKernelOptions.desktopToolApprovalsEnabled, default off and not reachable from the daemon in this PR (nothing inindex.tscalls the new kernel method yet), so the approval flow itself stays off and asend_messagecall without a grant still fails immediately withapproval_required. Four things do change on the existing path: for the gated tools, an input with unknown keys, a missing required field or a wrong type is now refused asinvalid_tool_input, which takes precedence overapproval_required; the grant resource for the two list tools is nowmessages:chats/mail:inboxinstead of none, so a session grant can name them (a legacy*grant still matches); andcapture_screen, which ran with no approval, now fails withapproval_requiredlike the others until a client renders the card (PR 1b and PR 2), so this PR must not ship in a release without them; and typed chat, pill and workstream runs no longer see the realtime voicescreenshottool (realtime voice runs still do). PR 1b wires the relay and a client-declared capability that turns the switch on; PR 2 flips the default and deletes the switch, so this is a temporary dual path, not a lasting one.Product invariants affected
INV-AGENT-* (
product/invariants/agent-control-plane.md), sections Authority ("Physical tool authority is run/attempt scoped and single use") and Lifecycle ("Exactly one non-terminal attempt per run has execution authority", "Silence, disappearance, and UI dismissal are not success"), plus Persistence (state transitions and their events commit in onewithTransaction). The ledger transition staysprepared -> dispatched -> succeeded|failed; a parked invocation isprepared, so restart turns it intofailedand neveroutcome_unknown, and holding the invocation object is not dispatch authority. Spec §8.3: approval requests are events and the dispatch row reusesdesktop_dispatches; no approvals table. Spec §17: no automatic decision mints a grant; only a user answer with a finite expiry of at most 24 hours does. Guard tests updated:run-tool-capability.test.ts,control-tools.test.ts,sqlite-store.test.ts,desktop-tool-policy.test.ts.INV-CHAT-1 (
product/invariants/chat-continuity.md) is named becausedesktop/macos/agent/src/runtime/**matches its path glob. Unaffected: this change writes no conversation turns, adds no store outsideomi-agentd.sqlite3, and leaves the kernel journal the single transcript owner.How it was verified
From
desktop/macos/agentat the branch tip (10ef09e, rebased on main d3208a0):npm ci && npm run build-> clean.npx vitest run-> 72 files, 946 tests passed (899 on main d3208a0 + 47 new), 0 failed.tool-surfaces-exhaustiveness.test.tsis in that run; this PR changes one manifest entry (the realtime voicescreenshotloses its pi-mono advertisement), soGeneratedToolExecutors.swift(two digests) andtests/fixtures/tool-manifest.jsonwere regenerated withscripts/generate-tool-surfaces.mjsand--checkreports no drift.desktop/macos/scripts/agent-logic-harness.sh --node-only --skip-install-> "Harness passed." The full harness (Swift focused tests, 684 executed, 0 failures) was run on the combined change before the split; the only Swift change in this PR is the regenerated manifest digests, andxcrun swift buildplus the realtime and generated-surface suites (487 tests) pass on the stack top.scripts/pr-preflight --base upstream/main --pr-body-file <this body>-> "PR preflight passed: 32 checks" (lane ci, base d3208a0, 16 files). With the machine default Python 3.10 the run stops atfeature-flag-registrybecausebackend/routers/conversations.pyusesexcept*(3.11 syntax); the passing run used Python 3.11.scripts/failure-class prepare-> OK, nofix:commits.python3 .github/scripts/check_agent_doc_references.py-> OK;python3 .github/scripts/desktop-changelog.py validate-> OK.Not verified: the real user-facing path. With the switch off, the park/resolve flow is exercised end to end only through the real kernel, broker and SQLite store in tests (a live acp-lane run with a deferred adapter, resolved through
handleAgentControlToolCall), not through a model turn or the dev bridge. No packaged-app smoke was run, including realtime voice after thescreenshotprojection change.Tests
Core path (
control-tools.test.ts, "per-invocation device tool approvals"): with the switch off the kernel still rejectsapproval_requiredand writes nothing; with it on, a live acp-lane run parks inwaiting_approvaland recordsapproval.requestedin the UX-doc shape; allow once admits one dispatch, mints no grant, resumes the run and refuses a replay; allow for this chat mints a session-scoped grant that covers the same recipient and not another; deny fails the invocation and the run continues; a decision-less resolution is rejected; the model's ownresolve_desktop_dispatchreturnspolicy_deniedwith the dispatch still pending; a tampered payload admits nothing; expiry and run cancellation close the card with an auditedapproval.resolved; an owner change closes the card, recordsapproval.resolvedand frees the run; a resolution refused for stale authority (wrong owner) still cancels the dispatch durably; a grant over 24 hours is refused and the card stays answerable; ending a chat's approval scope closes its parked card and revokes only that session's user grants (not a policy grant, not another session's), refuses another owner, is idempotent with no second audit event, and the same send then asks again; a session grant for a parkedcapture_screenis refused because the card never offered one, nothing is minted, and the card is then allowed once; a decision whose transaction fails (store write made to throw once, for bothresolve_desktop_dispatchand the system termination path) leaves the dispatch pending, the ledger row prepared and the broker still refusing dispatch, and the card then resolves normally; a model-created approval dispatch has itsinvocationkey stripped and remains an ordinary dismissible item.Error paths and restart (
run-tool-capability.test.ts, "desktop tool approvals"): a caller-suppliedresource_ref, a missingtextand a wrong type are refused asinvalid_tool_inputwith nothing prepared, and a grant for bob never covers mallory; too-large input ->input_too_large_to_approve; a hard policy deny never parks; owner mismatch and a superseded attempt at approve time revoke and close the card; owner revocation reports the closed approvals;restorePendingApprovalround trip; the broker gate (holding the invocation object is not dispatch authority); binding mismatch; deny; a covering grant skipping the dispatch; stable list-tool resource refs;capture_screenparking underscreenwith Allow Once and Deny only, and a grant row forscreennot covering the next capture; every relay-callable tool explicitly classified; the approval set matching every relay-callable sensitive-bundle tool except the named exceptions;get_screenshotandscreenshotstaying off the relays;show_rewind_evidenceclassed as stored-frame text; every gated tool being sensitive and relay-callable; the voicescreenshotabsent from pi-mono main chat, floating bar, floating pill, task chat and workstream runs and present in a realtime voice run; a terminal attempt cancelling the dispatch; a daemon restart (prepared -> failedwithnever_auto_retry, dispatchexpired, oneapproval.resolved, idempotent second reconciliation).sqlite-store.test.tscovers reconciliation at the store contract.omi-tool-manifest.test.tspins the voicescreenshotto the realtime voice surface and off the pi-mono and stdio projections.desktop-tool-policy.test.tscovers the request builder (options, default deny,allow_sessionomitted without a resource,coverstext, the plain screenshot card copy with Allow Once and Deny only, no grant covering a capture, an unscoped screen-image grant refused, preview allowlist, path redaction, 4 KiB bound, TTL).UI evidence
UI-Evidence: none -- runtime-only change
Failure class (fixes)
Failure-Class: none