Skip to content

XS✔ ◾ Fix: Claude Code mode ignores Wait approval mode and auto-executes tools#988

Open
tomek-i wants to merge 1 commit into
mainfrom
fix/920-claude-code-wait-approval-mode
Open

XS✔ ◾ Fix: Claude Code mode ignores Wait approval mode and auto-executes tools#988
tomek-i wants to merge 1 commit into
mainfrom
fix/920-claude-code-wait-approval-mode

Conversation

@tomek-i

@tomek-i tomek-i commented Jul 21, 2026

Copy link
Copy Markdown
Member

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) treated wait identically to yolo.

Closes #920

What changed

File Change
src/backend/services/mcp/mcp-tool-bridge.ts wait mode now raises the same UserInteractionService.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.ts Wires the live UserInteractionService singleton into McpToolBridge.
src/backend/services/cli-bridge/bridge-router.ts Forwards shaveId from the POST /tools/call body through to callTool.
src/shared/cli-bridge/protocol.ts Adds CLI_BRIDGE_SHAVE_ID_ENV and a shaveId field on ToolCallInputSchema, mirroring the existing serverFilter plumbing.
src/cli/mcp-serve.ts Reads the shave id from env (or an injected option) and forwards it on POST /tools/call.
src/backend/services/mcp/local-claude-orchestrator.ts Passes options.shaveId into the front-door's injected env; updates buildArgv/surfaceServerNotices docs — MCP tools are now gated by the bridge itself, independent of the CLI's own --permission-mode.
AGENTS.md Updates the front-door architecture note to describe wait's dialog behaviour instead of "wait runs everything".
*.test.ts (4 files) Updated/added coverage — see Testing below.

Decisions

  • Decision: Gate wait mode in McpToolBridge.callTool() (server-side, in the main process) rather than trying to make the headless claude -p CLI itself defer a prompt.
    • Why: Every MCP tool call — which is what YakShaver actually cares about (GitHub/Jira/Azure DevOps/etc.) — is already proxied through the bridge's POST /tools/call, which runs in the interactive desktop app's main process and can freely call UserInteractionService. The bridge call has no request/response timeout on either side (BridgeClient.post sets none, and the bridge's http.Server has no configured timeout), so blocking there for up to 15s is exactly as safe as OpenAI mode's in-process wait.
    • Alternatives considered: Using the Claude CLI's own --permission-prompt-tool/canUseTool hook — not available; the installed claude CLI (2.1.197) exposes no such flag, and this integration doesn't use the TS Claude Agent SDK (it spawns the bare CLI). --permission-mode remains bypassPermissions for wait so 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

  • Claude Code mode prompts the user for permission through a dialogue before executing tools when Wait mode is selected — for MCP tools (the tools YakShaver's servers expose), the fix routes wait through the identical requestToolApproval dialog+countdown OpenAI mode uses.
  • If prompting is not technically feasible, users are clearly informed that Claude Code mode grants permission to execute all tools by default — not needed for MCP tools (now prompts correctly). Claude's own built-in tools (Read/Bash/etc.) are out of scope for this fix since there's no bridge in front of them to intercept; a follow-up item below tracks this gap.

Testing

  • Unit tests added/updated
  • Integration tests added/updated
  • Manual testing performed (no interactive Electron UI in this environment; covered by the automated repro/verify below instead)
  • Build passes (npm run build)
  • Tests pass (npm rebuild better-sqlite3 --build-from-source && npx vitest run --exclude 'src/ui/**' — 705 passed; npm --prefix src/ui test — 261 passed)
  • Lint / format clean (npm run lint)

Covered scenarios:

  • wait + non-whitelisted tool → raises requestToolApproval, 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).
  • shaveId forwarding end-to-end: orchestrator env → mcp-servePOST /tools/call body → bridge-routerMcpToolBridge, so the existing per-shave auto-approve override (UserInteractionService.setShaveAutoApprove) now also applies to Claude Code mode.
  • Full real-HTTP integration coverage in front-door.integration.test.ts (spawns a real localhost bridge, no mocks on the path under test) for all of the above.
  • ask mode 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

  • Symptom (pinned): Under Claude Code mode with "Wait" approval selected, a non-whitelisted MCP tool call executes immediately with no approval dialog and no countdown — the exact behaviour "yolo" mode has, not "wait" mode.
  • Repro method: This is a headless backend/logic bug (not a UI-rendering bug), so per the bug-fix workflow it was reproduced with a regression test against the unpatched code: 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 under wait with an empty whitelist, and passed unmodified against the old McpToolBridge.callTool(), proving UserInteractionService.requestToolApproval was never consulted.
  • Before (unpatched): That test passed against the old code — executeSpy was called once with no approval step, res.ok === true — verified by running the suite on main before making any change.
  • After (patched): The same scenario is now covered by "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 assert requestToolApproval is called and gates execution. All 705 backend + 261 UI tests pass.

Follow-up items

  • Claude's own built-in tools (Read/Write/Bash/etc., as opposed to MCP tools proxied through the yakshaver front-door) still can't be gated under wait — there's no interception point for those in the current bare-CLI-spawn architecture (no canUseTool callback / --permission-prompt-tool flag on the installed claude CLI). If YakShaver's Claude Code mode ever enables those built-ins, this gap should be revisited (possibly via the Claude Agent SDK's canUseTool, which is not currently a dependency of this project).

🤖 Generated with Claude Code

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>
Copilot AI review requested due to automatic review settings July 21, 2026 09:22
@github-actions

Copy link
Copy Markdown
Contributor

PR Metrics

Thanks for keeping your pull request small.
Thanks for adding tests.

Lines
Product Code 130
Test Code 240
Subtotal 370
Ignored Code 6
Total 376

Metrics computed by PR Metrics. Add it to your Azure DevOps and GitHub PRs!

@tomek-i tomek-i added the armada Eligible for the ARMADA fleet to pick up label Jul 21, 2026
@github-actions github-actions Bot changed the title Fix: Claude Code mode ignores Wait approval mode and auto-executes tools XS✔ ◾ Fix: Claude Code mode ignores Wait approval mode and auto-executes tools Jul 21, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 wait approval mode in McpToolBridge.callTool() by invoking UserInteractionService.requestToolApproval() for non-whitelisted tools instead of auto-executing.
  • Thread shaveId from orchestrator → env → mcp-servePOST /tools/call → router → tool bridge.
  • Update unit + integration coverage and refresh docs/comments to reflect the corrected wait behavior.

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.

Comment on lines +116 to +132
// 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.`,
};
}
Comment on lines +52 to +56
* 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.
@github-actions

Copy link
Copy Markdown
Contributor

🚀 Pre-release build is available for this PR:
https://github.com/SSWConsulting/SSW.YakShaver.Desktop/releases/tag/0.6.0-beta.988.1784625798

@tomek-i tomek-i added the armada:reviewing Claimed by crows-nest; review->merge pipeline running label Jul 21, 2026
@tomek-i

tomek-i commented Jul 21, 2026

Copy link
Copy Markdown
Member Author

🔭 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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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());

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Comment thread AGENTS.md
`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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

@tomek-i

tomek-i commented Jul 21, 2026

Copy link
Copy Markdown
Member Author

armada:muster review — PR #988

Two 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: McpToolBridge.callTool() is genuinely the sole choke point for every MCP tool call reachable through the yakshaver front-door (verified — no other .execute( call site bypasses it), the wait-mode gating logic correctly routes through the same UserInteractionService.requestToolApproval() dialog+countdown OpenAI mode uses, ask mode is unchanged, and the shaveId plumbing is consistent end-to-end across all five hops (protocol schema → bridge-router → cli-bridge-server → mcp-tool-bridge → UserInteractionService.isAutoApproveActive). Both lenses independently confirmed the "blocking the HTTP request for up to 15s+" design is safe (no server-side or client-side request timeout to fight).

Findings (worst first)

[major] src/backend/services/mcp/local-claude-orchestrator.ts:270 — Deleted runtime notice leaves the built-in-tools gap undisclosed to the user. The old code warned the user at runtime (console + UI notice) that "wait" behaved like YOLO locally. This PR deletes that block and replaces it with only a code comment — but the PR's own description confirms Claude's built-in tools (Read/Write/Bash/etc.) are still ungated under wait (no --disallowedTools), since --permission-mode bypassPermissions remains. Now that MCP tools visibly show an approval dialog, a user has every reason to believe "wait" fully protects them, when built-ins still auto-run silently. (codex-rescue)

[minor] src/backend/services/mcp/mcp-tool-bridge.ts:106getWhitelistWithServerPrefixAsync() is unguarded in the ask/wait gate, unlike the toolset lookup which is deliberately wrapped in collectToolsOrEmpty for the same reason. A throw here propagates past the "never a transport error" contract the AGENTS.md update promises. Pre-existing for ask; this PR widens it to also cover wait. (code-review)

[minor] src/backend/services/user-interaction/user-interaction-service.ts:126 (not directly diffed by this PR, so no inline comment could be posted — noted here instead) — requestToolApproval's promise depends entirely on a renderer-side countdown to resolve; the main process enforces no timeout of its own, and neither does the bridge's HTTP server or client. This is pre-existing shared infrastructure, but this PR is what newly routes a headless-spawned claude -p process's tool calls through this same promise for the first time under wait — previously that path always auto-ran instead of waiting, so a crashed/unresponsive renderer is a new (if narrow) way for a headless run to hang. Suggested fix: a main-process fallback timer that force-resolves after autoApproveAt + grace period. (codex-rescue)

[minor] src/backend/services/mcp/backlog-orchestrator.ts:63 (not directly diffed by this PR, so no inline comment could be posted — noted here instead) — ManualLoopOptions.signal's doc comment still says "the headless local-Claude backend has no approval prompts," which this PR makes false. Worth a one-line fix alongside the rest of this PR's doc-comment cleanup. (code-review)

[nit] AGENTS.md:596 — "never a transport error either way" slightly overclaims given the unguarded-whitelist-throw gap noted above; tighten the wording once that's fixed, or now. (code-review)

Lens agreement/divergence

The 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 note

Per 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 shipwright address-review pass.


Posted by armada:muster (dual-lens: code-review + codex-rescue)

@tomek-i tomek-i removed the armada:reviewing Claimed by crows-nest; review->merge pipeline running label Jul 21, 2026
@tomek-i

tomek-i commented Jul 21, 2026

Copy link
Copy Markdown
Member Author

🔭 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 armada-labelled and eligible for a future tick's address/re-validate/merge pipeline.

@tomek-i tomek-i added the armada:reviewing Claimed by crows-nest; review->merge pipeline running label Jul 21, 2026
@tomek-i

tomek-i commented Jul 21, 2026

Copy link
Copy Markdown
Member Author

🔭 crows-nest: ready-PR pipeline started — review → address → re-validate → gated merge.

@tomek-i

tomek-i commented Jul 21, 2026

Copy link
Copy Markdown
Member Author

🔭 crows-nest / muster review — PR #988

Lens: code-review (conventions + correctness) — ran, no blocking findings.
Lens: codex-rescue — unavailable in this environment (no codex agent type registered). Review is degraded: single-lens.

Findings (2 minor, 0 blocking):

  1. minor src/backend/services/mcp/mcp-tool-bridge.ts:123deny_stop under wait mode only fails the single tool call, unlike the OpenAI/mcp-orchestrator path where deny_stop terminates the whole run. Not a regression (bridge can't reach into the CLI's own loop), but a real behavioural gap worth being explicit about.
  2. minor src/backend/services/mcp/local-claude-orchestrator.ts:728 — the new shaveId → env forwarding in resolveFrontDoorConfig has no direct unit-test coverage (mirrors a pre-existing, similarly-untested line one above it).

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: blocked — needs a human to either (a) accept the single-lens review as sufficient and merge manually, or (b) re-run muster once a codex:codex-rescue-capable environment is available.

@tomek-i tomek-i added armada:blocked ARMADA could not finish; needs a human and removed armada:reviewing Claimed by crows-nest; review->merge pipeline running labels Jul 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

armada:blocked ARMADA could not finish; needs a human armada Eligible for the ARMADA fleet to pick up

Projects

None yet

Development

Successfully merging this pull request may close these issues.

🐛 Bug - Claude Code mode ignores Wait approval mode and auto-executes all tools

2 participants