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):
- A compound
git add && git commitwas blocked by the local-git hook, but the deny text did not say the whole line was rejected — the agent assumed thegit addhad run. 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.git merge --continuethen failed with “unmerged files” (because the add from step 1 never ran), indistinguishable from yet more steering.- The working exit —
madt_addthe 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.
- 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.
- 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 adddid not execute).” - 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_addthe 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. - 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.”
- 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.)
- 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 nomadt_*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 rawgit <op>only in--continue/--abort/--skipcontrol 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-progressmadt_add+git merge --continuerail, 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.