ADR 0026 — Guard texts are terminal or forwarding: no guard cycles, no dead ends

  • Status: Accepted
  • Date: 2026-07-14
  • Tracking: #15

Context

madtea steers agents away from raw, credential-unsafe or shared-state-unsafe git through two families of guards: the Claude Code plugin’s PreToolUse hooks (they deny/advise on the Bash tool) and the madt_* tool refusals (they reject an unsafe call and teach the safe one). Each guard was written well on its own. The failure mode is at the seams between them.

A live case study (an agent in a foreign-repo worktree, mid-merge-conflict):

  1. A compound git add && git commit was blocked by the local-git hook, but the deny text did not say the whole line was rejected — the agent assumed the git add had run.
  2. madt_commit’s foreign merge-in-progress refusal said “resolve or abort that operation directly in the target repo” — which reads as use raw git, the exact thing the hook steers away from. The hook and the refusal pointed at each other: a two-guard cycle.
  3. git merge --continue then failed with “unmerged files” (because the add from step 1 never ran), indistinguishable from yet more steering.
  4. The working exit — madt_add the resolutions + git merge --continue + madt_push + merge the PR — was named nowhere.

Each guard was locally correct; together they formed a maze with no marked exit. A guard that wedges the agent it guards trains evasion, which is worse than no guard.

Decision

Treat the guards as one directed graph and hold it to six rules. Every guard text is either terminal (it hands back a runnable command for the current arguments and repo state) or forwarding (it points at another tool whose own refusal continues the chain) — never a dead end, never a cycle.

  1. The guard graph is a DAG terminating in executable commands. Every deny/steer/refusal either gives the exact replacement call for the current arguments and repo state, or forwards to a tool whose own refusal continues the chain. Two guards may never reference each other’s territory without naming the state that breaks the loop.
  2. Compound-command denials state execution semantics explicitly. When a blocked form is part of a chain, none of it ran: say so and name the segment — “This ENTIRE invocation was blocked; none of it ran (the git add did not execute).”
  3. State-aware steers where cheap. The local-git hook detects a merge/rebase/cherry-pick in progress in the target repo and emits the continue-the-operation rail directly (madt_add the resolutions + git <op> --continue, the same rails the orchestrate reconcile flow prints), instead of a generic redirect that would bounce off another guard’s refusal.
  4. Say what NOT to do next. A deny adds one line so the agent does not bounce off the wall retrying variants — “Do not retry any raw git commit variant; every form is steered, every time.”
  5. Shipped strings state the rule, self-contained. One plain sentence of WHY per text (“raw git against a credentialed remote can leak the token”). Steering/error text names no ADR numbers, internal issue numbers, or internal repo/host names. (This is ADR 0023; rule 1 depends on it — a rule an agent cannot read cannot terminate the chain.)
  6. Name the executable exit verbatim. A refusal names the runnable exit (madt_add dir= + git merge --continue), never a vague “directly in the target repo”. The mid-operation controls (git <op> --continue/--abort/ --skip) have no madt_* projection, so they stay available and are the sanctioned terminal step — the refusal points at them so the chain ends in an action.

The forms a hook blocks (raw git add/commit, branch create/delete, bare rebase, worktree add/remove/prune, foreign switch/checkout/pull/fetch/merge) are exactly the forms no refusal may recommend. The mid-operation controls are the one raw-git family a refusal may name, because the hooks deliberately leave them available — a refusal that recommends one is forwarding to a real exit, not closing a loop.

Enforcement

  • A table-driven Go test (internal/mcp/foreign_guard_chain_test.go) walks the foreign-repo refusal rails and fails if any recommends a hook-blocked raw form, tolerating a raw git <op> only in --continue/--abort/--skip control form — so a refusal can never re-open a cycle into a blocked form.
  • Hook tests (hooks/tests/) lock the deny TEXT: the compound-block nothing-executed sentence, the merge-in-progress madt_add + git merge --continue rail, and the do-not-retry line on the commit and add steers.
  • The dangling-reference lint (internal/danglingref, extended here to the shipped hook scripts and manifest) keeps rule 5’s numeric half enforced: shipped strings carry the rule, not a tracker citation.

As a corollary of rule 1, the hook steers were corrected to name the sanctioned files=[...] staging form rather than the removed all=true blanket-staging mode (ADR 0024) — a steer to a form the tool now rejects is a dead end.

Consequences

  • Guard texts get slightly longer but strictly more useful: an agent that hits a wall is handed the next runnable step, not a riddle.
  • Writing a new guard now means tracing where its refusal sends the agent and confirming that destination is itself terminal or forwarding — the DAG property is a design obligation, checked by the chain test.
  • Rule 5’s non-numeric half (ADR numbers, host/repo names) is swept here for the shipped steer/refusal STRINGS in scope — the hook deny reasons and the madt_* refusal rails (foreign-primary pull, foreign worktree ownership, the orchestrate dangerous-verify refusal) now state their WHY in plain words and cite no ADR number. It is NOT yet lint-enforced: the static dangling-reference lint scans every Go string literal uniformly and cannot tell a refusal string from a tool DESCRIPTION, so extending its pattern to ADR numbers would also flag the description surface. Sweeping the ADR-number citations out of tool DESCRIPTIONS and help topics (which also ship) is a known follow-up, tracked separately; until then rule 5’s non-numeric half is held by review plus the per-refusal test assertions added here.

Relates to ADR 0009 (guards are non-overridable), ADR 0019 (foreign primaries immutable — the shared-state refusals this governs), ADR 0021 (steering-text quality is part of guard design), ADR 0023 (shipped strings self-contained), and ADR 0024 (declared-files staging — the all=true dead-end this removes).

Canonical source: docs/adr/0026-terminal-forwarding-guards.md in the madtea repo.