Skip to content

feat: ask once per agent before it controls the Mac, and keep agent input off Desktop Tools' own UI (#24) - #120

Merged
iamnbutler merged 7 commits into
mainfrom
feat/caller-approval
Oct 17, 2026
Merged

iamnbutler merged 7 commits into
mainfrom
feat/caller-approval

Conversation

@iamnbutler

@iamnbutler iamnbutler commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Closes #24.

The Desktop Tools app now asks once per agent before serving it. It also refuses agent input aimed at its own UI, so only the user can answer its prompts or choose Pause.

What changes

  • Who is asking. The host takes the client's pid from the socket's audit token and walks up its parents to the first one that isn't node, npx, npm, a shell, login or env, such as Claude Code or the terminal app.

    • A caller whose signature validates is keyed on team ID + signing identifier, so an update that moves it keeps its approval. Anything else is keyed on its path, plus its identifier when it has one.
    • The name comes from its app bundle, else the signing identifier's last part (claude-code), else its file name. argv[0] is never used.
  • Prompt. "Allow name to control this Mac?" with Allow and Don't Allow. It names the signer and path.

  • Unapproved callers. Every request, observation included, is refused with CALLER_NOT_APPROVED and a hint. The handshake refuses too, so a client learns why before asking for anything. The caller is never held while the user decides. Answers persist in callers.json (0600) in the state folder.

  • Approved Agents submenu. It lists waiting, allowed and refused agents with their signer and path:

    • waiting: Allow / Don't Allow;
    • allowed: Revoke;
    • refused: Allow / Forget.
  • Recent Activity names the verified caller and its pid (claude-code (6820)) instead of node.

  • Self-target refusal. Agent input is refused with SELF_TARGET_REFUSED when:

    • its target is any process in the host's bundle family (the app, its client, another build), matched by bundle ID rather than pid; or
    • any of the family's prompts or menus is open, whatever the target.

    Input means the pause rule's input set plus beginSnapshotMutation, the lease every snapshot-bound click, key, type, set-value, scroll or insert takes first. Observing the host's windows still works. This makes Pause genuinely user-only.

  • MCP client. hostRefusal rewrites Peekaboo's permissionDenied to the host's code. It also replaces Peekaboo's hint "Grant the required permission before retrying." with one telling the agent to ask the user. The server instructions explain both codes.

  • Embedders opt in with desktop_tools_set_caller_handler and desktop_tools_set_callers. Without a handler nothing changes.

  • Patch. peekaboo-caller-admission.patch comes last, after peekaboo-secure-subrole.patch. It extends operationAdmission with the client's pid and the request's target pid. The target is found from the window, the scope, a process or window identity in the request (by reflection), or the snapshot the request names. The hook is also consulted at the handshake. Details are in native/patches/README.md.

  • For Fill password fields: from the Keychain by reference, or agent-supplied text with approval #117:

    • Confirmations.shared.ask(.init(key:title:message:allow:deny:)) async -> Answer? and cancel(key:);
    • Callers.shared.caller(ofClient:) -> Caller?.

Upgrade note (in the CHANGELOG): existing users are asked once per agent after updating.

Tests

  • npm test → 74 pass. New test/caller-approval.test.mjs covers code and hint mapping, a refused listing, read_error, and the rules.

  • npm run check → clean.

  • DesktopToolsCallersTests → 9/9 pass: the ancestor walk, keys, names, save/load at 0600, refusal prefixes, the guard, the bundle family, and the prompt/menu window rule. CI now runs them.

  • Peekaboo PeekabooBridgeOperationRecordTests → 6/6 pass, isolated:

    • the admission carries the client pid;
    • targets are learned from a scope and from a request's process identity;
    • a handshake refusal leaves no operations.

    These ran before rebasing onto fix: mark secure text fields, keep their masks out of results, and refuse typing into them #118. After the rebase the patch applies and the signed app builds; I didn't rerun these tests.

  • npm run live-check -- --only 120 against the dev app → 2 pass:

    • self-target, plus observing the host;
    • the perl caller, on its "already denied" path.

    Run under the denied perl, the harness stops at preflight with its "allow the harness's caller" message. docs/live-checks.md documents these and lists the menu checks that stay manual.

Live verification on a signed dev build (state /tmp/approve-state), all agent-run

  • An unapproved Claude Code got CALLER_NOT_APPROVED on desktop_apps, desktop_windows and desktop_screen, with one prompt for three calls. No call waited; refusals came back in about 50–230 ms.

  • An agent answered the Claude Code prompt Allow. It clicked Allow with the installed 0.13.0 tools, which predate the refusal, as the lead authorized. Listings then worked, and the approval survived a restart. callers.json holds team:Q6L2SF6YDW:com.anthropic.claude-code at mode 0600.

  • desktop_activate on the dev app → SELF_TARGET_REFUSED, 119 ms, nothing dispatched.

  • A perl-spawned client → CALLER_NOT_APPROVED and one prompt: "Allow perl to control this Mac?" / "Signed by Apple. /usr/bin/perl".

    • While that prompt was open, desktop_focus on my own TextEdit test window → SELF_TARGET_REFUSED.
    • An agent then answered the prompt Don't Allow with the installed tools. perl is now refused as denied, and no second prompt appears.
  • With the lead's OK, an agent opened the dev app's status menu read-only with the installed tools. Recent Activity showed claude-code (6820) on both refused entries, and the Approved Agents item is present.

  • Not live-checked:

    • The submenu's contents. A pixel click couldn't open the submenu, and Escape couldn't reach the menu, so I closed it by quitting my dev app.
    • Revoke / Allow / Forget in the submenu.

    These are listed as manual in docs/live-checks.md, for the user to check.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The bridge serves any same-user process, including input

1 participant