Background
destroy_azure (in backend/src/claude/custom-tools.ts, runDestroy ~line 604) tears down resources for a project/topology. In tag-filter mode it runs az group list across the entire subscription and deletes every resource group whose tags match ALL the supplied filters, plus every standalone matching resource — with az group delete --yes --no-wait.
Problem / Goal
The delete is powerful and largely unguarded, so a too-broad or wrong tag filter can destroy far more than intended:
- Loose/empty filters aren't rejected meaningfully. The only guard (~line 611) requires either a resource group name or at least one non-empty tag entry. A single common tag (e.g.
mcp-project=lab — or worse, some generic key that also exists on unrelated resources) is enough to sweep the whole subscription for matches. There's no requirement that the canonical mcp-project tag be present, and no lower bound on specificity.
- No plan / dry-run. The tool deletes immediately; the model (and user) never see the match set before it's gone. Combined with
--no-wait, deletions are irreversible before anyone can react.
- Project-wide teardown filters by
mcp-project only (chat.ts:227), which is by design, but with no confirmation step a mis-scoped project name deletes everything tagged for that project across the subscription.
Given the SP typically has broad rights on the subscription, the blast radius of a bad filter is "everything the SP can see."
Where to look
backend/src/claude/custom-tools.ts — runDestroy ~604: filter guard ~611, RG listing/JMESPath ~637-661, delete loop ~651-653.
backend/src/routes/chat.ts:223-249 — the teardown system-prompt block that tells Claude which tags to pass.
Suggested approach
- Require a project anchor. Reject a tag-filter destroy unless it includes a non-empty
mcp-project (or azure-mcp-project) tag, so a destroy can never run on an unanchored/overly-generic filter. Keep the explicit resource_group_name mode as-is.
- Add a dry-run / plan mode. Compute and return the matched RG names + standalone resource ids without deleting, unless an explicit
confirm: true (or similar) is set. This lets the loop surface "about to delete N groups: …" and require a confirming turn.
- Sanity-cap the match set. If the number of matched resource groups exceeds a configurable threshold, refuse and return the list rather than mass-deleting, on the assumption the filter is wrong.
Reproduce locally: point AZURE_CLI_IMAGE at a stub that echoes canned az group list output, call runDestroy with an empty-value/over-broad filter, and confirm current behaviour proceeds to the delete branch; after the fix, confirm it refuses or returns a plan.
Acceptance criteria
- A tag-filter destroy without a non-empty
mcp-project/azure-mcp-project tag is refused with a clear message and deletes nothing.
- With a plan/dry-run flag, the tool returns the matched RGs/resources and performs no deletion.
- Deletion only happens when explicitly confirmed (flag set) or via the explicit
resource_group_name path.
- Legitimate per-topology and project-wide teardown still work when properly anchored + confirmed.
Testing
- Unit tests for the guard: missing project tag → refused; present → allowed. Empty match set and over-threshold match set → refused/plan.
- Integration test (stubbed az) verifying dry-run returns matches and does not reach the delete commands.
Out of scope
- Command-injection hardening and tag validation (separate parent) — but coordinate the "require non-empty project tag" rule with that parent's tag validation.
- Stage-based authorization (separate parent).
Sub-issues: (1) require a project anchor + reject over-broad/empty filters, (2) add a dry-run/plan-then-confirm flow.
Background
destroy_azure(inbackend/src/claude/custom-tools.ts,runDestroy~line 604) tears down resources for a project/topology. In tag-filter mode it runsaz group listacross the entire subscription and deletes every resource group whose tags match ALL the supplied filters, plus every standalone matching resource — withaz group delete --yes --no-wait.Problem / Goal
The delete is powerful and largely unguarded, so a too-broad or wrong tag filter can destroy far more than intended:
mcp-project=lab— or worse, some generic key that also exists on unrelated resources) is enough to sweep the whole subscription for matches. There's no requirement that the canonicalmcp-projecttag be present, and no lower bound on specificity.--no-wait, deletions are irreversible before anyone can react.mcp-projectonly (chat.ts:227), which is by design, but with no confirmation step a mis-scoped project name deletes everything tagged for that project across the subscription.Given the SP typically has broad rights on the subscription, the blast radius of a bad filter is "everything the SP can see."
Where to look
backend/src/claude/custom-tools.ts—runDestroy~604: filter guard ~611, RG listing/JMESPath ~637-661, delete loop ~651-653.backend/src/routes/chat.ts:223-249— the teardown system-prompt block that tells Claude which tags to pass.Suggested approach
mcp-project(orazure-mcp-project) tag, so a destroy can never run on an unanchored/overly-generic filter. Keep the explicitresource_group_namemode as-is.confirm: true(or similar) is set. This lets the loop surface "about to delete N groups: …" and require a confirming turn.Reproduce locally: point
AZURE_CLI_IMAGEat a stub that echoes cannedaz group listoutput, callrunDestroywith an empty-value/over-broad filter, and confirm current behaviour proceeds to the delete branch; after the fix, confirm it refuses or returns a plan.Acceptance criteria
mcp-project/azure-mcp-projecttag is refused with a clear message and deletes nothing.resource_group_namepath.Testing
Out of scope
Sub-issues: (1) require a project anchor + reject over-broad/empty filters, (2) add a dry-run/plan-then-confirm flow.