Skip to content

feat(plugins): let a plugin tool ask the owner before it runs - #2953

Open
LordMelkor wants to merge 2 commits into
block:mainfrom
LordMelkor:clod/plugin-tool-approval
Open

LordMelkor wants to merge 2 commits into
block:mainfrom
LordMelkor:clod/plugin-tool-approval

Conversation

@LordMelkor

@LordMelkor LordMelkor commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

FOUNDATION files: this PR touches two files marked FOUNDATION:

  • src/app/services.ts: 4 lines that create the approval store and pass it to the agents service.
  • src/app/App.tsx: 2 lines that mount ToolApprovalNotices beside the interrupted-agents notice.

kray asked for this API in #buzz-feedback, and I made these changes at his direction. Maintainers, please confirm.

Problem

A plugin tool's call(client, context, args) can't pause for a person. ctx.decisions returns a model's judgment, and protection review is a model review that runs only for protected agents.

Harness permission prompts (Claude Code, Codex) can't see inside a plugin tool. So a plugin that gives agents destructive tools, such as the MCP servers plugin's mcp_manager_call (delete files, send mail, merge PRs), can only switch each tool fully on or fully off.

Change

  • client.approve({ title, detail }) pauses the call and shows a notice. The notice names the agent, the plugin's tool, title and detail, and offers Allow once, Always allow and Deny.
    • It resolves "once" or "always".
    • Deny throws, so the agent sees a tool error. A tool that does nothing but await it is safe by default.
  • Always allow is saved on this device for that plugin and tool, across agents (buzz.plugin-tool-allowed.v1:<plugin>/<tool>). Later calls resolve "always" with no notice. When a person picks Always allow, the other calls already waiting on the same tool are allowed too.
  • Binding. addTool binds approve to the plugin and the tool, so a tool can't ask as another tool. approve is absent where no owner can be asked, such as a standalone server.
  • The notice reuses the toast that the interrupted-agents prompt uses (ToolApprovalNotices). At most 16 asks can be pending.
  • Lifecycle. Pending asks live in memory.
    • An ask ends with its turn. The app client polls the turn's check(), because most harnesses end a turn with no event. HarnessKit.tools uses the delivery's new ended signal, which aborts when the run and everything it handed to workingUntil have settled. It isn't the advisory deadline signal. The Claude adapter gives each turn's tool calls a signal that aborts when the turn settles, whether it succeeds, fails or is stopped, so a failed turn's ask ends too. The store checks again before it settles, so an answer given after an interrupt is never taken and saves nothing.
    • An ask made over an external MCP connection ends when the app sends notifications/cancelled for that call.
    • An ask also ends when the plugin that added the tool unloads.
    • Restarting the app drops pending asks.
  • Documented in docs/plugin-architecture.md and docs/plugin-authoring-guide.md. The generated @buzz/author declarations include approve.

Intent

Original request: kray's #buzz-feedback post "let a plugin tool ask the agent's owner for approval before it runs" (Nov 3).

Must change: a plugin tool's call can await an owner prompt (Allow once / Always allow / Deny) and get the answer; Deny becomes a tool error for the agent.
Out of scope: remote/mobile approval delivery, per-argument policies, changing protection review.
Acceptance: a plugin tool that calls approve pauses until the owner answers; each answer produces the right result; "Always allow" skips the prompt next time for the same tool.

Validation

  • Store (tool-approval.test.ts):
    • Always allow answers the waiting calls for that tool, and no other tool.
    • It's remembered per plugin tool across a restart.
    • An ask ends with its turn.
    • Always allow on a stopped call answers no other call and saves nothing.
  • Notice (ToolApprovalNotices.test.tsx): each button gives the right answer.
  • Service (service.test.ts):
    • A plugin tool awaits approval end to end.
    • Deny reaches the agent as a tool error.
    • A waiting approval is dropped when its plugin unloads.
  • App client (app-client.test.ts): no Always allow is saved for a call whose turn was interrupted. This uses a Goose-shaped client over the real store.
  • Harness kit (kit.test.ts): an ask made through HarnessKit.tools survives the delivery's deadline and ends with its turn.
  • Runner (service.test.ts): ended stays live past the 60 s deadline while handed-off work runs, and aborts when that work settles.
  • External MCP (external.test.ts): notifications/cancelled aborts the named tools/call and its ask.
  • The regressions fail without their fixes. Focused tests pass. tsc --noEmit and biome check pass. Full suite at 22907841, on main 41e64bf0: 19,441 passed, none failed.
  • Review rounds (each finding checked against the frozen intent above):
    • Round 1 (sq agents review and Sigil): two P2s, both fixed. A pending approval survived a Goose interrupt, and it survived its plugin unloading. Sigil reproduced the first with the real ACP session, app client and store.
    • Round 2 (sq agents review): three P2s, all fixed.
      • Always allow on a stopped call still allowed the other calls waiting on that tool.
      • A harness serving tools through HarnessKit.tools had no turn signal, so an ask outlived its turn. A conversation now carries its turn's end, and a standalone turn passes its own signal.
      • An ask outlived its external MCP request. notifications/cancelled now ends the call it names, and the ask with it. An HTTP connection that drops without a cancel message is ended by its turn or by the plugin unloading, because the host transport has no per-request signal.
    • Round 3 (Sigil): the round-2 wiring used Delivery.signal, which is an advisory handoff deadline. A harness still working through workingUntil would have had every tool call fail after 60 s. Fixed: Delivery gains an optional ended signal, which aborts once the run and everything it handed off have settled, or the agent is removed. The kit uses it.
    • Final sq agents review at 22907841, after rebasing onto main 41e64bf0: no findings. The rebase resolved one import conflict in src/buzz-mcp/client.ts by keeping both sides.
    • Rebase review (sq agents review) at 34c18b72, after squashing and rebasing onto main b1436b09: one P2, fixed in 9150c642 (+7 production lines). When a native Claude turn failed while a plugin call waited for approval, the ask stayed answerable, because the adapter's signals covered only process exit and Stop. Its tool calls now also get a per-turn signal. The new test in claude.test.ts runs two turns on one process and fails without the fix. The rebase dropped the owner-read tools' wrapper from app-client.ts, because main removed those tools.
      Full suite at 9150c642: 20,530 passed, 1 failed. The failure was a spawn timeout under load in browser-host/vite-config.test.mjs, which this PR doesn't touch, and it passes alone.
    • Second rebase (2026-11-12) onto main f10e7771, squashed again to one feature commit (6389350a). The approval's abort list also takes main's new Stop signal (callSignal). The review of the rebase found one P2, fixed in 08647b11 (+7/−1 production lines): a turn Claude starts itself, such as one a finished background task starts, settled nothing when it went idle, so its tool calls and any approval they asked for outlived it. Its calls now end when Claude reports idle. The new test in claude.test.ts fails without the fix. A final sq agents review at 08647b11 found nothing.
      Full suite at 08647b11: 21,256 passed and 20 failed, all in 3 files this PR doesn't touch. Those files pass alone (101 tests), so the failures were timeouts on the shared machine.
    • Third rebase (2026-11-15) onto main 2ccfbb34, keeping the two commits (head c68a2e00). Conflicts were in the docs, imports and the Claude adapter, where the turn signal now sits beside main's callEpoch. Two adaptations to main's new operation proofs:
      • The addTool wrapper passes main's new proof argument through.
      • The turn-end reason uses main's wording, "The Claude turn has ended".
      • Rebase review (sq agents review): one P2, fixed: a provider plugin that claimed an operation proof got the raw approveTool, so it could ask as another plugin's tool. The claimed authority's client now leaves it out, while the consumer tool keeps its bound approve. The new test in service.test.ts fails without the fix.
      • TypeScript, Biome and design:check pass. Full suite before the fix: 22,104 passed and 1 failed, a load timeout in store-discovery.test.ts, which this PR doesn't touch and which passes alone (74 tests). After the fix, the four affected test files pass (955 tests).
    • Pressure test: an independent agent (Sigil) reproduced the round-1 interrupt defect with the real ACP session, app client and store, and found no other blocker. Later rounds it found the advisory-deadline defect above with a probe at the kit boundary. At the round-3 fix head (1ada6c2d, now 22907841 after the rebase) that probe passes, an independent source review confirms that ended follows the run, its handed-off work and retirement, and 559 affected tests pass.

Try it (AGENTS.md step 3, for a human)

  1. In a dev plugin, add a tool whose call does await client.approve?.({ title: "Delete file", detail: "notes.txt" }); return "deleted";.
  2. Ask an agent to use it. Expected: a notice names the agent, the tool, "Delete file" and "notes.txt", and the agent waits.
  3. Click Deny. The agent reports a tool error saying you denied it.
  4. Ask again and click Allow once. The tool returns "deleted". The next call asks again.
  5. Ask again and click Always allow. Later calls run with no notice, including after a restart.
  6. Start a call and interrupt the agent before answering. The notice disappears.

🤖 Generated with Claude Code

@LordMelkor
LordMelkor requested a review from a team as a code owner November 7, 2026 02:06
@LordMelkor
LordMelkor force-pushed the clod/plugin-tool-approval branch 2 times, most recently from 9150c64 to 08647b1 Compare November 12, 2026 17:08
LordMelkor and others added 2 commits November 15, 2026 09:35
Plugin tools run in the app's webview, so `client.approve({ title, detail })`
pauses the call on a notice with Allow once, Always allow and Deny, reusing
the toast notice the interrupted-agents prompt uses. Deny throws, so the agent
sees a tool error; Always allow is saved per plugin tool on this device.

Co-authored-by: Clod <c7b21872e9ddb79ca62967d0e21e6a4518a669eb04648e963cdfb2426e62fafb@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: LordMelkor <kray@squareup.com>
A turn Claude starts itself, such as one a finished background task
starts, has no pending message, so settle() never ran and its Buzz tool
calls (and any approval they asked for) outlived the turn.

Co-authored-by: Clod <c7b21872e9ddb79ca62967d0e21e6a4518a669eb04648e963cdfb2426e62fafb@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: LordMelkor <kray@squareup.com>
@LordMelkor
LordMelkor force-pushed the clod/plugin-tool-approval branch from 08647b1 to c68a2e0 Compare November 15, 2026 14:36

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant