Safety hooks

Claude Code only. These bash hooks are Claude-Code-specific. They ship with the Claude Code plugin — installing the plugin is the way to get them. Agents on other MCP clients (Codex, Antigravity, Cursor, Zed, …) get the same credential-safe MCP tools but not these hooks — and don’t need them, because the MCP tools never put a token in argv, history, or errors in the first place.

The Claude Code plugin installs seven PreToolUse hooks that intercept tool calls before they run — they don’t change git itself, only what the agent is permitted to run. The plugin is the single delivery channel: a binary-only madtea (Homebrew, Scoop, release tarball) does not materialize these hooks — install the plugin to add them. An MCP-only user who installs neither still keeps the credential-safe MCP tools, which never leak credentials in the first place.

Getting the hooks

The hooks ship with the madtea Claude Code plugin (no weakened variant; ADR 0009). Add the plugin marketplace, then install the plugin:

/plugin marketplace add https://codeberg.org/sixfold_space/claude-plugins.git
/plugin install madtea@sixfold-plugins

The plugin layers these hooks (and the forge-workflow / dev-workflow skills) on top of the same MCP tools. madtea install — the one setup command that installs the binary, shell completions, and the Claude Code MCP-server registration — prints a one-line pointer to the plugin when Claude Code is present, but the binary itself is not a hook-delivery channel: it never materializes hook scripts or merges into ~/.claude/settings.json.

What the plugin installs — the guard list. Each hook and exactly what it stops the agent from doing:

  • check-git-commit.sh — Blocks raw git commit and the commit-creating continuation forms (git merge/revert/cherry-pick --continue, git am --continue/--resolved, including under an env-var prefix like GIT_EDITOR=true); redirects to madt_commit (which completes an in-progress merge/revert/cherry-pick) and enforces the feature-branch + madtea-finish workflow on the default branch (empty-repo initial commit allowed).
  • check-git-remote.sh — Blocks raw git push/fetch/ls-remote/remote -v and git clone <url> only when a remote URL embeds a credential token; redirects to madtea push/pull/finish/clone.
  • check-git-worktree.sh — Blocks raw git worktree add/list/remove/prune; redirects to madt_worktrees. remove refuses a dirty or locked worktree and carries no force override on any madtea surface — force-discarding it is an unrecoverable, user-only raw-git action.
  • check-git-local.sh — Blocks state-mutating raw git (git add, branch create/delete, rebase) toward madt_* tools; advises (non-blocking) on pure-read git.
  • check-git-cli.sh — Blocks shelling out to the madtea CLI for a forge/git op; redirects to the matching madt_* MCP tool (madtea status -> madt_status).
  • check-git-secrets.sh — Blocks Bash commands that would print a git-stored credential (whole-config dumps, –get-regexp/–get-urlmatch over-match, token/url keys, .git-credentials reads).
  • check-foreign-primary.sh — Blocks raw git mutations (Bash) and file edits/writes (Edit/Write/NotebookEdit) targeting a foreign repo’s primary checkout (shared-state protection). Governing policy: ADR 0019.

Each guard is described in full in the per-guard sections below.

Removal. Uninstall the plugin (/plugin uninstall madtea@sixfold-plugins) to remove the hooks along with it.

Why they exist

An AI agent with bash access can run git push/git remote -v directly. If your remote URL has a token embedded (https://user:token@host/...), that token flows straight into the agent’s context — and from there to whoever runs the model. These hooks steer the agent toward madtea’s credential-safe wrappers (madtea push/pull/finish), which route the token through GIT_ASKPASS so it never appears in argv, history, or error output. The underlying threat model and credential-storage backends are in credential-safety.md.

These guards are non-overridable from inside an agent session — there is no agent-reachable opt-out (ADR 0009). The hooks fire only on Claude Code’s Bash tool (PreToolUse); a human at a terminal — or a ! bang — never hits them and already has unconditional raw git/CLI. An opt-out would therefore only let the agent off the steering leash, which is exactly the guarded party switching off its own guard.

The guards come in two tiers (ADR 0021). The hard-deny tier — state mutation (git add/branch create+delete/rebase), worktree ops (the dedicated worktree steering guard covers add/list/remove/prune), credential exposure (credential-leak and credential-read guards), and shared-state protection (the foreign-primary clamp) — blocks with permissionDecision: deny and is non-overridable: it gates only what load-bearing policy exists for. The advisory tier — pure-read local-git steering — does not block; it emits a non-blocking additionalContext pointer to the richer madt_* call and lets the command run, because the madt_* surface is a projection of git that can never cover its full read surface, so a hard block on a read would eventually wedge an agent and train hook-evasion. The advisory is still non-overridable in the sense that matters — an agent cannot switch it off — it is simply non-blocking; demoting reads to advisory was a human policy change, not an agent opt-out. If a madt_* tool is missing or broken, the remedy is to fix madtea, never to bypass — the non-steered passthrough verbs (rebase/merge/diff/reset/…) stay available so the agent is never wedged. The one structural carve-out on the deny tier is the empty-repo initial commit (a repo with no HEAD), which keys on git state, not on any setting.

Credential-leak guard

Blocks raw git push/fetch/ls-remote/remote -v/remote show/remote get-url and git config --get remote.*.url only when the repo’s remote URL has user-info embedded (e.g. https://user:token@host/...). Repos using SSH or credential helpers are unaffected. Points agents at madtea push/pull/finish, which route credentials through GIT_ASKPASS.

It also blocks git clone <url> when the clone URL itself carries an embedded token (git clone https://user:token@host/...) — that token lands directly in argv, shell history, and the agent’s context, so the guard catches it regardless of the current directory’s remotes and redirects to madtea clone <owner>/<repo>. A plain git clone https://host/repo (no credentials) or an SSH clone is left alone.

git pull is not blocked: git strips credentials from its display output, and madtea pull exists as the preferred wrapper. Local-only git (status/diff/add/commit/branch/tag/log) and all madtea commands pass through untouched.

The guard resists common shell evasions while avoiding false positives. It splices backslash line-continuations and folds multi-line commands so a git/subcommand split across lines is still caught; it follows cd/pushd so a remote op run after changing into a credentialed repo is evaluated against that repo; and it resolves quoting the way the shell does — a quoted or partially-quoted subcommand (git 'push', git "fetch", git pus'h') is functionally git push/git fetch and is blocked, while a trigger word that only appears inside a quoted commit message or echoed string (git commit -m "fix: git push race") is left alone.

Default-branch guard

Blocks git commit when HEAD is on main/master (or whatever madtea.default-branch is set to). Steers toward the feature-branch + madtea finish workflow. The empty-repo initial commit (no HEAD yet) is allowed so a fresh repo can still be bootstrapped.

It also blocks — on any branch — the continuation forms that create a commit by another name: git merge --continue, git revert --continue, and git cherry-pick --continue (each completed instead by madt_add + madt_commit, which now concludes an in-progress merge/revert/cherry-pick), and git am --continue / git am --resolved (git am has no madtea projection — the exit is git am --abort). A leading environment-variable assignment (e.g. GIT_EDITOR=true git commit or GIT_EDITOR=true git merge --continue) is part of the scanned line and never dodges the match. The mid-operation aborts (git merge/revert/cherry-pick/am --abort) and git rebase --continue (a rebase is not concluded by a commit and has no madt_commit stand-in) are left available.

Local-git steering guard

Steers the raw local-git forms that a one-call madtea MCP tool replaces, redirecting the agent to that tool (it never touches remote ops — those are the credential-leak guard’s domain). It works in two tiers (ADR 0021): a hard-deny tier for forms with a load-bearing policy rationale (state mutation, shared-state protection), and a non-blocking advisory tier for pure reads.

Hard-deny tier (mutation / shared-state)

Blocks with permissionDecision: deny and redirects to the replacing tool:

Raw git formRedirect to
git add (staging)madt_add / madt_commit
branch creation (git switch -c/--create/-C, git checkout -b/-B, bare git branch <name>, flag-led git branch -t/--track/--no-track/-f)madt_branch_create
git branch -d/--delete <name> (safe merged-only delete)madt_branch_delete
git rebase <base> / bare / --ontomadt_pull rebase=true / madt_worktrees action=rebase
foreign-primary git switch/checkout <branch>, git pull/fetch, and git mergeworktree flow (ADR 0019 — shared state, not steering)

Advisory tier (pure reads — non-blocking)

The command runs; the hook emits a non-blocking additionalContext “for next time” pointer (no permissionDecision field, so the user’s permission flow is untouched). These eleven pure reads cannot mutate anything and never contact a remote:

Raw git formPointer to
git status (any flags)madt_status
git branch read/list (--show-current, --list[=pat], -v/-vv/--verbose, bare)madt_status / madt_branches_remote
git rev-parse HEAD-sha (HEAD, --short HEAD, @, --verify HEAD) and bare --git-dirmadt_status / the madt_commit confirm
git log (any read form: plain, --oneline, -n N, ranges, --grep, git log | grep)madt_log action=list (grep= for messages)
git ls-tree (any form)madt_ref_inspect action=tree
git show <ref>:<path> (colon form)madt_ref_inspect action=show
git grep <pattern> <ref>madt_ref_inspect action=grep
git tag / -l / --list[=pat] (LOCAL tags)madt_tag_list (madt_tags is named only as the remote-forge pointer)
git diff <ref> (ref before --)madt_ref_inspect action=diff
git ls-files --deleted/-d/-v/--verbosemadt_ref_inspect action=checkout_state
git sparse-checkout listmadt_ref_inspect action=checkout_state

A compound command matching BOTH a deny form and an advisory form is denied (deny wins over advise).

Chainable read-only trio (legacy tracker 2120, Decided 2026-07-10). The verify-in-one-breath trio — git log -<N>/--oneline, git rev-parse --short HEAD, git status --short/--porcelain — is permitted cleanly (the advisory is suppressed, so no nudge) when it is chained via && with a genuine non-git work segment, e.g. pwd && git log --oneline -1 && go test ./.... This lets a lead capture branch@hash proof in the same command as the build/test it verifies. Standalone use of the trio still gets the advisory nudge. The carve-out stays tight and never widens the deny surface: it only ever suppresses a non-blocking advisory (it emits no allow, so it grants nothing the advisory did not already let run); the deny tier runs first, so a mutating/remote/destructive git anywhere on the line is still classified as its deny form before the trio check; a “non-git work segment” excludes both git and cd, so a second git op (even a destructive one) cannot pose as the non-git segment; a foreign-repo target keeps its dir= advisory; and only the narrow trio variants qualify (git log -p, git status -v, and any extra flags keep the nudge).

Everything a tool does not replace stays fully silent — neither deny nor advisory: git branch -r/-a (remote refs), branch force-delete/rename/copy (-D/-m/-c), switching to an existing branch (git switch <existing> / git checkout <existing>), git checkout -- <path> file restore, git worktree (all subcommands — owned entirely by the dedicated worktree steering guard), git rev-parse plumbing (--show-toplevel/--abbrev-ref/…), plain git show <commit> (no colon) and git show-ref/git show-branch, a refless git grep, a no-ref git diff / git diff --cached / git diff --no-index <a> <b> (its positionals are filesystem paths, not refs — so it is silent, not advised), other git ls-files forms, and non-list git sparse-checkout subcommands. The guard follows cd/pushd so a form run after changing into a repo is evaluated against that repo, and it fails open (a parsing hiccup never blocks or advises a legitimate command).

Worktree steering guard

Steers raw git worktree subcommands toward the madt_worktrees MCP tool (MCP sessions) and madtea worktree CLI (terminal humans). This is a dedicated guard that covers all four gated subcommands in one place.

Steered subcommands (hard deny):

Raw git formRedirect to
git worktree add <path> [<branch>]madt_worktrees action=add dir= branch=
git worktree listmadt_worktrees action=list
git worktree remove <path>madt_worktrees action=remove dir=
git worktree prunemadt_worktrees action=prune

list is steered for habit-forming consistency, not safety — the deny text says so explicitly. madt_worktrees action=remove refuses a dirty or locked worktree and carries no force override — force-discarding it is a user-only raw-git action — matching the guard that steers to it.

Pass-through subcommands (no equivalent — fully silent): lock, unlock, move, repair. Denying these would leave no sanctioned path, so they are never intercepted.

A compound command matching more than one steered subcommand shows the deny message for the most safety-critical one (priority: remove > prune > add > list). The deny text names the entire invocation as blocked so the agent knows none of it ran. Like all guards it is non-overridable and fails open.

Foreign-primary mutation guard

Enforces ADR 0019a foreign repository’s primary checkout is immutable to madtea — against the raw Bash and file-editing tools, closing the gap the MCP-layer refusals (madt_commit/madt_branch_create/madt_pull) can’t reach. It fires on two matchers, both dispatched from one script:

  • Bash: a working-tree/HEAD/index-mutating raw git verb (pull, merge, rebase, checkout, switch, restore, reset, cherry-pick, revert, am, apply, clean, add, rm, mv, commit, and mutating stash subcommands) whose effective target repo — resolved from git -C <dir>, a cd/pushd prefix, or the hook’s cwd — is a foreign primary.
  • Edit / Write / NotebookEdit: a tool call whose file_path/notebook_path lands inside a foreign primary (a not-yet-existing path resolves via its nearest existing ancestor).

A checkout is primary when git rev-parse --git-dir equals --git-common-dir (a linked worktree differs), and foreign when its git-common-dir differs from the session repo’s (CLAUDE_PROJECT_DIR when set, else the hook’s cwd). The block fires only when the target is both; linked worktrees of a foreign repo are the sanctioned writable surface and are always allowed, and git fetch (the refs-only ADR 0019 carve-out) and the remote ops owned by the credential-leak guard are never touched here. The denial teaches the sanctioned worktree flow (madt_worktrees action=addmadt_commit in the worktree → madt_finish) with the resolved foreign-primary path filled into dir= — it never suggests touching the foreign primary itself, not even a refs-only madt_fetch (legacy tracker 2039, the never-freshen ruling). Like the others it is non-overridable and fails open.

CLI steering guard

Blocks an agent shelling out to the human madtea CLI for a forge/git op (madtea status, madtea issue list, madtea finish, …) and redirects it to the matching madt_* MCP tool — the CLI analogue of the local-git steering guard. Detection is command-position / segment-based, so madtea only matches when it is the command word, never when it appears as a path component (this repo lives at /path/to/madtea) or argument. Only verbs with a one-call MCP equivalent steer; dev/config verbs (setup, docs, version, …) and a bare madtea stay silent. Like the others it is non-overridable and fails open.

Credential-read guard

A defense-in-depth backstop that hard-denys (not redirects) the common and accidental ways a Bash command would print a git-stored credential into the agent transcript. It is a higher-stakes class than steering and has no opt-out of any kind (it never reads git config to decide). It denies:

  • git config --list / -l (any scope, including --show-origin) — dumps the whole config, token included.
  • git config --get-regexp … / --get-urlmatch … for any pattern — a pattern read over-matches and prints credential keys; this is the exact command (git config --global --get-regexp '^madtea\.') that leaked a live token. The safe path is a single explicit --get <key>.
  • git config [--get|--get-all| ] <key> when <key> is credential-ish — matching (case-insensitive) token/password/passwd/secret/bearer/credential/extraheader, or the anchored remote\..*\.url / ^url\. (remote and url-rewrite entries embed the token on this host).
  • any command whose argument list (or < redirect target) references the credential store by basename .git-credentials (e.g. cat ~/.git-credentials, cp ~/.git-credentials …, dd if=$HOME/.git-credentials, mapfile < ~/.git-credentials) — keyed on the file, not a fixed reader list, so a non-enumerated reader can’t slip it past.

It is a deny-pattern guard: it blocks keys that match the credential pattern and stays silent on everything else, so reading one explicit non-secret key (git config --get user.name, core.editor, branch.*, or a vendor key whose name merely contains url without matching the anchored rules) is unaffected. git/config as a path component or argument, and any non-git command that does not reference .git-credentials, are likewise silent. Each deny names the cause and the safe path: read one explicit key with git config --get <key>, and to confirm madtea auth works without printing a token, run madtea repo list --limit 1. Like the rest it uses command-position detection (splitting on backticks as well as $(…), so a backtick command substitution is caught symmetrically), first-line-only + quoted-span stripping, and fails open.

This guard is one layer, not a sandbox — it is non-overridable but not exhaustive. It blocks the common, accidental leak vectors above; it does not prevent an arbitrary, determined exfiltration, because a shell pattern-matching hook cannot be complete. Known residuals: (a) it inspects only the first physical line of the command (shared with the sibling command-position hooks, chosen to avoid heredoc/multi-line false positives), so a read placed on a later line is not scanned; and (b) obfuscated or encoded reads (base64/hex/variable-indirection) can evade the patterns. The supported credential-safe surface remains the madt_* MCP tools, which never put a token in argv, history, or errors in the first place.

Canonical source: docs/safety/safety-hooks.md in the madtea repo.