Part of the stage-authorization parent issue.
Background
Even once the tool list is stage-filtered (sibling sub-issue), the dispatcher should independently refuse a mutating tool call that arrives in a disallowed stage — belt-and-braces, so a caching bug, a stale prefix, or a hand-crafted request can't slip a mutation through.
Problem / Goal
Add a server-side authorization check in the tool dispatch path: given the active stage and the tool name, refuse mutating tools in read-only stages (and deploy-vs-destroy cross-stage) before any cloud call runs.
Where to look
backend/src/claude/tool-bridge.ts:169-207 (callMcpTool dispatcher) and custom-tools.ts:callCustomTool.
backend/src/routes/chat.ts:337-367 — the per-tool dispatch loop where callMcpTool(t.name, t.input) is invoked; stage is in scope here.
Suggested approach
- Add a small pure predicate, e.g.
isToolAllowedInStage(name, stage): boolean, reusing the mutating-tool classification from the sibling sub-issue (factor it into a shared module so both use one source of truth).
- Thread
stage into callMcpTool (or check in the chat.ts loop just before calling it). If not allowed, skip the call and push a tool_result with is_error: true and a message like "<tool> is not permitted in stage <stage>".
- Ensure the loop still emits a
tool_result for that tool_use_id so the Anthropic message sequence stays valid.
Acceptance criteria
- A
destroy_azure/deploy_bicep call in a read-only stage returns is_error: true, spawns no container, and makes no cloud call.
- The chat loop continues cleanly (valid tool_result posted; no dangling tool_use).
- Allowed calls (deploy in
push, destroy in teardown) are unaffected.
Testing
- Unit test
isToolAllowedInStage for the mutating tools across all five stages.
- Integration test: dispatch a
destroy_azure with stage='build' and assert the refusal result and that spawnAndCapture/docker was never invoked (mock it).
Out of scope
- Tool-list filtering (sibling sub-issue).
Part of the stage-authorization parent issue.
Background
Even once the tool list is stage-filtered (sibling sub-issue), the dispatcher should independently refuse a mutating tool call that arrives in a disallowed stage — belt-and-braces, so a caching bug, a stale prefix, or a hand-crafted request can't slip a mutation through.
Problem / Goal
Add a server-side authorization check in the tool dispatch path: given the active
stageand the tool name, refuse mutating tools in read-only stages (and deploy-vs-destroy cross-stage) before any cloud call runs.Where to look
backend/src/claude/tool-bridge.ts:169-207(callMcpTooldispatcher) andcustom-tools.ts:callCustomTool.backend/src/routes/chat.ts:337-367— the per-tool dispatch loop wherecallMcpTool(t.name, t.input)is invoked;stageis in scope here.Suggested approach
isToolAllowedInStage(name, stage): boolean, reusing the mutating-tool classification from the sibling sub-issue (factor it into a shared module so both use one source of truth).stageintocallMcpTool(or check in thechat.tsloop just before calling it). If not allowed, skip the call and push atool_resultwithis_error: trueand a message like"<tool> is not permitted in stage <stage>".tool_resultfor thattool_use_idso the Anthropic message sequence stays valid.Acceptance criteria
destroy_azure/deploy_bicepcall in a read-only stage returnsis_error: true, spawns no container, and makes no cloud call.push, destroy inteardown) are unaffected.Testing
isToolAllowedInStagefor the mutating tools across all five stages.destroy_azurewithstage='build'and assert the refusal result and thatspawnAndCapture/docker was never invoked (mock it).Out of scope