In this guide (10 items)
Hooks — Deterministic Controls
Hooks — Deterministic Controls
MANDATORY FOR ALL AI AGENTS: A hook is the only control that cannot be talked out of. Guidelines and skills are advisory — the agent reads them and may still decide otherwise. When a policy must hold every single time, it MUST be a hook. You MUST NOT put an approval prompt in a build-phase hook, and you MUST NOT let any agent-authored change bypass a gate by editing the hook that enforces it.
Companion files: evals.md (§4 gates hook changes in CI), ai-sdlc.md (§2 where each control belongs), security.md, git-workflow.md §11.
1. The three control tiers
| Tier | Mechanism | Strength | Fails how |
|---|---|---|---|
| Guideline | guidelines/*.md | Advisory | Silently — the agent had context and chose differently |
| Skill | skills/*/SKILL.md | Applied when it triggers | Silently — the skill never fired |
| Hook | script + settings.json | Deterministic | Loudly — the action is blocked |
The escalation rule: a rule that has been violated twice does not need better prose, it needs a hook. Rewording a guideline after a breach is the most common and least effective response available; the rule was already there and was already read.
Not everything belongs at tier 3. A hook costs latency on every matching tool call, so reserve it for rules that are cheap to check, unambiguous, and expensive to violate.
2. Build hooks vs approval gates
The distinction the playbook draws, and the one most setups get wrong.
Build hooks — PreToolUse / PostToolUse
Run during the agent’s work. Fast, scoped to the changed files, fully automatic.
- Allowed: block, warn, auto-fix, log.
- Forbidden: ask a human anything.
A prompt here puts a person on the critical path of every edit. That destroys the throughput that made parallel sessions worth having, and it trains the human to approve reflexively — which is worse than no gate, because it produces an audit trail of approvals that mean nothing.
Budget: 3s for a blocking hook, 30s for a background formatter. A hook slower than that gets disabled by whoever is in a hurry, and then it is not a control at all.
Approval gates — deploy-time
Run before an irreversible, outward-facing action: production deploy, release authorisation, protected-path edit, force-push to a shared branch.
- Allowed:
allow,ask(pause for a human),block. - The block message MUST name the reason and the route to approval. A gate that says “denied” without saying who can unblock it gets routed around.
#!/usr/bin/env bash# deploy-gate.sh — release authorisation, deploy stageinput=$(cat)cmd=$(jq -r '.tool_input.command // ""' <<<"$input")case "$cmd" in *"deploy"*"prod"*|*"argocd app sync"*"prod"*) if [ -z "${RELEASE_APPROVAL:-}" ]; then echo "Production deploy requires RELEASE_APPROVAL." >&2 echo "Route: release manager sets it after the change ticket is approved." >&2 exit 2 # block fi ;;esacexit 0The agent may act up to a gate and never past it. That is the separation of duties the whole model rests on: the thing that wrote the change has no path to authorising it.
3. Current inventory, classified
Twenty hooks are registered. Classified against §2:
| Hook | Tier | Verdict |
|---|---|---|
claude-secret-scan.sh | Build, blocking | Correct. The one hook that blocks, on the one class of mistake that is unrecoverable once pushed. |
gsd-workflow-guard.js, gsd-prompt-guard.js, gsd-read-guard.js | Build, blocking | Correct — workflow invariants, no human in the loop. |
gsd-validate-commit.sh | Build, blocking | Correct. Extend it — see §4. |
claude-spotless-apply.sh | Build, auto-fix | Correct. Silent, throttled, per-project opt-out. |
claude-test-recency.sh | Build, warn-only | Weak. See §4. |
modern-cli-suggest.sh | Build, log-only | Advisory by design. Leave it; the eval case is the real enforcement. |
gsd-read-injection-scanner.js | Build, scan | Correct — untrusted-content defence. |
claude-usage-log.sh, gsd-context-monitor.js | Telemetry | Correct. This is the metrics substrate ai-sdlc.md §4 draws on. |
claude-control-path-guard.sh | Control plane, blocking | Added 2026-09-17. Blocks agent mutation of hooks/, settings.json, rules/, evals/run.sh and existing eval cases — via Write/Edit and Bash. Guards itself. |
claude-attribution-gate.sh | Build, blocking | Added 2026-09-16. Both filters from rules/git-attribution.md — message and added paths — with the Codehunters exemption resolved from the remote. |
| — | Approval gate | None exist. The largest remaining hole. |
The hole
Every control on this machine is build-phase. There is no gate on:
- force-push to a shared branch,
- production deploy,
- edits to
settings.json,hooks/orrules/— the controls themselves.
That last one is the sharp edge: an agent that can edit ~/.claude/hooks/ can disable the control that constrains it, and nothing today prevents that.
4. Gaps worth closing, in order
1. Attribution gate — DONE (claude-attribution-gate.sh). The rule in rules/git-attribution.md has been broken three times and cost two history rewrites. Prose plus memory did not hold it. Both filters now live in a standalone hook — message and added paths — with the Codehunters exemption resolved from git remote get-url origin. It is deliberately NOT hosted in gsd-validate-commit.sh: that file is GSD-vendored (overwritten on update) and opt-in per project, which is exactly wrong for a rule that must always hold.
remote=$(git remote get-url origin 2>/dev/null || echo "")case "$remote" in *[Cc]odehunters-IO*) exit 0 ;; esac # exemptgit diff --cached | grep -inE '^\+.*(claude|anthropic)' && exit 2Note the exemption covers the trailer only. The diff filter — never versioning CLAUDE.md, .claude/, .mcp.json — applies everywhere, Codehunters included.
The path filter is scoped to --diff-filter=A. Blocking edits to an already tracked CLAUDE.md (ForgeOS has one, deliberately) would fire on every legitimate change, and a hook that cries wolf gets switched off — which is how you end up with no control at all.
2. Control-path protection — DONE (claude-control-path-guard.sh). Controls the agent can rewrite are not controls. Four details decide whether this one works or gets switched off:
- It must cover Bash, not just Write/Edit. A guard on the edit tools alone is routed around by
cat > hook.sh,sed -i,rm,mv,chmod -xor a two-line Python script. Bash matching requires a mutating verb before it blocks, so reads —cat,rg,jq, running the eval suite — always pass. - It must match on the path field only, never on content. Matching the raw payload would block writing
guidelines/hooks.md, a file whose entire subject is the hooks directory. - In the Bash branch, the path must be the ARGUMENT of the verb — same line, verb first, path within ~80 characters. “A mutating verb somewhere AND a protected path somewhere” is not enough, because a shell command carries its own content inline: under that rule, authoring any document that describes the control plane trips the guard. This one blocked its own documentation within two minutes of being registered, and in auto-mode — where files are written by heredoc — that is constant. Prose reads “X blocks rm and mv”; a mutation reads “rm X”. Ordering is what separates them. Accept the residual over-block on
cp <protected> /tmp/: source and destination cannot be told apart without parsing argument positions, and looseningcpopens a real bypass. Over-blocking a read is cheap; under-blocking a write is the failure that matters. - What it does NOT protect is as important as what it does.
CLAUDE.mdandguidelines/**stay freely editable: the “mistake twice →CLAUDE.md” rule requires editing that file constantly, and a guard that fires on routine work gets disabled, taking the real protection with it. Advisory content stays advisory; only the enforcement layer is sealed. Likewise, creating a new eval case is allowed — it is the incident→eval workflow — while modifying or deleting an existing one is blocked, because that is the configuration equivalent of weakening a failing test.
One consequence to plan for: make every control-plane edit BEFORE registering the guard. Registering it mid-task locks the agent out of the hooks README, the settings file and the hook itself — including out of fixing a bug it has just found in that hook. The fix then has to be staged outside the control plane and applied by the human, or by a hatched session. That is the guard working correctly, and it is still an afternoon you do not want to spend.
The escape hatch is the design. CLAUDE_CONTROL_EDIT=1 claude lifts the guard for a session. This is not a loophole: each Bash tool call gets a fresh shell, so the agent cannot export the variable for its own later calls. Only the human launching the session can set it. A guard with no authorised route to legitimate work is a guard that gets deleted; one whose route is available only to the human is a guard that holds.
3. Test-tampering guard. claude-test-recency.sh warns about missing test runs. It does not stop the other failure: weakening the test to make the fix pass. During a bug fix, edits to the failing test file should be blocked — write the failing test first, then leave it alone (testing.md §2, evals.md §5).
4. Force-push gate. git push --force / --force-with-lease to a shared branch is the one build-time action that destroys other people’s work. It earns an ask.
5. Governance
| Property | Requirement |
|---|---|
| Version-controlled | Hook scripts and settings.json live in the repo. A hook only on one laptop protects one laptop. |
| Reviewed | Changes to hooks/ and settings.json are reviewed harder than feature code — they change what everything else is allowed to do. |
| Eval-gated | A hook change merges only on a green eval run (evals.md §4). |
| Logged | Every block is logged with timestamp and reason. A gate with no record proves nothing to an auditor. |
| Non-overridable | Gates that must survive a hurried engineer belong in managed settings, not repo settings. |
| Fail-open vs fail-closed | Build hooks fail open (exit 0 on internal error) so a broken script never wedges work. Gates fail closed — a gate that cannot evaluate must block. |
| The exception | claude-control-path-guard.sh is gate-shaped but fails open, because it matches every Write/Edit/Bash call on the machine: failing closed on a parse error wedges all work everywhere. Blast radius decides, not the label. |
That last row is the one to get right. The current inventory is uniformly fail-open, which is correct for everything in it today and wrong for the first approval gate added. A deploy gate whose script crashes and therefore allows the deploy is not a gate.
6. Anti-patterns
| Anti-pattern | Why it fails | Instead |
|---|---|---|
| Approval prompt in a build hook | Human on the critical path; reflexive approval | Move it to a deploy gate |
| Hook slower than its budget | Gets disabled by whoever is in a hurry | Scope to changed files; cache; background it |
| Blocking on a heuristic with false positives | Trains everyone to bypass | Block on high-confidence only, warn on the rest (the secret-scan split) |
| Gate the agent can edit | Not a gate | §4.2 |
| Gate that fails open | Not a gate | Fail closed with a named approval route |
| Hook with no log | Nothing to show an auditor | Log every block with timestamp and reason |
| Rewriting a guideline after a second breach | Prose already failed twice | Escalate to a hook (§1) |
| Hook that duplicates an eval | Latency on every call for a rule a test already covers | Hooks for must-never; evals for should-usually |
7. Quick reference
# inventorybat ~/.claude/hooks/README.mdjq '.hooks | keys' ~/.claude/settings.json
# add a hook$EDITOR ~/.claude/hooks/my-hook.sh && chmod +x ~/.claude/hooks/my-hook.shjq '.hooks.PreToolUse += [{matcher:"Write|Edit",hooks:[{type:"command",command:"bash ~/.claude/hooks/my-hook.sh",timeout:3}]}]' \ ~/.claude/settings.json > /tmp/s.json && mv /tmp/s.json ~/.claude/settings.jsonjq empty ~/.claude/settings.json
# test before trustingecho '{"tool_input":{"file_path":"x.kt","content":"sk-ant-api03-AAA"}}' | bash ~/.claude/hooks/claude-secret-scan.sh; echo "exit=$?"
# exit codes: 0 = pass, 2 = block (stderr is shown to the agent)