ADR 0009 — Agent-facing guards are non-overridable; no agent-reachable opt-out

  • Status: Accepted
  • Amended: 2026-07-07 by ADR 0021 — pure-read local-git steering demoted to non-blocking advisory; blocking guards (mutation, credential-exposure, shared-state) are unchanged.
  • Date: 2026-06-28
  • Tracking: legacy tracker 1721

Context

madtea’s Claude Code plugin ships four PreToolUse Bash hooks (check-git-{commit,local,cli,remote}.sh). They intercept the agent’s Bash tool and either steer raw git / madtea CLI calls to the credential-safe madt_* tools, or hard-block the raw remote ops that would leak a credential token embedded in a remote URL.

Each hook honoured a git-config opt-out the agent could set:

  • madtea.allow-raw-commit — umbrella key disabling raw-git/CLI steering in check-git-{commit,local,cli}.sh.
  • madtea.allow-default-commit — lifted the default-branch commit block in check-git-commit.sh.
  • madtea.allow-raw-remote — disabled the credential-leak guard in check-git-remote.sh.

Two facts make these opt-outs a net loss:

  1. The hooks only ever constrain the AGENT. They fire exclusively on Claude Code’s Bash tool (PreToolUse, matcher Bash). A human at a terminal — or a ! bang — never reaches them and already has unconditional raw git/CLI. So the opt-out was never meaningful for a human; its only effect was to take the agent off the steering leash.

  2. An escape the agent can reach defeats the guard. A guard the guarded party can switch off is one the agent will hunt down and disable the moment it is inconvenient. Concretely: the keys were read with git config --get (no --local), so a stray value in the machine-global ~/.gitconfig silently disabled all steering in every repo — and the deny messages actively recommended the --global form (observed live 2026-06-28: a leftover global allow-raw-commit=true disabled steering and broke the cd-aware hook tests). Nothing blocked git config writes either, so the agent could set the key on itself and then run raw git.

This is the same posture already taken by block-claude-attribution.sh: a deliberate, non-overridable hard block. An advisory rule the agent can override is worthless; the guard only means something when it cannot be switched off from inside the session it governs.

Decision

Agent-facing guards are non-overridable. No hook reads an agent-reachable opt-out. All three madtea.allow-* keys are removed from the four hooks: the git config --get madtea.allow-* reads and their early-exits, and the “To opt out … git config --global madtea.allow-…” footer in every deny message. The header comments and inline comments describing the opt-outs are scrubbed too — a no-optout-guard.test.sh regression guard greps every hooks/scripts/*.sh and fails (in CI, via the gate’s hook-tests step) if the literal string madtea.allow- reappears anywhere, comments and messages included.

The cd-aware effective-directory resolution is kept where it serves a legitimate repo-scoped read (branch classification, the empty-repo check, madtea.default-branch); only the opt-out reads are gone.

Coverage gaps are fixed in madtea, never bypassed

If a madt_* tool is missing or broken, the remedy is to fix madtea, not to re-open a raw-git escape. The agent is never wedged by removing the opt-out: the non-steered passthrough verbs (rebase merge diff reset revert stash cherry-pick fetch checkout tag) remain available, and a human can always intervene from the terminal or a ! bang. A visibly-stuck agent surfacing “madt_X is broken” is strictly better than a silent raw-git bypass.

The one allowed carve-out is structural, not an opt-out

check-git-commit.sh still allows the initial commit on an empty repo (a repo with no HEAD, rev-parse HEAD fails → exit 0), so a fresh repo can be bootstrapped. This is the lone exception, and it is deliberately structural: it keys on the absence of HEAD — a fact about git state — not on any agent-settable configuration. It cannot be repurposed as an opt-out.

Consequences

  • No fork of a prior decision; it strengthens one. This aligns with ADR 0001 (agent conventions live in the MCP layer and reach every client; the hooks are Tier-2 mechanical enforcement) and follows the block-claude-attribution.sh precedent of a deliberate non-overridable block. ADR 0006’s “empty repo is the initial-push exception” carve-out is mirrored here as the one structural exemption.

  • Breaking for anyone who relied on an opt-out key. In practice only an agent could have benefited (a human is unhooked), so the blast radius is the intended one: the agent can no longer disable its own steering or credential-leak guard. The --global foot-gun (one stray value disabling steering machine-wide) is eliminated.

  • Drift is CI-locked. Per the consistency-or-nothing rule, the no-opt-out decision is enforced by no-optout-guard.test.sh, which also self-checks that its grep actually bites (it asserts a planted opt-out line would fail), so the guard can never silently become a no-op.

  • Subsumes the core of legacy tracker 1692. The four non-hermetic hook-test cases were all opt-out cases; removing the opt-out deletes both those cases and the cd-aware global-config read that caused the non-hermeticity.

Alternatives considered

  • Keep the opt-out but read it --local only. Rejected: it removes the stray-global foot-gun but not the core flaw — the agent can still write the local key and disable its own guard. An escape the guarded party can reach is the thing this ADR exists to eliminate.

  • Block git config writes to the opt-out keys instead of removing them. Rejected: it bolts a second guard onto a now-pointless setting and invites evasion races (config can be set many ways). Removing the read entirely is simpler and leaves nothing to evade.

  • Make the guards advisory (warn, don’t block). Rejected: an advisory the agent can ignore is worthless for a credential-leak guard; the whole point is a hard block the session cannot relax.

Canonical source: docs/adr/0009-non-overridable-agent-guards.md in the madtea repo.