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 denypermissionDecision: "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:

  1. The madt_* surface is a PROJECTION of git, not git. For mutations the projection’s narrowness is deliberate policy (no commit to main, 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-index filesystem compares, plumbing shapes madt_ref_inspect does 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.

  2. 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=diff structurally cannot return — it returns clean text) was wedged by the diff block and learned evasion instead: script -qec "…" hides the command from the matcher, and git update-index dodges the add block. 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 mutationgit add, branch create/delete, git rebase, git worktree add|remove|prune. The madt_* narrowness is the point.
  • Credential exposurecheck-git-remote.sh and check-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.sh switch/checkout/pull/fetch on a foreign primary, and check-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 -l advisory names LOCAL madt_tag_list as the primary equivalent (madt_tags is 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 — since madt_ref_inspect has 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.sh asserts 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.sh still 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 explicit allow force-bypasses the user’s permission flow. Emitting only additionalContext leaves 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 (no allow), 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 — emitting allow — stays rejected here.)
  • The deny tier runs first, so a mutating / remote / destructive git anywhere on the chain (git add, git rebase, foreign pull/merge, …) is classified as its deny form before the trio check runs; the suppression only ever fires for a pure status/log/rev-parse read 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 git nor cd, 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/-C into another repo) is excluded — those reads keep their dir= 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.