Pillar 2: Guardrails
What keeps them safe.
Guardrails apply to Claude Code and Codex. Both harnesses can run powerful tools. Without boundaries, they can push to production branches, bump versions without a release workflow, leak credentials into tool calls, loop infinitely on broken commands, or quietly bloat their own instruction files. Guardrails close all of these gaps — automatically, at the hook layer, before any damage reaches your codebase.
Your fleet operates within boundaries you set.
10+ guardrails ship active by default. No configuration required. They fire silently when everything is fine, block loudly when something would go wrong, and surface informational banners for supply chain changes.
What’s new (Unreleased)
- Controls toggle (bypass mode) — operator phrase
disable controlsflips a session-wide bypass that lifts every CI-manifesto signal gate at once: branch creation, PR creation, and force-push to non-main branches. Spawned subagents inherit it. HARD FLOOR (push-to-main, commit-on-main, secrets-in-files) stays enforced. Restored byenable controlsor SessionEnd. See Guardrail 9. - Two-tier secrets — code-file writes still hard-block; inline Bash args (no file redirect) scan + log + note only. Lets agents pass a token to
curl -Hwithout breaking the workflow, while keeping durable storage locked. - Session-scoped PR signals — a PR into
devneeds no signal. Any other base needs one signal phrase from the operator, which unlocks PR creation for the full session, with a 3-per-session counter.main,masterandv1are protected: bypass mode does not open PRs or merges into them. - Subagent signal isolation — subagents cannot self-arm PR signals. Only top-level operator sessions can.
gh pr create --base mainrequired — PRs to branches other than main are now blocked. Dev work pushes directly, no PR needed.- Dependency banner — every
pip/npm/cargo/...install emits a visible banner. Never blocks — surfaces supply chain additions for operator audit. - Negation-aware signals — “don’t merge to main” no longer arms the PR gate. Matchers skip phrases preceded by
don't,not,never,shouldn't,won't,can't. - Shared
_strip.py— strip heredocs (any delimiter), echo/printf/curl/python-c/jq quoted arguments before guards apply regex. Eliminates false-positive blocks on documentation text in command payloads. - Guards fail-closed — unexpected exceptions in branch_guard / prod_lockdown now emit a stderr warning and the outer main exits 1. Silent hook failures are no longer possible.
Table of contents
- What’s new (Unreleased)
- The guardrail pipeline
- Guardrail 1: Secrets scanning
- Guardrail 2: Retry circuit breaker
- Guardrail 3: Branch guard
- Guardrail 4: Version guard
- Guardrail 5: CLAUDE.md sanity
- Guardrail 6: MCP surface area
- Guardrail 7: Output token limit
- Guardrail 8: File read deduplication
- Guardrail 9: Controls toggle (bypass mode)
- Guardrail 10: Serena edit guard
- Local test and mutation execution
- Process environment reads
- All guardrails at a glance
- Exit code semantics
- Hardening your setup
The guardrail pipeline
Every agent action passes through a layered defense before executing. The pipeline runs at three Claude Code hook events: UserPromptSubmit, PreToolUse, and PostToolUse.
flowchart TD
P([User Prompt]) --> UPS
subgraph UserPromptSubmit
UPS[Secrets Scanner\nprompt scan]
end
UPS --> TOOL([Tool about to execute])
subgraph PreToolUse["PreToolUse — security gate"]
S[Secrets Scanner\ntool input scan]
B[Branch Guard\ngit command filter]
V[Version Guard\nmanifest protection]
C[CLAUDE.md Sanity\nline limit check]
RC[Retry Circuit Breaker\nhard block]
FR[File Read Dedup\nredundant read block]
end
TOOL --> S --> B --> V --> C --> RC --> FR
S -->|secret found| BLOCK1([BLOCKED — exit 2])
B -->|merge/force-push/tag| BLOCK2([BLOCKED — exit 2])
V -->|version field edit| BLOCK3([BLOCKED — exit 2])
C -->|file exceeds line cap| BLOCK4([BLOCKED — exit 2])
RC -->|hard max hit| BLOCK5([BLOCKED — exit 2])
FR -->|unchanged file| BLOCK6([BLOCKED — exit 2])
FR -->|all clear| EXEC([Tool executes])
subgraph PostToolUse["PostToolUse — learning + enforcement"]
RCT[Retry Circuit Breaker\nfailure tracking]
BA[Bash Output Filter\nverbose truncation]
end
EXEC --> RCT
EXEC --> BA
RCT -->|threshold hit| WARN([Inject research instructions])
BA -->|verbose output| TRUNC([Truncated + re-emitted])
Guardrail 1: Secrets scanning
Hook: UserPromptSubmit (warn), PreToolUse (block) Default: On — AGENTIHOOKS_SECRETS_MODE=standard
The secrets scanner intercepts credentials before they can enter tool calls, log files, or git history. It runs twice per turn: once on the raw user prompt (warn only) and once on every tool’s input parameters (block on detection).
What it detects
| Pattern name | What it catches |
|---|---|
aws_access_key | AKIA/ASIA/AROA/AIPA prefixed AWS key IDs |
aws_secret_key | aws_secret_access_key = <value> assignments |
github_token | ghp_, ghs_, github_pat_ tokens |
private_key | PEM-encoded RSA, EC, OPENSSH, PGP private keys |
bearer_token | Authorization: Bearer <token> headers |
db_url_creds | postgres://user:pass@host, mysql://, mongodb:// URLs |
generic_secret | PASSWORD=, API_KEY=, SECRET= assignments with 8+ char literal values (quotes excluded); comparisons (==, ===) and code values such as settings.x, get_secret() or Optional[str] are not flagged |
In strict mode, three additional patterns activate:
| Pattern name | What it catches |
|---|---|
slack_token | xox[bpors]-... Slack OAuth tokens |
stripe_key | sk_live_, sk_test_, rk_live_, rk_test_ keys |
jwt_token | Three-part base64url JWTs (eyJ...eyJ...) |
Suppression
Lines with # nosecret are excluded from scanning. Use this for documentation, test fixtures, or known-safe patterns:
example_key = "AKIAIOSFODNN7EXAMPLE" # nosecret
Configuration
| Variable | Default | Options |
|---|---|---|
AGENTIHOOKS_SECRETS_MODE | standard | off, warn, standard, strict |
off— scanning disabled entirelywarn— detects and warns, never blocksstandard— detects standard 7 patterns, blocks onPreToolUsestrict— standard + Slack/Stripe/JWT, blocks onPreToolUse
Guardrail 2: Retry circuit breaker
Hook: PostToolUse (track + warn), PreToolUse (hard block) Default: On — RETRY_BREAKER_ENABLED=true, soft at 5, hard at 10
Claude Code agents will retry failing operations. Without a circuit breaker, a stuck agent can hammer the same broken command dozens of times — burning context, wasting tokens, and never escaping the loop.
The retry circuit breaker tracks consecutive failures per operation. When the same operation fails repeatedly, it escalates through two stages:
Stage 1 — Soft warning (default: 5 failures): Injects a banner into Claude’s context telling it to stop retrying and instead launch parallel error-researcher agents for web search. The agent still can proceed — but it now knows it should research first.
Stage 2 — Hard block (default: 10 failures): Raises a BlockAction via PreToolUse, preventing the tool from executing. The breaker is then half-open: the next call to that operation runs as a single trial. A success resets the counter; another failure blocks again.
How operations are fingerprinted
The breaker tracks per-operation, not per-tool. Operations are fingerprinted by their base command, with subcommand-level grouping for DevOps tools:
| Tool call | Operation key |
|---|---|
Bash: kubectl apply -f deploy.yaml | bash:kubectl:apply |
Bash: kubectl get pods | bash:kubectl:get |
Bash: terraform plan | bash:terraform:plan |
Bash: terraform apply | bash:terraform:apply |
Bash: docker build . | bash:docker:build |
Edit /repo/app.py | edit:/repo/app.py |
This means kubectl apply and kubectl get have independent counters — a stuck apply doesn’t block unrelated reads. File tools are keyed per path, and a call made inside a subagent is prefixed with its agent_id, so parallel subagents never share a counter.
Write, Edit, MultiEdit, NotebookEdit and Read echo file content in their success response, so for them (as for MCP tools) only an explicit error flag counts as a failure — never words like “error” or “not found” in the content.
Error text is also normalized (hex, timestamps, paths, numbers stripped) before fingerprinting, so slightly different error messages from the same root cause are treated as the same failure.
Configuration
| Variable | Default | Description |
|---|---|---|
RETRY_BREAKER_ENABLED | true | Master switch |
RETRY_BREAKER_MAX | 5 | Consecutive failures before soft warning |
RETRY_BREAKER_HARD_MAX | 10 | Consecutive failures before hard block |
RETRY_BREAKER_TTL | 3600 | Redis key TTL in seconds (1 hour) |
State is persisted in Redis per session and falls back to in-memory when Redis is unavailable.
Guardrail 3: Branch guard
Hook: PreToolUse (block, Bash commands only) Default: On — always active
Prevents destructive git operations that bypass the normal PR and release workflow. Fires on git commands before they execute.
Blocked operations
| Command pattern | Reason |
|---|---|
git merge ... main / git merge ... master | Direct merges bypass PR review |
git reset ... main / git reset ... master | Rewrites protected branch history |
git push --force / git push -f | Can destroy remote history |
git push --force-with-lease | Force push variant — same risk |
git tag | Tagging is a release operation; must go through CI |
The prod lockdown guard also refuses gh pr merge into a base branch with a merge queue: the installed gh predates merge queues and lands the pull request directly through the admin bypass, cancelling the running group build. Queue it with agentihooks swarm <slug> merge queue <url> (the GraphQL enqueuePullRequest mutation). Only bypass mode lifts this refusal. The guard reads gh pr merge only; a merge through gh api is not checked.
All blocked operations return exit code 2 with a human-readable explanation. CI alone promotes green dev into main and issues release tags. Agent pull requests target dev. An agent creates a pull request into main, master or v1 only with the operator’s PR phrase in its session; bypass mode does not replace the phrase, and merging into those branches stays with the operator or CI.
What is allowed
Normal pushes to worktree branches and read-only inspection are allowed. Direct pushes and commits to main or master remain blocked. Pull requests must name an explicit base; --base dev needs no PR signal.
The guard strips heredoc bodies and quoted commit messages before pattern matching to avoid false positives on message content that happens to mention “main” or “master”.
Guardrail 4: Version guard
Hook: PreToolUse (block, Edit and Write tools) Default: On — always active
Version fields in project manifests should be managed by the CI release workflow, not by an AI agent editing files directly. The version guard blocks any Edit or Write operation that would modify a version field in a known manifest file that already exists. Creating a new manifest may declare its first version.
Protected files
pyproject.toml, package.json, Cargo.toml, setup.cfg, setup.py, version.txt, VERSION
Detected patterns
version = "1.2.3" # pyproject.toml, Cargo.toml, setup.cfg
"version": "1.2.3" # package.json
If a Write or Edit targets one of these files and the new content contains a version field pattern, the operation is blocked with a message directing to the release workflow:
BLOCKED: Version field modification in pyproject.toml is not allowed.
Version bumping is handled by CI promotion and its release workflow.
Do not edit version fields manually.
Guardrail 5: CLAUDE.md sanity
Hook: PreToolUse (block, Edit and Write tools) Default: On — AGENTIHOOKS_CLAUDE_MD_SANITY_CHECK=true
CLAUDE.md is loaded by Claude Code on every turn. Every line in it costs tokens, every session. Agents writing unchecked content into CLAUDE.md files can quietly inflate the per-turn base cost while also degrading instruction quality through drift and dilution.
The sanity check intercepts any Write or Edit targeting a CLAUDE.md or CLAUDE.local.md file and simulates what the resulting file would look like. If the resulting line count exceeds the cap, the operation is blocked before the file is touched:
BLOCKED: Write to /home/user/.claude/CLAUDE.md would produce 347 lines,
exceeding the CLAUDE.md cap of 200 lines.
Trim the content to 200 lines or fewer before writing.
For Edit operations, the guard reads the current file from disk, applies the proposed change in memory, and counts the resulting lines — catching incremental bloat that would accumulate over many small edits.
Configuration
| Variable | Default | Description |
|---|---|---|
AGENTIHOOKS_CLAUDE_MD_SANITY_CHECK | true | Enable/disable the guardrail |
AGENTIHOOKS_CLAUDE_MD_MAXLINES | 200 | Maximum allowed lines in CLAUDE.md files |
Guardrail 6: MCP surface area
Hook: SessionStart (warn) Default: On — MCP_TOOL_WARN_THRESHOLD=40
Every MCP tool schema loaded into a session costs tokens — approximately 150 tokens per tool, every turn. With 9 MCP servers and 112 tools, that’s ~16,800 schema tokens injected into every single context turn before Claude writes a line of code.
At session start, agentihooks counts the total number of MCP tools across all configured servers. If the count exceeds MCP_TOOL_WARN_THRESHOLD (default 40), a warning banner fires:
MCP SURFACE AREA: 112 tools across 9 servers (~16,800 schema tokens/turn).
Consider disabling unused servers via /mcp to reduce per-turn overhead.
The companion CLI command provides the full breakdown:
agentihooks mcp report
MCP Surface Area Report
Total: 9 servers, ~112 tools, ~16,800 schema tokens
Server Source Tools ~Tokens
agentihooks user 32 4,800
github user 40 6,000
postgres user 15 2,250
...
Configuration
| Variable | Default | Description |
|---|---|---|
MCP_TOOL_WARN_THRESHOLD | 40 | Tool count threshold for session-start warning |
MCP_SCHEMA_AVG_TOKENS | 150 | Estimated tokens per tool schema |
Guardrail 7: Output token limit
Hook: SessionStart (inject awareness) Default: Passive — activates when CLAUDE_CODE_MAX_OUTPUT_TOKENS is set
When CLAUDE_CODE_MAX_OUTPUT_TOKENS is set in the environment, agentihooks injects an awareness message into the session context at startup so Claude knows the limit is in effect and can plan accordingly:
OUTPUT TOKEN LIMIT: This session is capped at 8192 output tokens per response.
Plan responses to stay within this limit.
Without this injection, Claude can unknowingly start generating a response that will be cut off mid-output by the token limit, resulting in truncated tool calls, incomplete edits, or garbled output. The awareness injection prevents the surprise.
Configuration
| Variable | Default | Description |
|---|---|---|
CLAUDE_CODE_MAX_OUTPUT_TOKENS | (unset) | Output token cap; awareness injected when set |
Guardrail 8: File read deduplication
Hook: PreToolUse (block redundant reads), PostToolUse (cache on read) Default: On — FILE_READ_CACHE_ENABLED=true
Tracks every file read during a session. If Claude tries to read the same file again and the file has not changed on disk since the last read, the operation is blocked:
BLOCKED: /path/to/file.py was already read this session and is unchanged on disk.
Use the content already in your context window.
This prevents a common and expensive pattern: Claude re-reading the same source file 3–5 times over the course of a long session, each read injecting the same content into the context window.
File identity is tracked by path + mtime. A file modified on disk since the last read passes through normally, and the cache entry is updated.
Configuration
| Variable | Default | Description |
|---|---|---|
FILE_READ_CACHE_ENABLED | true | Master switch |
FILE_READ_CACHE_BACKEND | redis | redis or memory |
FILE_READ_CACHE_TTL | 21600 | Redis key TTL (6 hours) |
Guardrail 9: Controls toggle (bypass mode)
Hook: UserPromptSubmit (signal detection), PreToolUse (consumed by branch_guard + prod_lockdown), SessionEnd (cleanup) Default: On — CONTROLS_BYPASS_ENABLED=true
A session-level escape hatch the operator activates by saying disable controls (or turn off controls, deactivate controls, kill controls). It lifts the workflow gates listed below. Protected promotion and PyPI authorization remain enforced. Restored by enable controls (or turn on, activate, restore) or by SessionEnd of the activating session.
What it unlocks
While bypass mode is ACTIVE:
- Branch creation (CI Manifesto §8) —
git checkout -b,git switch -c,git branch <name> - PR creation into non-protected bases —
gh pr createwith no per-session counter cap;--base devalready needs no signal - Hotfix-category prod ops (§5) —
:latest/:prod/:stableimage push, tag, build - Force push to non-main branches —
git push --force,-f,--force-with-lease gh pr mergepast the merge queue of a non-protected base
What stays HARD FLOOR
These remain blocked even with controls disabled — no toggle reaches them:
- Direct
git pushto main/master (also blocked at OS layer + GitHub ruleset) - Force push to main/master (caught by the main-push pattern that runs first)
git commitwhile HEAD is on main/master- Pull request creation into
main,masterorv1without the operator’s PR phrase, or past the per-session PR limit - Agent merges of pull requests into
main,masterorv1; merging stays with the operator or CI - Manual release workflow dispatch by agents; CI alone creates releases
- PyPI publication without the operator’s
release dancerequest - Explicit
--baserequirement ongh pr create git reseton main,git tag,git branch -D main- Secrets written to code files (Write/Edit/Bash-redirect into a file)
Subagent inheritance
The flag is global (single file at ~/.agentihooks/controls_flags/active.flag + Redis key controls_disabled:_global storing the owner session ID). Any subagent spawned during the bypass window inherits the unlock without per-session signaling. SessionEnd of the parent session clears it — child SessionEnds are no-ops, so a long-running parent doesn’t lose its bypass when a subagent ends.
Operator phrases
| Phrase | Effect |
|---|---|
disable controls, turn off controls, deactivate controls, kill controls | Activate bypass |
enable controls, turn on controls, activate controls, restore controls | Restore gates |
The phrases count only in a prompt the operator typed. A prompt the swarm sends (an opening or handoff prompt, a learned note, the culture, a wake nudge, an inbox delivery) never flips bypass or voice, even when it quotes a phrase.
A CONTROLS banner is injected on every transition and on each turn while bypass is active, so the operator (and the agent) can never silently forget the gates are down.
Configuration
| Variable | Default | Description |
|---|---|---|
CONTROLS_BYPASS_ENABLED | true | Master switch — set false to disable the feature entirely |
When the operator is likely to invoke it
- Spawning a fleet of subagents to open multiple PRs in parallel
- Hotfix sequences where the operator has already validated and just wants speed
- Force-push to a feature branch after a rebase
Agents must NOT volunteer the toggle (“want me to disable controls?”). The operator decides; the agent doesn’t suggest the escape hatch.
Release publication
release dance authorizes gh workflow run publish-pypi.yml --ref main only. The workflow publishes the latest stable release after verifying its tag tree matches main. Verify the successful workflow, the package index version and a clean install. CI promotion owns the preceding release, tag and images; release and hotfix signals never authorize agents to open a pull request into main; only the operator’s PR phrase does, and no signal authorizes an agent merge into it.
Guardrail 10: Serena edit guard
Hook: PreToolUse (Bash, Edit, MultiEdit and Write; Codex apply_patch reaches the hook as Edit) Default: On — AGENTIHOOKS_SERENA=true
Python in a linked worktree is edited through Serena’s symbol tools (Development Manifesto §7), so the guard refuses built-in and shell rewrites of an existing .py file there. A linked worktree is a checkout whose .git is a file; the primary checkout, whose .git is a folder, stays open for work done directly on the base branch. Serena exposes no file deletion, so deleting a whole Python file is a separate path: allowed inside the session’s own checkout, refused in any other checkout.
What it refuses
Edit,MultiEditandWriteon an existing.pyfile in a linked worktree.- A Codex
apply_patchthat updates, adds over, or moves an existing.pyfile there. Every patch target is checked, so a patch that deletes one file and updates another is refused for the update. - Shell rewrites of an existing
.pyfile in a linked worktree:sed -i,perl -i,cp/mv/installonto it,>/>>redirects,tee,dd of=, andopen(..., "w")orPath(...).write_text()in inline Python.
The refusal names the worktree and the fix:
BLOCKED: pkg/mod.py is Python in the worktree /home/me/dev/worktrees/agentihooks/engineer-abc123-0042, which is edited through Serena (Development Manifesto §7).
Call mcp__serena__activate_project with /home/me/dev/worktrees/agentihooks/engineer-abc123-0042, then edit with replace_symbol_body, insert_after_symbol / insert_before_symbol, rename_symbol or replace_content. A new .py file may be created with Write; a whole file in this worktree is deleted with git rm or rm.
What stays open
- Serena’s own tools, new
.pyfiles created withWrite, and every non-Python file. - Formatters (
ruff format,black),patchandgit apply. - Deleting a whole Python file in the session’s own checkout, through any of these:
| Path | Example |
|---|---|
git rm | git rm tests/proof_probe.py |
rm | rm tests/proof_probe.py, rm -r scratch_pkg |
git -C into the session’s worktree | git -C ~/dev/worktrees/antoncore/engineer-abc123-0042 rm pkg/old.py |
Codex apply_patch | a patch whose only action on that path is *** Delete File: tests/proof_probe.py |
A patch that deletes a file and then adds it again in the same patch is an edit and stays refused. A Write whose body only looks like a patch is a write and stays refused: patch deletions are read only from the apply_patch mapping.
The session’s own checkout
A deletion target counts as the session’s own when either holds:
- It lies in the checkout that contains the hook payload’s
cwd. - It lies in a worktree at
$WORKTREE_ROOT/<repo>/<name>(default~/dev/worktrees, compared after resolving symlinks) whose name is the session’s base name or that name with-2,-3and so on, or in a throwaway checkout at$WORKTREE_ROOT/<repo>/_tmp/<base>-tmp-<n>. The base name is the plain swarm agent name fromAGENTIHOOKS_AGENT_NAME(engineer@abc123-0042givesengineer-abc123-0042), elsesession-and the first eight hex characters ofCLAUDE_CODE_SESSION_ID. This covers a session whose task worktree is in another repository while the harness keepscwdat its launch checkout.
The second rule follows the names wt.sh new and wt.sh tmp give, so it reaches only worktrees this session created.
Deletions refused in another checkout
Deleting Python in any other checkout, another agent’s worktree or the primary checkout, is refused for every ordinary deletion form:
rmandgit rm, including aftersudo,env,nohup,command,execortime, as\rmor/bin/rm, and with globs such asrm pkg/*.py.git -C <dir> rm.find ... -delete,find ... -exec rmand-execdir rm.... | xargs rm, judged by the paths of the producing command.rm -r <dir>of a folder that holds any.pyfile.
BLOCKED: /home/me/dev/worktrees/agentihooks/engineer-abc123-0041/pkg/mod.py is Python in another checkout, /home/me/dev/worktrees/agentihooks/engineer-abc123-0041. A session deletes Python files only inside its own checkout.
Deleting non-Python files there, or a .py path that does not exist, is not checked.
Configuration
| Variable | Default | Description |
|---|---|---|
AGENTIHOOKS_SERENA | true | false turns the guard off for a session or a machine: every edit and deletion is let through and no Serena directive is injected |
WORKTREE_ROOT | ~/dev/worktrees | Folder whose <repo>/<name> worktrees named after the session count as its own |
AGENTIHOOKS_AGENT_NAME | (set by the swarm) | Swarm agent name that gives the session’s base name; without it the session id is used |
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
is Python in the worktree ..., which is edited through Serena on a deletion | The call is an edit, not a whole-file deletion: a patch with an update or an add on that path, or a Write | Delete with git rm / rm, or a patch whose only action on the path is Delete File |
is Python in another checkout for a file in your own task worktree in another repo | The worktree name does not match the session’s base name, or it sits outside WORKTREE_ROOT | Create the worktree with wt.sh new so the name comes from the session; check AGENTIHOOKS_AGENT_NAME and WORKTREE_ROOT |
is Python in another checkout in a session without a swarm agent name | Only the launch checkout and worktrees named session-<first eight of the session id> count as its own | Run the deletion from that checkout, or from a worktree wt.sh new created for the session |
Current limits
git -Cwith a quoted folder path that contains spaces is not parsed, so that deletion is not checked.- A guard failure other than a refusal prints
WARNING: serena_edit_guard check failed (...) — guard bypassedand lets the call through.
The edit guard was delivered by pull request #445. Whole-file deletion was added by #4513 (red Proofs runs 39128039655 and 39129889642, green 39128167690) and extended to the session’s worktrees in other repositories by #5242 (red 39413853717, green 39416415454). Tests: tests/context/test_serena_edit_guard.py.
Local test and mutation execution
The local test guard blocks shell commands that execute tests or mutation by default, while permitting narrow source and symbol inspections and prose that mentions runners. See Local test guard for examples, configuration, syntax limits and troubleshooting.
Process environment reads
The credential guard refuses every shell, Read, NotebookRead and Grep read of a process environment file under /proc, such as /proc/<pid>/environ, and names the leak free alternatives: a test of one variable or a names only listing. See Process environment reads for the refused forms, what still passes, configuration and troubleshooting.
All guardrails at a glance
| # | Guardrail | Hook event | Default | Block or warn | Config key |
|---|---|---|---|---|---|
| 1 | Secrets scanner | UserPromptSubmit / PreToolUse | On | Warn / Block | AGENTIHOOKS_SECRETS_MODE |
| 2 | Retry circuit breaker | PostToolUse / PreToolUse | On | Warn → Block | RETRY_BREAKER_ENABLED |
| 3 | Branch guard | PreToolUse | On | Block | (always on) |
| 4 | Version guard | PreToolUse | On | Block | (always on) |
| 5 | CLAUDE.md sanity | PreToolUse | On | Block | AGENTIHOOKS_CLAUDE_MD_SANITY_CHECK |
| 6 | MCP surface area | SessionStart | On | Warn | MCP_TOOL_WARN_THRESHOLD |
| 7 | Output token limit | SessionStart | Passive | Inject awareness | CLAUDE_CODE_MAX_OUTPUT_TOKENS |
| 8 | File read dedup | PreToolUse / PostToolUse | On | Block | FILE_READ_CACHE_ENABLED |
| 9 | Controls toggle (bypass) | UserPromptSubmit / PreToolUse / SessionEnd | On | Operator-toggled | CONTROLS_BYPASS_ENABLED |
| 10 | Serena edit guard | PreToolUse | On | Block | AGENTIHOOKS_SERENA |
Exit code semantics
Guardrails that block communicate via exit code 2. Claude Code cancels the tool action and displays the hook’s stderr output as a warning to the agent.
| Exit code | Meaning |
|---|---|
0 | Allow — tool proceeds |
2 | Block — tool cancelled, stderr shown as warning |
This is the same mechanism Claude Code uses for all hook blocks — guardrails are first-class citizens of the hook event pipeline.
Hardening your setup
The defaults cover the most common failure modes. For teams or higher-stakes environments, consider these adjustments:
# ~/.agentihooks/.env
# Stricter secrets scanning (adds Slack, Stripe, JWT patterns)
AGENTIHOOKS_SECRETS_MODE=strict
# Tighter retry tolerance (warn sooner, hard block sooner)
RETRY_BREAKER_MAX=3
RETRY_BREAKER_HARD_MAX=7
# Tighter CLAUDE.md size cap
AGENTIHOOKS_CLAUDE_MD_MAXLINES=150
# Warn earlier on MCP surface area
MCP_TOOL_WARN_THRESHOLD=25
# Set output token ceiling
CLAUDE_CODE_MAX_OUTPUT_TOKENS=8192
To verify all guardrails are active after installation:
agentihooks status
The status output lists each guardrail with its current state (enabled/disabled), mode, and thresholds. Inside a live session, /agentihooks shows the same panel alongside real-time session metrics.