XS✔ ◾ Fix: Claude Code mode ignores Wait approval mode and auto-executes tools#988
XS✔ ◾ Fix: Claude Code mode ignores Wait approval mode and auto-executes tools#988tomek-i wants to merge 1 commit into
Conversation
McpToolBridge.callTool previously treated `wait` identically to `yolo`, running every non-whitelisted MCP tool immediately with no prompt — the approval-mode setting was silently ignored for Claude Code (local) runs, unlike the OpenAI backend which correctly shows the approval dialog. Wire `wait` mode into the same UserInteractionService.requestToolApproval dialog+countdown OpenAI mode uses: a non-whitelisted tool now blocks the bridge call until the user approves/denies or the 15s countdown auto-approves, safe because the localhost bridge call has no transport timeout. Thread shaveId end-to-end (orchestrator env -> mcp-serve -> bridge-router -> McpToolBridge) so the per-shave auto-approve override also works for Claude Code mode, matching OpenAI mode's behaviour. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PR Metrics✔ Thanks for keeping your pull request small.
Metrics computed by PR Metrics. Add it to your Azure DevOps and GitHub PRs! |
There was a problem hiding this comment.
Pull request overview
Fixes Claude Code (local) mode so Wait tool-approval behaves like the OpenAI backend by enforcing approval server-side in McpToolBridge.callTool() (prompt + countdown for non-whitelisted tools), and forwards shaveId end-to-end so per-shave auto-approve applies consistently.
Changes:
- Enforce
waitapproval mode inMcpToolBridge.callTool()by invokingUserInteractionService.requestToolApproval()for non-whitelisted tools instead of auto-executing. - Thread
shaveIdfrom orchestrator → env →mcp-serve→POST /tools/call→ router → tool bridge. - Update unit + integration coverage and refresh docs/comments to reflect the corrected
waitbehavior.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/backend/services/mcp/mcp-tool-bridge.ts | Adds wait-mode gating via user approval prompt for non-whitelisted tools; accepts forwarded shaveId. |
| src/backend/services/cli-bridge/cli-bridge-server.ts | Wires UserInteractionService into McpToolBridge and forwards shaveId in tool calls. |
| src/backend/services/cli-bridge/bridge-router.ts | Forwards shaveId from POST /tools/call body into the tools service. |
| src/shared/cli-bridge/protocol.ts | Adds CLI_BRIDGE_SHAVE_ID_ENV and shaveId to ToolCallInputSchema. |
| src/cli/mcp-serve.ts | Reads shaveId from env/options and forwards it on POST /tools/call. |
| src/backend/services/mcp/local-claude-orchestrator.ts | Injects shaveId into the front-door env and updates permission-mode documentation to emphasize server-side gating. |
| src/cli/mcp-serve.test.ts | Verifies shaveId forwarding behavior in the CLI front-door proxy. |
| src/backend/services/mcp/mcp-tool-bridge.test.ts | Adds coverage for wait-mode prompt gating (approve/deny/request_changes + whitelisted). |
| src/backend/services/cli-bridge/front-door.integration.test.ts | End-to-end HTTP coverage for wait-mode prompt gating + shaveId forwarding. |
| src/backend/services/mcp/local-claude-orchestrator.test.ts | Updates assertions/docs around wait mapping to CLI permission flags. |
| AGENTS.md | Updates architecture note to reflect corrected wait behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // wait: raise the same approval dialog+countdown the OpenAI backend uses, and block until | ||
| // the user responds (or the countdown auto-approves). Never hangs forever — the dialog | ||
| // itself auto-resolves after WAIT_MODE_AUTO_APPROVE_DELAY_MS. | ||
| const decision = await this.userInteraction.requestToolApproval(name, args, { | ||
| message: `Approval required to run ${name}`, | ||
| shaveId, | ||
| }); | ||
| if (decision.kind !== "approve") { | ||
| const feedback = decision.kind === "request_changes" ? decision.feedback : undefined; | ||
| return { | ||
| ok: false, | ||
| notApproved: true, | ||
| error: feedback | ||
| ? `Tool '${name}' was not approved by the user: ${feedback}` | ||
| : `Tool '${name}' was not approved by the user.`, | ||
| }; | ||
| } |
| * The current shave's id, injected the same way as {@link CLI_BRIDGE_SERVER_FILTER_ENV}. The | ||
| * front-door forwards it on `POST /tools/call` so the bridge's `wait`-mode approval prompt can key | ||
| * off the same per-shave "auto-approve for this shave" override the OpenAI backend supports | ||
| * (`UserInteractionService.setShaveAutoApprove`), and so an approval dialog raised from a headless | ||
| * Claude Code run is attributable to the shave that triggered it. |
|
🚀 Pre-release build is available for this PR: |
|
🔭 crows-nest: ready-PR pipeline started — review pass via muster (this tick runs review-only; address/merge deferred to a future tick). |
| notices.push(msg); | ||
| console.warn(`[LocalClaudeOrchestrator] ${msg}`); | ||
| } | ||
| // "wait" needs no caveat here (#920): a non-whitelisted MCP tool call is gated by |
There was a problem hiding this comment.
[major] Deleted runtime notice leaves the built-in-tools gap undisclosed to the user
Before this PR, surfaceServerNotices emitted a console.warn + an onStep UI notice every time approvalMode === "wait" ran under Claude Code (local), explicitly telling the user that "wait" behaved like YOLO for this backend. This PR deletes that block entirely and replaces it with only a code comment (no notices.push/console.warn).
Per the PR's own description and buildArgv's doc comment, wait still passes --permission-mode bypassPermissions to the CLI, so Claude's own built-in tools (Read/Write/Bash/etc.) are never gated by the new McpToolBridge approval dialog — only MCP tools routed through the yakshaver front-door are. No --disallowedTools or equivalent restricts built-ins out of the run, so this is a live, not theoretical, gap.
Since MCP tools now visibly show an approval dialog+countdown under "wait", a user has every reason to believe "wait" now fully protects them, when in fact built-in tool calls still auto-run with zero prompt and zero signal. Suggested fix: keep an updated runtime notice for "wait" mode under the local backend specifically calling out that built-in tools are NOT gated by the new dialog, so the disclosed-limitations UX isn't silently downgraded from "always warned" to "never warned" for a risk the PR itself says is unresolved (see the PR's own Follow-up items section).
flagged by: codex-rescue
| const approvalMode = (await this.settings.getSettingsAsync()).toolApprovalMode; | ||
| if (approvalMode === "ask") { | ||
| if (approvalMode === "ask" || approvalMode === "wait") { | ||
| const whitelist = new Set(await this.manager.getWhitelistWithServerPrefixAsync()); |
There was a problem hiding this comment.
[minor] getWhitelistWithServerPrefixAsync() is unguarded in callTool, undercutting the 'never a transport error' contract
In callTool, const whitelist = new Set(await this.manager.getWhitelistWithServerPrefixAsync()); (shared by both the ask and — as of this PR — the wait branch) is not wrapped in try/catch, unlike collectToolsForSelectedServersAsync, which is deliberately caught in the private collectToolsOrEmpty helper for exactly this reason (per that method's own doc comment: "That throw must NOT propagate to the bridge router").
If the whitelist lookup throws (e.g. an MCP client's getPrefixedToolNamesAsync RPC failing), the rejection propagates out of callTool. bridge-router.ts's top-level try/catch does convert it to a JSON {ok:false} body (so it isn't a raw socket-level transport error), but callToolViaBridge in mcp-serve.ts treats any non-200 via its generic catch path ("Tool failed: ") rather than the structured {ok:false, notApproved:true} envelope this PR's AGENTS.md update promises ("never a transport error either way").
This is pre-existing behaviour for ask (unchanged by this PR), but this PR newly extends the same unguarded call to gate wait too, widening the blast radius. Consider wrapping the whitelist call the same way collectToolsOrEmpty wraps the toolset call. No test exercises getWhitelistWithServerPrefixAsync throwing from inside callTool's ask/wait gate.
flagged by: code-review
| `ask` returns a structured "not approved" envelope for anything not whitelisted | ||
| (never prompts, since a headless caller can't answer), while `wait` raises the | ||
| SAME approval dialog+countdown the OpenAI backend shows and blocks the call until | ||
| the user responds or the countdown auto-approves (never a transport error either |
There was a problem hiding this comment.
[nit] Doc slightly overclaims 'never a transport error either way'
The updated POST /tools/call description states it "...blocks the call until the user responds or the countdown auto-approves (never a transport error either way)." As flagged separately on mcp-tool-bridge.ts, an unguarded throw from getWhitelistWithServerPrefixAsync inside the ask/wait gate surfaces as a bridge-level 500 caught generically by callToolViaBridge's catch block, not the structured notApproved envelope this sentence implies always happens. Either tighten the wording or close the underlying gap (see the related mcp-tool-bridge.ts comment).
flagged by: code-review
armada:muster review — PR #988Two independent lenses reviewed this diff in parallel: a conventions/correctness code-review pass and a root-cause codex-rescue second opinion. Neither saw the other's output. Findings below are consolidated and deduped. Bottom line: 0 blocking, 1 major, 3 minor, 1 nit — no blocking findings, but the major finding is worth addressing before merge. The core fix is sound: Findings (worst first)[major] [minor] [minor] [minor] [nit] Lens agreement/divergenceThe two lenses did not flag the same finding independently (no exact dedupe collisions), but their concerns are complementary: code-review focused on an untested error-propagation edge case and a stale doc comment, while codex-rescue focused on the user-facing disclosure regression and a latent timeout gap newly exposed to a headless caller. Both lenses independently confirmed the shaveId plumbing, the sole-choke-point claim, and the no-server-timeout safety claim — no disagreement to surface there. Review-only notePer this run's scope, no changes were pushed and no merge/approval action was taken — this is an inspection pass only. Addressing findings is a future Posted by armada:muster (dual-lens: code-review + codex-rescue) |
|
🔭 crows-nest: review pass complete — muster posted 5 findings (1 major, 3 minor, 1 nit; 0 blocking). This tick ran review-only (no address/merge pass). PR remains |
|
🔭 crows-nest: ready-PR pipeline started — review → address → re-validate → gated merge. |
|
🔭 crows-nest / muster review — PR #988 Lens: code-review (conventions + correctness) — ran, no blocking findings. Findings (2 minor, 0 blocking):
CI is green (all checks pass) and the PR is mergeable, but per ARMADA's merge gate a degraded (single-lens) review is not treated as a green light — this stops the pipeline rather than advancing to a human-merge-ready state. Verdict: |
Summary
Claude Code (local) mode ignored the "Wait" tool-approval setting and ran every MCP tool immediately, with no approval prompt — unlike OpenAI mode, which correctly shows the approval dialog and auto-approves only after a 15s countdown. The root cause:
McpToolBridge.callTool()(the server-side gate every MCP tool call passes through, regardless of which backend is driving the run) treatedwaitidentically toyolo.Closes #920
What changed
src/backend/services/mcp/mcp-tool-bridge.tswaitmode now raises the sameUserInteractionService.requestToolApproval()dialog+countdown OpenAI mode uses for a non-whitelisted tool, instead of auto-running it. Denied / "request changes" decisions return a structured not-approved result instead of executing.src/backend/services/cli-bridge/cli-bridge-server.tsUserInteractionServicesingleton intoMcpToolBridge.src/backend/services/cli-bridge/bridge-router.tsshaveIdfrom thePOST /tools/callbody through tocallTool.src/shared/cli-bridge/protocol.tsCLI_BRIDGE_SHAVE_ID_ENVand ashaveIdfield onToolCallInputSchema, mirroring the existingserverFilterplumbing.src/cli/mcp-serve.tsPOST /tools/call.src/backend/services/mcp/local-claude-orchestrator.tsoptions.shaveIdinto the front-door's injected env; updatesbuildArgv/surfaceServerNoticesdocs — MCP tools are now gated by the bridge itself, independent of the CLI's own--permission-mode.AGENTS.mdwait's dialog behaviour instead of "wait runs everything".*.test.ts(4 files)Decisions
waitmode inMcpToolBridge.callTool()(server-side, in the main process) rather than trying to make the headlessclaude -pCLI itself defer a prompt.POST /tools/call, which runs in the interactive desktop app's main process and can freely callUserInteractionService. The bridge call has no request/response timeout on either side (BridgeClient.postsets none, and the bridge'shttp.Serverhas no configured timeout), so blocking there for up to 15s is exactly as safe as OpenAI mode's in-process wait.--permission-prompt-tool/canUseToolhook — not available; the installedclaudeCLI (2.1.197) exposes no such flag, and this integration doesn't use the TS Claude Agent SDK (it spawns the bare CLI).--permission-moderemainsbypassPermissionsforwaitso Claude's own built-in tools (Read/Bash/etc., which never reach the bridge) aren't hard-denied at the CLI layer — those still can't be gated by this fix, since there's no per-call interception point for them in the current CLI-spawn architecture.Acceptance criteria
waitthrough the identicalrequestToolApprovaldialog+countdown OpenAI mode uses.Testing
npm run build)npm rebuild better-sqlite3 --build-from-source && npx vitest run --exclude 'src/ui/**'— 705 passed;npm --prefix src/ui test— 261 passed)npm run lint)Covered scenarios:
wait+ non-whitelisted tool → raisesrequestToolApproval, executes only on{kind:"approve"}.wait+ non-whitelisted tool +deny_stop→ does not execute, returns a structured not-approved result.wait+ non-whitelisted tool +request_changes→ does not execute, surfaces the feedback text.wait+ whitelisted tool → runs without prompting (no regression for the common case).shaveIdforwarding end-to-end: orchestrator env →mcp-serve→POST /tools/callbody →bridge-router→McpToolBridge, so the existing per-shave auto-approve override (UserInteractionService.setShaveAutoApprove) now also applies to Claude Code mode.front-door.integration.test.ts(spawns a real localhost bridge, no mocks on the path under test) for all of the above.askmode behaviour is unchanged (still never prompts, still hard-denies non-whitelisted tools) — regression-checked.No pre-existing failures observed on the base branch for any of the touched suites.
Bug repro evidence
src/backend/services/mcp/mcp-tool-bridge.test.ts's pre-existing test"wait: runs a NON-whitelisted tool (wait = auto-approve, matching buildArgv's bypassPermissions)"already encoded the bug — it asserted the tool ran immediately underwaitwith an empty whitelist, and passed unmodified against the oldMcpToolBridge.callTool(), provingUserInteractionService.requestToolApprovalwas never consulted.executeSpywas called once with no approval step,res.ok === true— verified by running the suite onmainbefore making any change."wait: a NON-whitelisted tool raises the approval dialog and runs only once APPROVED (#920)"(and its deny/request-changes/whitelisted-tool siblings) plus the real-HTTP integration test"raises the approval prompt for a non-whitelisted tool under 'wait' and runs it once APPROVED (#920)"— both assertrequestToolApprovalis called and gates execution. All 705 backend + 261 UI tests pass.Follow-up items
yakshaverfront-door) still can't be gated underwait— there's no interception point for those in the current bare-CLI-spawn architecture (nocanUseToolcallback /--permission-prompt-toolflag on the installedclaudeCLI). If YakShaver's Claude Code mode ever enables those built-ins, this gap should be revisited (possibly via the Claude Agent SDK'scanUseTool, which is not currently a dependency of this project).🤖 Generated with Claude Code