ADR 0021 — Pure-read local-git steering is non-blocking advisory, not a hard deny
- Status: Accepted
- Date: 2026-07-07
- Tracking: legacy tracker 2044, legacy tracker 2047
Context
madtea’s Claude Code plugin ships a local-git steering hook
(hooks/scripts/check-git-local.sh) that intercepts the agent’s Bash tool and
redirects raw git forms to the madt_* MCP tool that does the same job. Since
legacy tracker 1610/legacy tracker 1612 it did this by hard deny — permissionDecision: "deny", no
agent-reachable opt-out (ADR 0009).
Over time the hard-deny set grew to cover a large read surface: git status,
git branch read/list, git rev-parse HEAD, git log (every read form),
git worktree list, git ls-tree, git show <ref>:<path>, git grep <pattern> <ref>, git tag -l, git diff <ref>, git ls-files --deleted,
and git sparse-checkout list. The legacy tracker 2044 audit classified every hard-blocked
git form and found twelve pure reads blocked purely for MCP-tool steering
— they cannot mutate the object DB, refs, working tree, index, config, or
stashes, and never contact a remote. Their block carried no mutation,
credential, or shared-state rationale; the only reason was economics (steer the
agent onto the one-call structured madt_* tool).
Two facts (surfaced live in legacy tracker 2047, working legacy tracker 2045) make a hard deny the wrong mechanism for a pure read:
The
madt_*surface is a PROJECTION of git, not git. For mutations the projection’s narrowness is deliberate policy (no commit tomain, no force-delete, staged-only discipline). For reads the projection can never be complete — git’s read surface is unbounded (raw ANSI diff bytes,--no-indexfilesystem compares, plumbing shapesmadt_ref_inspectdoes not model). A hard block on a read is therefore guaranteed to eventually wedge an agent whose legitimate need falls outside the projection, with no opt-out (ADR 0009) and no human in the loop.A hard block with capability gaps trains hook-evasion — a safety inversion. In legacy tracker 2047 an agent that needed git’s raw SGR diff bytes (which
madt_ref_inspect action=diffstructurally cannot return — it returns clean text) was wedged by the diff block and learned evasion instead:script -qec "…"hides the command from the matcher, andgit update-indexdodges theaddblock. A guard that teaches the guarded party to dodge guards is worse than the read it prevented.
The current classifier is ~800 lines of bash regex parsing git’s grammar. Its
false positives (e.g. git diff --no-index <a> <b>, blocked today despite not
being a ref diff at all — the positional paths mistaken for refs) are denials,
not shrugs. A wrong advisory is a shrug; a wrong deny is an incident. None
of the twelve read steers is a strict superset of what it blocks — the tag
steer even pointed a LOCAL read at the REMOTE madt_tags — so there is no
defensible deny-keeping subset.
gh — the naming and behavior yardstick (ADR 0016) — never wraps or intercepts
local git reads. The drift here is not madtea-the-CLI (gh-parity and the
COMPARISON.md discipline hold); it is that the hook suite, a third product (the
agent policy layer), inherited the “route everything through madtea” instinct
from the CLI/MCP surfaces. Policy should gate only what policy exists for.
Decision
The hooks hard-block only what load-bearing policy exists for — state mutation, credential exposure, and shared-state protection. Pure-read local-git steering is demoted to a non-blocking ADVISORY.
The twelve pure-read forms above no longer deny. Instead the hook emits
hookSpecificOutput.additionalContext — a compact “for next time” pointer to
the richer madt_* call — and returns exit 0. The command runs. Crucially
the advisory emits no permissionDecision field at all: an "allow" would
force-bypass the user’s own permission flow, whereas omitting the field leaves
permission handling untouched and merely delivers the steer as context to the
model. (This additionalContext shape is verified delivered to the model in
Claude Code v2.1.202.)
Everything with a real policy rationale stays a hard deny, unchanged:
- State mutation —
git add, branch create/delete,git rebase,git worktree add|remove|prune. Themadt_*narrowness is the point. - Credential exposure —
check-git-remote.shandcheck-git-secrets.sh. A token-bearing URL or config must never enter the conversation.check-git-secrets.sh’s own header already draws this exact line (“an advisory rule is one an agent can violate — so this hook is a hard deny”); that stays true for secrets. - Shared-state protection — the foreign-primary clamp
(
check-git-local.shswitch/checkout/pull/fetch on a foreign primary, andcheck-foreign-primary.sh), ADR 0019. A foreign repo’s primary checkout is immutable state madtea does not own; these are blocked for shared state, not for steering, and are untouched here.
Precedence. A compound command matching BOTH an advisory form and a
still-deny form is DENIED — deny wins over advise (git status && git add x,
git log && git rebase main both deny). The classifier runs the deny tier
first with independent guards, then the advisory tier only when no deny fired.
Advisory is not an agent opt-out. Demoting reads to advisory is a HUMAN
policy change; an agent still cannot switch the steer off, and the deny guards
remain non-overridable (ADR 0009). no-optout-guard.test.sh passes unchanged —
no madtea.allow-* key is reintroduced.
Two matcher fixes ride along (both legacy tracker 2044/legacy tracker 2047):
- The
git tag -ladvisory names LOCALmadt_tag_listas the primary equivalent (madt_tagsis named only as the remote-forge pointer), fixing the prior local-read→remote-tool mismatch. git diff --no-index <a> <b>is recognized as NOT a ref diff (its positionals are filesystem paths; it works outside any repo) and is fully silent — no deny, no advisory — sincemadt_ref_inspecthas no equivalent.
Consequences
- Agents are never wedged on a pure read. The command runs; the steer is a
pointer, not a wall. The first-contact teaching value of the old denials (they
demonstrably steer agents onto
madt_status/madt_ref_inspect) is kept with no wedge and no workaround pressure. - Matcher false positives become harmless. A wrong advisory on ~800 lines of git-grammar regex is noise the agent shrugs off, not a denial that blocks legitimate work.
- The deny guards are unchanged and still non-overridable. Mutation, credential-exposure, and shared-state blocks keep the ADR 0009 posture. This ADR narrows what is blocked, not whether blocks can be overridden.
- Drift is CI-locked.
hooks/tests/check-git-local.test.shasserts each demoted form advises (tip present, no deny) and each still-deny form denies, plus the compound-precedence and--no-index-silent cases;no-optout-guard.test.shstill passes. - The middle rung, if ever needed. If fleet behavior later shows advisories
getting ignored, the escalation is: advise by default, deny only where a
madt_*tool is proven a strict superset of the blocked form (not the case for any of the twelve today).
Alternatives considered
- Keep the hard deny on reads. Rejected: the projection argument makes a read block guaranteed to wedge eventually, and legacy tracker 2047 showed it trains hook-evasion — a safety inversion strictly worse than the read it prevents.
- Delete the read steering entirely (no advisory). Rejected: the denials had
real first-contact teaching value (they demonstrably move agents onto the
structured
madt_*tools). Advisory keeps that value with none of the wedge. - Make the advisory emit
permissionDecision: "allow". Rejected: an explicitallowforce-bypasses the user’s permission flow. Emitting onlyadditionalContextleaves the human’s permission handling untouched and still delivers the steer. - Blanket-demote every hook to advisory (including mutation/credential/ shared-state). Rejected: those blocks are load-bearing policy. An advisory a credential-leak or shared-state guard could be ignored past is worthless — the whole point there is a hard block the session cannot relax. Only steering-for- economics on a pure read, whose failure mode is a structural capability gap, belongs in advisory context.
Amends ADR 0009 (the read-steering guards it made non-overridable are demoted to non-blocking advisory here; the mutation / credential-exposure / shared-state deny guards are unchanged and still non-overridable). Relates to ADR 0016 (gh-parity: gh never intercepts local git reads) and ADR 0019 (foreign-primary immutability, untouched).
Amendment — chainable read-only trio (legacy tracker 2120, Decided 2026-07-10)
Even as a non-blocking advisory, the pointer is noise inside the
verify-in-one-breath pattern a lead uses to prove “right worktree, right branch,
right HEAD” in the same compound command as the thing being verified
(pwd && git log --oneline -1 && go test ./...). The ruling (legacy tracker 2120): the minimal
trio — git log -<N>/--oneline, git rev-parse --short HEAD, and
git status --short/--porcelain — is permitted cleanly (advisory
suppressed) when it is chained via && with a genuine non-git work segment; the
madt_* nudge applies to standalone use only.
The carve-out cannot widen the deny surface, by construction:
- It only ever suppresses a non-blocking advisory — it emits no
permissionDecision(noallow), so it grants nothing the advisory did not already let run and cannot bypass the user’s permission flow. (Rejected alternative from the parent decision — emittingallow— stays rejected here.) - The deny tier runs first, so a mutating / remote / destructive git anywhere on
the chain (
git add,git rebase, foreignpull/merge, …) is classified as its deny form before the trio check runs; the suppression only ever fires for a purestatus/log/rev-parseread form. A remote-touching git exits the hook earlier still (the credential-leak guard’s domain). - A “non-git work segment” is a command word that is neither
gitnorcd, so a compound of two git commands (one of them destructive) never qualifies — a second git op cannot ride the chain posing as the non-git segment. - A foreign-repo target (cd/
-Cinto another repo) is excluded — those reads keep theirdir=advisory. Only the current repo’s verify is blessed. - Only the narrow trio variants qualify;
git log -p,git status -v, and any extra flags keep the nudge.
Locked by the legacy tracker 2120 block in hooks/tests/check-git-local.test.sh (blessed
compound forms clean, standalone forms still advise, all-git chains and
tricky/extra-flag variants still advise, and deny/remote precedence unchanged).
Canonical source: docs/adr/0021-read-steering-is-advisory.md in the madtea repo.