Guard Hook¶
Bootstrap deploys scripts/guard.py to ~/.claude/hooks/guard.py and wires it as a PreToolUse hook in ~/.claude/settings.json, and for Codex in ~/.codex/hooks.json. It runs before every tool call. AGENTS.md ยง "Git Safety and Mechanical Gates" holds the gate table and the escape hatches. The table says what each gate matches and whether it denies, asks, or advises. This page holds the reasoning behind each gate and the measurements that settled its shape.
Deny Where a Reroute Exists, Ask Where None Does¶
The gates fall into two kinds. A gate whose trigger has an obvious rewrite denies, and the deny message carries a Suggested rewrite: line. An unattended agent (/implement-review auto, a headless claude -p, any loop nobody is watching) can then lift the reroute in one model turn instead of inferring it. The compound cd gate is the plain case: cd <path> && git status becomes git -C <path> status, and the message says so. A gate whose trigger has no agent-side reroute asks, because human approval is the contract: git push, git commit, gh pr create, npm publish, rm -rf. An ask stalls an unattended loop, which is the point for those operations and the reason the other gates do not use it. The 2026-08 noise audit that shipped in v0.7.0 set this criterion after measuring how often each gate fired in real sessions.
One Classifier for the Four Ask Rows¶
The four ask rows are one classifier that runs for the Bash and PowerShell tools (a legacy payload without a tool name counts as Bash). It keys on the exact leading token of each sub-command after splitting on ;, &&, ||, and |, never on a substring scan, so echo "rm -rf" and Write-Output "Remove-Item -Recurse" pass. It strips transparent prefix runners (sudo, doas, env, command, nohup, setsid, an inline VAR=VALUE) and sees through the built-in wrappers that carry a command as an argument: ssh, bash/sh/zsh -c, docker exec and docker run, pwsh/powershell -Command, cmd /c and /k, timeout, xargs. Nesting is followed to MAX_WRAPPER_DEPTH and asked about beyond it.
python -c, the low-frequency prefixes nice, ionice, stdbuf, and time, and any private wrapper (a personal job runner, say) are opaque by design. Their argument semantics are not inferable from the command text, and scanning arbitrary arguments for substrings would bring back the false-positive alarm fatigue the classifier exists to avoid. The user-level allow-list pairs Bash(*) with PowerShell(*), so the native permission layer allows by default and this hook is the sole risk arbiter on both shells. No environment variable turns any of the four rows into pass-through. The advertised escape set lives in guard.py as _ESCAPE_HATCH_ENV_NAMES. A literal-scan test in tests/test_guard.py fails when a hook reads an env var that is not registered there, so a future gate cannot grow an unadvertised bypass.
Why the Style Advisory Reports and Does Not Block¶
The banned-word gate denies because every hit has a one-word substitution, which lets an agent reroute in a single turn. Mechanical agent-style rules have no such reroute. Through 0.7.0, RULE-12 fires on any sentence over thirty words, which is a mechanical fix while an agent drafts and a judgement call while a person types. From 0.8.0 it reads the whole document, and every finding there is a judgement call. A gate that denied on either would be switched off within a day. The advisory therefore reports through the hook's JSON response and leaves the permission flow alone. Findings are capped at five with a count of the remainder, because a wall of them is one the reader learns to skip. The summary line says the findings mark departures from typical human prose and that one that reads well can stay. A comparison of human-written machine-learning papers with agent-assisted drafts found that obeying every default literally produced a new uniformity. Human authors break any single default now and then, and that variety is part of what reads as human. The advisory runs only when the banned-word gate did not deny, which gives a blocked write one message rather than two, and it shares AGENT_STYLE_HOOK rather than adding an env var. A missing or broken agent_style package degrades silently, since the hook runs in every repository and most have no reason to carry the package.
The rule set follows the installed agent-style release. Releases before 0.6.0 carry six mechanical rules (RULE-05, 06, 12, B, D, I), and the hook requires all six. From 0.6.0 the package adds RULE-03, which flags confidence intervals written into prose and precise figures stacked across consecutive sentences of an abstract or an introduction. The hook then requires all seven. From 0.7.0 the hook requires eight rules. RULE-A flags a LaTeX table whose cell holds more than 15 words or two sentences. RULE-03 adds checks for stacked body figures, numbers at four or more decimal places, and captions that carry figures or run long. These additions apply only to LaTeX and skip appendices and supplements. The body-figure and decimal-place checks also skip setup sections such as Methods. An Edit inside the appendix of a main.tex carries no \appendix marker. These checks therefore read it as body text.
From 0.8.0 the hook requires nine rules and runs _rule_12_profile in place of the per-sentence _rule_12. The profile still reports as RULE-12 and words each finding as a departure from human-written papers. It makes three checks, and the two that describe the whole document come first in its output. The share check reports a share of sentences over thirty words above 45% or below 6%, outside the range of about 10% to 35% that the human-written papers measured. It reads a complete LaTeX document only: one with \begin{document} and \end{document}, a class that is not a slide deck or a letter, no \input, \include, or \subfile of a body file left unexpanded, and at least 40 sentences. A section file, a fragment, a main file that pulls its sections in, and every Markdown or text file are never read as a profile, because nothing in them shows that the file is whole. The , so check reports six or more clause joins at 1.5 or more per 1,000 words, three times the human maximum, in a text of at least 1,000 words. It reads any LaTeX source that is not a slide deck or a letter, a section file included, and never Markdown or text: notes and design documents carry the join by design, and a heading cannot tell them from a paper. The third check reports sentences of more than 80 words as one finding per document. It names the length of a lone long sentence, and the count and the line of the first when there are several. Footnote bodies, acknowledgments, and a leading front-matter block do not count as prose. Each finding of the first two checks begins with "Across the document, ", a marker the audit reads (see below).
RULE-08 reports the same caveat framing stated three or more times outside a limitations section or an appendix, and a bold run-in lead such as "Limitations." counts as that section. Its finding begins with the same marker. It runs third, after RULE-03 and RULE-A. The cap therefore shows it before the baseline rules. Full thresholds and the human baseline behind them live in the style-review skill's rule-detectors.md in the agent-style repository.
The hook reads the release from agent_style.__version__ and compares it numerically, which makes 0.10.0 newer than 0.6.0. Missing or unreadable versions count as older releases. Keying on the release number keeps that promise checkable. Probing for the function would let a 0.6.0 package that lost it silently narrow the advisory to six rules while this page promised seven. The same all-or-nothing rule covers the call itself. A detector that raises disables the advisory, and the findings it prints always come from the complete set.
RULE-03, RULE-A, RULE-08, and the RULE-12 profile receive the path of the file being written, because they use it to recognize LaTeX sources, appendices, limitations sections, and abstract and introduction files. The per-sentence RULE-12 of earlier releases and every other rule receive the text alone. Labels are spelled out in the hook, since deriving them from the function names would give RULE-12_PROFILE and RULE-08_REPEATS. Fenced code, inline code, and LaTeX verbatim are blanked before the detectors run, and each blank keeps the newlines of the span it replaced. The line numbers a detector reports are then the lines of the file. An Edit or MultiEdit hands the detectors only the new text, and the lines they report count from its first line.
Before Round 1, the pre-review audit in /vet runs the hook's release-gated set through style-audit.py. This also checks edits from Codex or Agy that no hook saw. When the set holds the profile, the audit reports RULE-12 through it and omits the command-line tool's per-sentence hits. A long sentence is then not listed beside a reading of the whole document that treats most long sentences as ordinary. The audit scopes findings to the changed lines, with one exception. A finding about the document as a whole stays for any changed file, because a revision elsewhere in the file can move the numbers it reports while leaving its anchor line alone. Two kinds qualify: a finding that begins with the document-level marker, and any RULE-12 finding that the profile produced. The long-sentence finding gathers every sentence over 80 words under the first of them, and a new one further down changes its count without touching that line. A finding about one place, such as a banned word, keeps the line scoping.
Two details were settled by measurement rather than by reading the docs. The findings travel in hookSpecificOutput.additionalContext and in systemMessage, because probing Claude Code 2.1.229 showed those reach the model and the user respectively, while stderr on an exit-0 hook reached neither. RULE-G, which asks for title-case headings, is left out. Over the 155 markdown files in the maintainer's source repo it produced 1018 of 2561 findings, and it flagged the sentence-case headings that corpus writes on purpose. It would have filled the cap with nothing to act on. The style-review skill still runs it, where a person asked for the full audit.
The agent-io Marker¶
Both writing guards skip a path the caller marked as agent I/O. They pick their scope by file extension, and extension does not separate prose an agent is writing from text an agent is carrying. A scratch directory holds a dispatch prompt beside a draft proposal section. Measured across 34 local session transcripts, 23 percent of prose-extension writes landed in a scratch directory, and the most frequent names there belonged to the review loop itself (ir-prompt-r1.txt, review-prompt-r1.txt, ir-round1.txt). Findings on that text cannot be acted on. A dispatch prompt is an instruction to another agent, so rewriting it changes what was asked; captured round output is another agent's words, so rewriting it falsifies the record. The writer therefore declares the location by putting the file under a directory named agent-io.
The two guards trust that marker to different depths. Anywhere on disk is enough for the advisory, matched case-insensitively and on either separator, because a wrong exemption there costs one message. Only a path under a temp root that encloses no repository satisfies the deny gate, resolved through symlinks first, because CI checks repositories out below temp directories routinely. A marker trusted anywhere would be a one-token bypass: an agent could write repo/agent-io/proposal.md and skip the banned-word check on real prose. Carried text belongs in the session scratch directory in any case, which is where implement-review and prun are documented to write it. An unmarked path is still scanned, so a forgotten marker costs noise instead of silence.
Prose Written Through a Shell¶
The banned-word gate used to read only the Write, Edit, and MultiEdit tools, and a shell command writes the same file just as well. On 2026-10-21 a heredoc that put two banned words into a Markdown file passed without a message, while the same text through Write was denied. Claude Code's auto mode tells the agent to prefer the shell for small file changes, so agents were already taking this route.
The gate now also reads a Bash or PowerShell command that writes a prose file. Three shapes count as a write: an output redirection (> or >>), a tee, and an in-place sed -i or perl -i. PowerShell adds Set-Content, Add-Content, Out-File, and Tee-Object, reading the destination from -Path, -LiteralPath, -FilePath, or the first positional argument and skipping a call with -WhatIf. The gate sees through the same wrappers as the ask classifier, so a bash -c payload counts. Each target path then meets the scope test a Write meets, the agent-io exemption under a temp root included. A variable in the path is expanded first, from an assignment in the same command (P=/tmp/...; cat > "$P/agent-io/prompt.txt", or $out = "notes.md" in PowerShell) or from the environment, including ${NAME:-default} and PowerShell's $env:NAME. Command substitution leaves the path unknown.
The gate asks two questions of a command, where it writes and what it writes, and answers them from different readings. Where it writes comes from a small lexer that knows quotes, comments, and heredoc boundaries, so a redirection spelled inside a quoted search pattern, a comment, or a heredoc body is not a write. An earlier draft matched redirections over the raw text. Review reproduced a deny for rg 'delve > notes.md', for a code fixture whose heredoc held that command, and for a review prompt under agent-io that quoted it. What it writes is the whole command text once a real target is a prose file, heredoc body included, with the code-span stripping a Write to that extension gets and no attempt to tell content from command. This gate therefore reads the heredoc body that the nested-git init gate declines: there the body is data that looks like commands, and here it is the data to check.
Reading the whole command has a cost, since a command which removes a banned word, such as sed -i 's/delve/examine/' notes.md, is denied as well. The Edit tool makes the same change with new text that carries no banned word, so the reroute always exists, and the deny message names it. A search that saves its hits to a prose file is the other case, and the message points it at an agent-io directory in the scratchpad or at a non-prose extension. Commands that write no file never meet the gate, so grep -rn delve docs/*.md passes.
Two shapes stay opaque. A script that opens the file itself (python - <<EOF) would make the gate guess what a program does, which the ask classifier also declines for python -c. Nor does the gate guess at a path whose variable neither the command nor the environment supplies. Either one leaves that command as it behaved before the gate read shells. Only the three file tools reach the style advisory, since a shell command's text is not the document its detectors measure.
Why a Nested git init Is Denied¶
The second repository is invisible from the first. The directory that holds it is normally an ignored one, so git status in the parent never mentions it again. IDEs are where it surfaces. PyCharm and VS Code both scan for nested .git directories and register each as a VCS root. Every file staged in one then appears in the changes view beside real work, separated only by a branch label. Measured on one machine, four review packets left in a proposal repo over a single day held 85 staged-and-never-committed files across four roots. The commit panel offered all of them under one checkbox. A mis-click there commits build artifacts into a repository shared with a co-author.
It is a deny because the reroute exists and an unattended agent can take it in one turn. Artifacts an agent generates belong in the session scratch directory, which is already where the skills that carry text between agents write. The gate answers the question git would answer. It checks the executable before reading a subcommand, follows a global -C, resolves .. and symlinks, and skips the values of options that take one. A deliberate inner repository, a submodule most often, sets AGENT_NESTED_GIT_INIT_HOOK=off for that one call. No shipped skill creates such a directory, which is why this is a gate rather than a correction to one (anywhere-agents#56).
Shapes the Gate Declines to Judge¶
The nested-git init gate declines commands whose shape it cannot account for, and that is deliberate. A command carrying a heredoc is not judged at all. Its body is data that reads exactly like commands, and deciding where the body ends is where two separate defects lived. A single command carrying a redirection is not judged either, because its operands are not arguments, and reading one as the target denied a directory that never existed. A backslash or a backtick immediately before a quote declines the whole command. An escape moves where a quoted region ends, and the splitter and the tokenizer downstream both assume it does not. Recognizing it in one place would leave two others wrong. At a possible comment start, the gate declines as well when the boundary character before the # is itself preceded by either escape character. An escaped operator does not end a word. An escaped hash is not that shape, because an escape is not a boundary character. So \# stays the literal text a shell reads it as, and the command after the separator is still judged. Both escape checks ignore shell-specific escape rules and escape parity by design, so a doubled escape declines like a single one. A PowerShell block comment declines for the heredoc's reason, since its body spans lines.
Five review rounds produced the rule: every widening of the parser closed one gap and opened a false positive somewhere adjacent. The two errors are not symmetric. A false positive blocks work an agent is entitled to do and no rewrite repairs it, while a declined command behaves exactly as it did before this gate existed. A git init reached through any of those shapes is not how the nested repositories that prompted the gate were created.
The Banner Gate¶
The banner gate is the mechanical half of the Session Start Check. Its first arm denies every tool call except reads and the acknowledgement write while a session event is pending with no acknowledgement file. Its second arm passes a stale acknowledgement through with a [banner-gate] advisory, so a re-fire never blocks work (anywhere-agents#7). The event file, the acknowledgement file, the report the agent reads, and the acceptance rule are described in Session banner. Precomputing the banner did not retire the gate: precomputation does not guarantee visible emission, and removing enforcement while changing production would confound the measurement. The gate is revisited after the 2026-09 rollout with an eligible-session denominator.
Under Codex¶
Codex 0.153.3 and later run PreToolUse hooks with the payload and the deny shape Claude Code uses, so bootstrap registers the same file there. scripts/merge_codex_hooks.py adds one group to ~/.codex/hooks.json that matches Bash|apply_patch and runs guard.py --agent codex from ~/.claude/hooks/. Other hooks in that file stay as they were, and a group where the user put the guard beside handlers of their own is left alone with a warning. A machine without a Codex home is skipped, and the Windows entry point does not register the hook yet.
Codex runs a hook only after the user trusts it at its startup review, and it skips an untrusted hook without a message. Bootstrap cannot grant that trust, so it prints a reminder on each run until ~/.codex/config.toml records trust for the group's current definition, checked by computing the hash Codex records. A hook the user disabled there gets no reminder. The trust hash covers the hook definition and leaves out the script, so one approval survives every later guard.py update. A /vet reviewer dispatched with --ignore-user-config loads no trust records and runs without the hook, as a dispatched Claude reviewer runs without the user-level one.
The Codex path differs from the Claude Code path in three places. File edits arrive as apply_patch with the raw patch, so the writing-style gate reads the + lines of each file the patch adds, updates, or moves, and judges the destination with the Write scope test. Code spans are found on the file as the patch would leave it, read from disk and patched in memory, so a line inside a fence stays exempt wherever the fence opens. When the file cannot be read or a hunk does not match it, the added lines are judged alone. Codex rejects an ask as invalid output and runs the call, and a deny in its place would hold even after the user approves in the conversation; the Codex app has no ! command to run it by hand. The four ask rows therefore pass under Codex, and the user's approval in the conversation governs those commands, as the Git Safety rule in AGENTS.md requires. Claude Code keeps the banner gate and the compound-cd gate, because Codex never writes the banner acknowledgement and the compound-cd prompt is a Claude Code behaviour. A Codex hook that crashes, times out, or prints malformed output lets the call run, which is why the Codex path prints one deny document or nothing.
Escape Hatches¶
Each hatch is an env var in the env block of ~/.claude/settings.json; under Codex, set it in the environment Codex starts in, since Codex does not read that file. The disable values are off, 0, disabled, false, and no.
| Env var | Disables |
|---|---|
AGENT_STYLE_HOOK |
The writing-style gate and its advisory |
AGENT_COMPOUND_CD_HOOK |
The compound-cd gate |
AGENT_NESTED_GIT_INIT_HOOK |
The nested-git init gate |
AGENT_CONFIG_GATES |
The legacy blanket: writing style and the banner, nothing else |
Set the narrowest one for a legitimate write that quotes a banned word as an example (a style guide, a CHANGELOG entry), and remove it after the write. None of them reaches the four ask rows.
Fan-Out Stays a Written Rule¶
No gate enforces the Task Routing rule against unrequested subagents and Workflow runs, by design. Whether the user asked for parallel work is a judgement about the conversation, often made several turns before the launch. A hook that judged it wrong would block a fan-out the user requested. The rule exists because one unrequested fan-out on 2026-09-13 used a large share of a Claude five-hour window. If an agent fans out unasked again, the fix is sharper prose in the rule, not a hook.