Skip to content
My Guidelines
Guides
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

TierMechanismStrengthFails how
Guidelineguidelines/*.mdAdvisorySilently — the agent had context and chose differently
Skillskills/*/SKILL.mdApplied when it triggersSilently — the skill never fired
Hookscript + settings.jsonDeterministicLoudly — 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 stage
input=$(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 ;;
esac
exit 0

The 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:

HookTierVerdict
claude-secret-scan.shBuild, blockingCorrect. 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.jsBuild, blockingCorrect — workflow invariants, no human in the loop.
gsd-validate-commit.shBuild, blockingCorrect. Extend it — see §4.
claude-spotless-apply.shBuild, auto-fixCorrect. Silent, throttled, per-project opt-out.
claude-test-recency.shBuild, warn-onlyWeak. See §4.
modern-cli-suggest.shBuild, log-onlyAdvisory by design. Leave it; the eval case is the real enforcement.
gsd-read-injection-scanner.jsBuild, scanCorrect — untrusted-content defence.
claude-usage-log.sh, gsd-context-monitor.jsTelemetryCorrect. This is the metrics substrate ai-sdlc.md §4 draws on.
claude-control-path-guard.shControl plane, blockingAdded 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.shBuild, blockingAdded 2026-09-16. Both filters from rules/git-attribution.md — message and added paths — with the Codehunters exemption resolved from the remote.
—Approval gateNone 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/ or rules/ — 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.

Terminal window
remote=$(git remote get-url origin 2>/dev/null || echo "")
case "$remote" in *[Cc]odehunters-IO*) exit 0 ;; esac # exempt
git diff --cached | grep -inE '^\+.*(claude|anthropic)' && exit 2

Note 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 -x or 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 loosening cp opens 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.md and guidelines/** 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

PropertyRequirement
Version-controlledHook scripts and settings.json live in the repo. A hook only on one laptop protects one laptop.
ReviewedChanges to hooks/ and settings.json are reviewed harder than feature code — they change what everything else is allowed to do.
Eval-gatedA hook change merges only on a green eval run (evals.md §4).
LoggedEvery block is logged with timestamp and reason. A gate with no record proves nothing to an auditor.
Non-overridableGates that must survive a hurried engineer belong in managed settings, not repo settings.
Fail-open vs fail-closedBuild 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 exceptionclaude-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-patternWhy it failsInstead
Approval prompt in a build hookHuman on the critical path; reflexive approvalMove it to a deploy gate
Hook slower than its budgetGets disabled by whoever is in a hurryScope to changed files; cache; background it
Blocking on a heuristic with false positivesTrains everyone to bypassBlock on high-confidence only, warn on the rest (the secret-scan split)
Gate the agent can editNot a gate§4.2
Gate that fails openNot a gateFail closed with a named approval route
Hook with no logNothing to show an auditorLog every block with timestamp and reason
Rewriting a guideline after a second breachProse already failed twiceEscalate to a hook (§1)
Hook that duplicates an evalLatency on every call for a rule a test already coversHooks for must-never; evals for should-usually

7. Quick reference

Terminal window
# inventory
bat ~/.claude/hooks/README.md
jq '.hooks | keys' ~/.claude/settings.json
# add a hook
$EDITOR ~/.claude/hooks/my-hook.sh && chmod +x ~/.claude/hooks/my-hook.sh
jq '.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.json
jq empty ~/.claude/settings.json
# test before trusting
echo '{"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)