This article was originally published on EthereaLogic.ai.
The first article in this series introduced the five-layer governance stack and made a single load-bearing claim: the layers that live in documents are necessary, and the layers that run as code are what make the system trustworthy. This article goes inside the highest-leverage code layer — runtime enforcement via Claude Hooks — and shows what one looks like at the level of detail an engineering team would actually need to build it.
The thesis of the first article was that an instruction in CLAUDE.md or AGENTS.md can be ignored, reasoned around, or context-windowed out of an agent's working memory, but a hook that exits with status 2 cannot. The thesis of this article is that turning the abstract idea of a hook into a guard that holds under real subagent traffic is its own engineering discipline — one with a small but distinct set of patterns, failure modes, and tests. Most teams who reach the hook layer underestimate that engineering discipline. The empirical evidence in this article comes from one of those underestimations.
Layer 4 sits between the agent's intent and the tool call's effect. The hook receives the tool payload as JSON on stdin, decides allow or block, and on block exits with status 2 — the only exit code the Claude Code harness treats as a hard refusal whose reason is surfaced back to the model.
The Failure Mode Documents Cannot Close
Every team that operates an agentic coding workflow eventually meets the same failure mode: a rule that exists in the project's documentation, in the agent's instructions, in the user's persistent memory, and is still violated by a subagent operating at speed. The rule is not unclear. The rule has not changed. The agent has read it before. None of that prevents the next violation, because none of those locations are points of execution. They are points of intent. The tool call is the point of execution.
In the GovForge project on April 11, 2026 at 19:56 Pacific time, an automated /implement subagent produced commit 3f3b7f9 and pushed it directly to main. The rule "no direct pushes to main" was written in AGENTS.md, in the project Constitution as principle P1 ("protected branches are hard boundaries"), and in user memory as feedback_no_direct_push_main.md. Every one of those locations had been read by the subagent's parent context. The push happened anyway, because three enabling conditions stacked: the project's pre-tool-use.js hook existed in the repository as an empty stub copied from a sibling project's scaffold; .claude/settings.json had no PreToolUse registration, so even a non-empty hook would not have been invoked; and the operator's home-level Claude Code settings had skipDangerousModePermissionPrompt: true, which suppressed the confirmation dialog that would otherwise have caught the destructive operation. Forty-nine minutes later, commit b404fbe replaced the empty stub with a real protected-branch guard and registered it under PreToolUse:Bash in .claude/settings.json. The same class of attempt has exited with status 2 every time since.
The instruction existed. The instruction was not enough. The hook is the enforcement.
What a PreToolUse Hook Actually Is
A Claude Code hook is an executable that the harness invokes around tool calls. The protocol is small enough to describe in three sentences. The harness writes a JSON payload to the hook's stdin describing the tool name and the tool input. The hook decides whether to allow or block, and exits with status 0 to allow or status 2 to block. On a block, the harness reads the hook's stderr and surfaces it to the model as the refusal reason, which gives the agent a written explanation it can reason against on the next turn.
That last detail matters more than it sounds. The hook is not silent enforcement. It is enforcement that explains itself in natural language directly to the model that is now blocked, which means the same hook closes the failure mode and teaches the agent what to do instead — usually within the same turn. In the GovForge guard, the stderr message names the policy file the rule comes from, names the specific sub-command that was blocked, names the reason, and tells the model exactly what fix to attempt: create a chore/feat/fix branch, retry the commit there, and open a PR. After the hook lands, agents that hit the guard typically resolve it on the next message without operator intervention.
The registration is a small block in .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "node .claude/hooks/pre-tool-use.js" }
]
}
]
}
}
That is the entire interface. The matcher selects which tool calls the hook inspects — Bash here, because every git operation flows through the Bash tool. The command names the executable. There is no SDK. There is no daemon. There is one short script that runs, decides, and exits.
What the Guard Has to Catch
The hard part is not the protocol. The hard part is that "block direct commits and pushes to main" turns out to have a much larger surface than it appears.
The naive implementation inspects the Bash command for the literal token sequence git commit or git push, checks for the word main, and exits 2 if both are present. That guard fails on day one against a competent agent. Within the GovForge guard's 320 lines, six categories of bypass are closed:
Refspec rewriting. git push origin main is the obvious form. git push origin HEAD:main and git push origin +refs/heads/master are the same operation in different syntax. The guard has to parse the refspec, strip a leading +, split on the colon, normalize refs/heads/<branch>, and check the destination side against a PROTECTED_BRANCHES set. The source side is irrelevant; only where the commit lands on the remote matters.
Implicit refspec on a protected branch. git push with no refspec can update the current or upstream branch depending on push.default and the configured upstream. If HEAD is on main, the realistic outcomes all land commits on main. The guard calls git rev-parse --abbrev-ref HEAD and treats a protected current branch as a block when no explicit refspec is present, which closes the entire range of push.default configurations without having to model each one.
Broad-mode flags. git push --all and git push --mirror may update protected branches without the operator naming one. --all pushes every local branch, including main if it is local. --mirror is even broader, syncing refs the remote did not previously know about. Both are blocked unconditionally on the basis that the operator's intent is ambiguous and the failure mode is asymmetric.
Commit-producing subcommands that are not git commit. git merge, git rebase, git cherry-pick, git revert, git am, and git pull all produce new commits on HEAD without invoking git commit directly. A guard that only matches commit lets every one of these through. The GovForge guard maintains an explicit COMMIT_PRODUCING_SUBCOMMANDS set and treats a match against this set as equivalent to git commit when HEAD is on a protected branch. git pull is in the set because it is fetch followed by merge or rebase, which is exactly the failure path the guard exists to block when HEAD is protected.
Nested-shell bypasses. A whitespace tokenizer treats bash -c "git push origin main" as a three-token command where the first token is bash, not git. The naive token check does not fire. The GovForge guard recognizes the nested-shell pattern, extracts the inline payload, and recursively re-runs the full evaluator on the inner command with a depth limit of 4 to bound pathological nesting. The recognized forms are bash -c '...', sh -c '...', and zsh -c '...' — the real -c flags those shells expose — plus a defensive match against a --command= style flag that no listed shell currently supports but that an agent could plausibly hallucinate; the guard rejects the pattern up-front rather than relying on the shell to do it. On a block, the stderr message appends (via nested shell) to the reason so the model can see exactly which layer caught it.
Chained commands. git status && git push origin main is a single Bash invocation but two logical operations. The guard splits sub-commands on &&, ||, ;, newline, and a single pipe (|) before any other inspection, so each fragment is evaluated independently. The first fragment passes; the git push origin main fragment blocks the whole call. Treating | as a separator matters because agents often compose exploratory shell commands through pipes; the guard evaluates both sides rather than trying to infer whether the pipe is semantically meaningful.
The shape of those six categories tells you what is hard about hook engineering. It is not the policy. The policy is one sentence. It is enumerating the surface, finding the patterns that look superficially like the policy but are actually different commands, and deciding which side of the line each one is on. The 320 lines of pre-tool-use.js are almost entirely the enumeration.
Each category looks superficially like the policy but is a different command in syntax. All six are closed by explicit set checks, recursive evaluation, or command splitting before the guarded fragment can land.
Why The Hook Is Not A Security Boundary
A comment in the GovForge guard names this explicitly: the hook is advisory for the Claude harness, not a security boundary. A motivated human operating outside the harness can defeat it in any number of ways — base64-encode the payload, alias g to git, mutate .git/HEAD directly, set up a sibling clone with a different working tree. The guard does not try to defend against any of those. Trying would balloon the implementation and produce a false sense of containment.
The threat the hook defends against is narrower and more important: an agent operating at speed under the harness, with skipDangerousModePermissionPrompt: true enabled (which is the configuration most operators run because the prompts are too noisy under sustained automation), making a destructive call that violates a written rule. That is the case the GovForge incident produced. That is the case the guard is engineered to make impossible. The guard's value is that it closes a specific failure mode under a specific runtime, not that it is bulletproof against an adversary.
This distinction is worth being explicit about because the alternative framing — "if a determined attacker can bypass it, it is worthless" — leads teams to skip the hook layer entirely, which is the single highest-leverage mistake in the whole governance stack. The right framing is the same one engineers already apply to lint rules, type checkers, and pre-commit hooks: these tools defend against the realistic failure mode of a tired developer at 4 p.m., not against malice. They are load-bearing precisely because the realistic failure mode is the common one.
The Tests Are The Other Half
A hook that exits 2 on the right input and 0 on the right input is necessary but not sufficient. The other thing the GovForge incident proved is that a hook can silently degrade. The empty-stub state at the time of 3f3b7f9 was indistinguishable from a working hook from the operator's perspective: the file existed, the path was correct, the project looked governed. The runtime difference — the file exited 0 on every payload because there was no logic in it — was invisible until a destructive call ran. Nothing in the build system noticed. Nothing in CI noticed. The hook was invisible exactly when invisibility was the failure.
The regression suite that closes that gap is tests/test_pre_tool_use_hook.py — 22 test functions across 354 lines, expanded by pytest parametrization to 32 collected cases, exercising the hook end-to-end as a node subprocess against a real temporary git repository. The first two tests are the load-bearing ones for the empty-stub failure mode. T-1 asserts the hook file is non-empty, contains "use strict", and contains process.exit(2) somewhere — three properties that any working version of the hook satisfies and any empty stub fails. T-2 asserts that .claude/settings.json still registers the hook under PreToolUse:Bash. Together they assert that both enabling conditions of the original incident — the empty file and the missing registration — would be caught by CI before they shipped.
The remaining tests cover the surface enumerated above: explicit-refspec push to main, HEAD:main push, --all and --mirror broad modes, plain push while on a protected branch, commit while on a protected branch, every commit-producing subcommand on a protected branch, nested-shell bypasses through bash -c, sh -c, and the zsh -c family, chained commands with a protected-branch operation in the middle, single-pipe composition such as git status | git push origin main, and a battery of negative cases — push to a feature branch, read-only git commands on main, non-git commands, non-Bash tool calls, empty stdin — that all have to pass through unmodified. Each test instantiates a fresh git init repository under tmp_path and invokes the hook as a subprocess with the JSON payload the harness would send. There is no mocking. The test exercises the same code path the harness does.
Facts
The following are measured facts drawn from the GovForge repository and the public sync record covering the April 11, 2026 incident, verified on May 4, 2026. They should be read within the scope of the GovForge project.
- The protected-branch guard at
.claude/hooks/pre-tool-use.jsis 320 lines of JavaScript with no external npm dependencies — only Node built-ins (node:child_process,node:fs). It is registered underPreToolUse:Bashin.claude/settings.json. - The regression suite at
tests/test_pre_tool_use_hook.pyis 354 lines containing 22def test_functions that pytest expands to 32 collected cases via parametrization (the figure quoted as "32 tests" in the first article in this series). It runs the hook as a node subprocess against a temporary git repository. Two of those functions assert the empty-stub failure mode is caught by CI: hook file non-empty andPreToolUse:Bashregistered. - The April 11, 2026 incident — automated
/implementsubagent pushed commit3f3b7f9tomainat 19:56 Pacific — was closed by commitb404fbeat 20:45 Pacific, 49 minutes later. Two follow-on commits hardened the guard:6b11a35added--all/--mirrorbroad-push detection, andcffdc57added the regression suite. A later commit,038b0e8, extended the commit-producing-subcommand set to includegit pull. - The guard's surface includes six categories of bypass: explicit refspecs targeting protected branches, implicit pushes while
HEADis on a protected branch, broad-mode--all/--mirrorflags, commit-producing subcommands (merge,rebase,cherry-pick,revert,am,pull), nested-shell wrappers (bash -c,sh -c,zsh -c, plus a defensive--command=match) with depth-limited recursive evaluation up to depth 4, and chained commands split across&&,||,;, newline, and single-pipe (|) separators. - Among the six active projects in the development directory carrying the document foundation, GovForge and
sdlc_appboth wire aPreToolUse:Bashprotected-branch guard. The GovForge implementation is the audited exemplar — 320 lines paired with the 354-line regression suite and the GF-PLAN-015 audit document. Thesdlc_appimplementation is a separately-engineered 278-line variant of the same pattern. ADWS Pro, AetheriaForge, and DriftSentinel keep hook stubs in.claude/hooks/but do not register them insettings.json.spec-driven-docs-systemwires hooks of a different shape — Python documentation pre/post-write validators againstWrite|Editmatchers, not a Bash protected-branch guard. - The
sdlc_appproject'sdoc_pre_write.pyis 277 lines. Thespec-driven-docs-systemproject ships a four-file Python hook suite (doc_pre_write.py,doc_post_write.py,doc_post_review.py,hook_utils.py) totaling 751 lines, scoped to documentation files via a path-aware matcher.
Interpretation
The following are engineering judgments drawn from operating the hook layer on these projects. They should be read as claims about the author's experience, not universal prescriptions.
The hook is the highest-leverage single layer in the stack because it is the only place where a written rule becomes structurally impossible to violate inside the harness. Every other layer either explains the rule (documents) or detects the violation after the fact (CI). The hook prevents the violation from landing in the first place. That asymmetry is the entire reason the layer exists, and it is the reason teams that have only the document foundation see the same class of incident repeat across sessions even after the rule has been clarified for the third time.
The empty-stub state is the canonical failure mode and it is caught by exactly two assertions. The first is that the hook file is non-empty and contains the literal process.exit(2) somewhere. The second is that settings.json still has the registration. Both fit in twenty lines of test code. Both run in milliseconds. Adding them to a project's existing test suite is the cheapest single change that retires the original GovForge incident's enabling conditions. If a team is going to write only one piece of code in this whole layer, those two tests are the right one.
Nested-shell handling is the bypass most teams underestimate. It is easy to write a guard that catches git push origin main and miss that bash -c "git push origin main" is the same operation under a different surface. An agent does not have to be adversarial to produce that form — Makefiles, shell scripts, and toolchains shell out routinely, and once the agent reaches for one of those, the inner command is invisible to a token-level inspector. Recursive evaluation with a small depth limit is the right tool. It is also the part of the guard that grows the code most, because the inline payload extraction has to handle multiple quoting forms and the recursion has to re-enter the same evaluator without infinite loops.
The hook explains itself. A surprisingly large fraction of the guard's load-bearing behavior is in the stderr message, not in the exit code. The exit code stops the action. The message tells the agent why and what to do next. A well-written message — name the policy file, name the sub-command, name the reason, name the fix — turns a refusal into a self-correcting cycle. A terse message turns it into a confused retry loop. The work of writing the message is small; the leverage is high.
The hook is project-shaped, not generic. The spec-driven-docs-system project does not have a protected-branch guard because its failure mode is different — the realistic risk is malformed documentation, not destructive git operations, and the hook surface is a Write|Edit matcher running Python validators that check for forbidden patterns, missing structure, and consistency rules. The sdlc_app project carries both shapes: a 278-line PreToolUse:Bash protected-branch guard for the same reason GovForge does, plus the documentation-oriented Write|Edit validators for its own docs surface. The architectural pattern is the same across all three projects. The policy is different in each one. A team copying the GovForge hook into a project with a different risk surface has done the cargo-cult version of this work; the right move is to enumerate the project's own failure modes and write the guard against those.
Practical Implications for Teams Considering the Pattern
If your team has the document foundation and no hooks, write the protected-branch guard first. It is the highest-stakes destructive operation in any agentic coding workflow, and it is the one most likely to be performed by a confident subagent under speed. Start with the simplest correct version: parse the Bash command, check for git commit or git push, look up the current branch, exit 2 if HEAD is on a protected branch and the operation is one of the two. Register it in .claude/settings.json. Add the two-assertion regression test for the empty-stub state. That version is one evening of work and closes the dominant failure mode.
After the basic guard is in place and tested, harden it iteratively. The order I would recommend, based on the GovForge sequence: refspec parsing for HEAD:main and +main forms; broad-mode flag detection for --all and --mirror; commit-producing subcommand expansion to cover merge, rebase, cherry-pick, revert, am, pull; nested-shell recursion for bash -c, sh -c, zsh -c. Each addition is a self-contained commit with its own test. Each addition closes a category of bypass that is unlikely on day one and certain by day thirty.
If your team has hooks but treats them as set-and-forget, write the regression suite. The empty-stub failure mode is silent — the file exists, the configuration looks right, no error is ever raised, and the hook stops working. The only thing that catches it is a test that runs the hook end-to-end and asserts a known-blocked payload actually exits 2. Run that test in CI. Treat a failing hook test as a CI-blocking event with the same severity as a failing unit test. The guard is part of the production surface; it deserves the same treatment.
If you are starting a new project, ship the hook on the first commit. The cost of writing a working protected-branch guard is the same on day one as on day thirty. The cost of not having one is asymmetric: the longer the project runs without it, the more likely a subagent operating at speed produces a destructive operation that the rest of the stack is not positioned to catch. The GovForge incident happened because the hook scaffold was copied from a sibling project but never wired with real behavior. A working hook from day one would have prevented it. An empty stub from day one was worse than no file at all, because it produced the appearance of governance without the enforcement.
The runtime-enforcement layer is where agentic coding stops being a productivity experiment and starts being a system a regulated business can deploy with confidence. The hook is small. The policy is small. The leverage is the largest single asymmetry in the whole stack. The teams that are most ready to deploy agentic coding into production environments are the teams that have understood and crossed this line.
Get the templates
The protected-branch hook described in this article — together with its registration in .claude/settings.json and the regression test suite that catches the empty-stub failure mode — is available as part of the agentic governance starter kit at etherealogic.ai/agentic-governance-stack-templates. The hook is on the page in copy-paste-ready form alongside the document-foundation files from the first article in this series.
References
- Anthropic Claude Code documentation — Claude Hooks specification, including the PreToolUse / PostToolUse contract and the exit-code-2 block protocol.
- AGENTS.md open standard — agentsmd/agents.md, governed by the Linux Foundation's Agentic AI Foundation.
- GovForge — public repository implementing the guard and regression suite referenced in this article. The
.claude/hooks/pre-tool-use.jsfile,.claude/hooks/README.md,tests/test_pre_tool_use_hook.py,specs/GF-PLAN-015_Hook_Guard_Audit.md, and the incident sync record atreport/2026-04-12T03-41-21Z-notion-sync-record.mdare the authoritative artifacts. The latter preserves the operator-reported governance note for commit3f3b7f9. - First article in this series — CLAUDE.md Is Not Enough: The Governance Stack for Agentic Development.
This is the second article in the EthereaLogic series on the agentic governance stack. The next article covers the external-validation layer in the same depth, including the Codacy, Codecov, and Snyk configurations used in the four production projects, the SHA-pinning practice that closes the mutable-tag supply-chain class, and the rule that the agent's self-report is never authoritative when CI disagrees.


Top comments (0)