Background
Every chat turn happens in one of five lifecycle stages: build, view, push, teardown, free (see README "Lifecycle stages"). The intent is that mutating tools are only reachable in the stage that matches — you deploy in push, delete in teardown, and build/view/free are read-only.
The stage arrives on the request body as body.stage (backend/src/routes/chat.ts:33-44).
Problem / Goal
Stage restrictions are enforced only as an English instruction in the system prompt, never in code. In chat.ts the stage maps to a natural-language STAGE_GUARD block (chat.ts:46-57) that tells Claude what's allowed. But:
- The actual tool list handed to the model is built by
getClaudeTools(cloud) in backend/src/claude/tool-bridge.ts:79, which does not take stage into account at all — deploy_bicep, destroy_azure (and the AWS equivalents, plus every mutating Azure MCP *_create/*_delete tool) are always present regardless of stage.
- The dispatcher
callMcpTool (tool-bridge.ts:169) executes whatever tool the model names, with no stage check.
So the only thing stopping a destroy during a build/view/free turn is the model choosing to obey the prompt. A prompt-injection payload (from an MCP tool result, an Azure resource name, template text, etc.) or a plain model slip can invoke destroy_azure or deploy_bicep in a read-only stage. stage is also fully client-controlled, so there is no server-side authority binding a mutation to the right stage.
This is an authorization gap: the security boundary is advisory, not enforced.
Where to look
backend/src/routes/chat.ts:46-57 (STAGE_GUARD), :186-212 (tool loading + stage handling).
backend/src/claude/tool-bridge.ts:79-158 (getClaudeTools — no stage param), :169-207 (callMcpTool dispatcher).
backend/src/claude/custom-tools.ts — the mutating custom tools.
Suggested approach
Defence in depth — do both:
- Filter the tool list by stage so the model literally cannot see mutating tools in read-only stages (build/view/free). Thread
stage into getClaudeTools and drop deploy_bicep/destroy_azure/deploy_cloudformation/destroy_aws and known mutating MCP ops when the stage is read-only; restrict push to deploy tools and teardown to destroy tools. Mind the prompt-cache note below.
- Guard the dispatcher so that even if a mutating tool name reaches
callMcpTool in a disallowed stage, it is refused server-side with is_error: true before any cloud call.
Prompt-cache consideration: today the tool list is cached once per cloud and forms the cached prompt prefix (tool-bridge.ts:6-15). Per-stage tool lists will create a few more cache variants (one prefix per stage) — acceptable; document it. Do not try to keep a single prefix by leaving the tools in.
To reproduce: send a POST /api/chat with stage: "build" and a message that coaxes a destroy_azure call (or temporarily hardcode a destroy tool_use). Observe that today the call executes; after the fix it is unavailable/refused.
Acceptance criteria
- In
build, view, and free stages, deploy_bicep, destroy_azure, and the AWS deploy/destroy tools are absent from the tool list AND refused by the dispatcher.
- In
push, destroy tools are unavailable; in teardown, deploy tools are unavailable.
- A mutating tool call in a disallowed stage returns a clear
is_error result and performs no cloud action.
- Legitimate deploy in
push and destroy in teardown still work.
Testing
- Unit test:
getClaudeTools(cloud, stage) returns the expected tool subset per stage.
- Unit/integration test: dispatcher refuses a
destroy_azure call when stage is build.
Out of scope
- Fixing command injection inside the tools (separate parent).
- Requiring an explicit human confirmation token for destroy (see the destroy-safety parent).
Sub-issues: (1) stage-aware tool filtering, (2) server-side dispatcher guard.
Background
Every chat turn happens in one of five lifecycle stages:
build,view,push,teardown,free(see README "Lifecycle stages"). The intent is that mutating tools are only reachable in the stage that matches — you deploy inpush, delete inteardown, andbuild/view/freeare read-only.The stage arrives on the request body as
body.stage(backend/src/routes/chat.ts:33-44).Problem / Goal
Stage restrictions are enforced only as an English instruction in the system prompt, never in code. In
chat.tsthe stage maps to a natural-languageSTAGE_GUARDblock (chat.ts:46-57) that tells Claude what's allowed. But:getClaudeTools(cloud)inbackend/src/claude/tool-bridge.ts:79, which does not takestageinto account at all —deploy_bicep,destroy_azure(and the AWS equivalents, plus every mutating Azure MCP*_create/*_deletetool) are always present regardless of stage.callMcpTool(tool-bridge.ts:169) executes whatever tool the model names, with no stage check.So the only thing stopping a destroy during a
build/view/freeturn is the model choosing to obey the prompt. A prompt-injection payload (from an MCP tool result, an Azure resource name, template text, etc.) or a plain model slip can invokedestroy_azureordeploy_bicepin a read-only stage.stageis also fully client-controlled, so there is no server-side authority binding a mutation to the right stage.This is an authorization gap: the security boundary is advisory, not enforced.
Where to look
backend/src/routes/chat.ts:46-57(STAGE_GUARD),:186-212(tool loading + stage handling).backend/src/claude/tool-bridge.ts:79-158(getClaudeTools— no stage param),:169-207(callMcpTooldispatcher).backend/src/claude/custom-tools.ts— the mutating custom tools.Suggested approach
Defence in depth — do both:
stageintogetClaudeToolsand dropdeploy_bicep/destroy_azure/deploy_cloudformation/destroy_awsand known mutating MCP ops when the stage is read-only; restrictpushto deploy tools andteardownto destroy tools. Mind the prompt-cache note below.callMcpToolin a disallowed stage, it is refused server-side withis_error: truebefore any cloud call.Prompt-cache consideration: today the tool list is cached once per cloud and forms the cached prompt prefix (
tool-bridge.ts:6-15). Per-stage tool lists will create a few more cache variants (one prefix per stage) — acceptable; document it. Do not try to keep a single prefix by leaving the tools in.To reproduce: send a
POST /api/chatwithstage: "build"and a message that coaxes adestroy_azurecall (or temporarily hardcode a destroy tool_use). Observe that today the call executes; after the fix it is unavailable/refused.Acceptance criteria
build,view, andfreestages,deploy_bicep,destroy_azure, and the AWS deploy/destroy tools are absent from the tool list AND refused by the dispatcher.push, destroy tools are unavailable; inteardown, deploy tools are unavailable.is_errorresult and performs no cloud action.pushand destroy inteardownstill work.Testing
getClaudeTools(cloud, stage)returns the expected tool subset per stage.destroy_azurecall whenstageisbuild.Out of scope
Sub-issues: (1) stage-aware tool filtering, (2) server-side dispatcher guard.