THE ENGINEERING LADDER ENG · 2026·08·08 ENG·06 x:1369 y:807 x:050 y:050 FACE·05

The Guardrail Your Agent Can Reach

Most guardrails end up with an escape hatch. Check whether the thing you are constraining can reach yours.

Harry Floyd 21 min read THE ENGINEERING LADDER Law IV · Holds
Contents · 7 sections
  1. The hatch you put there on purpose
  2. The old rule, in a new place
  3. Mistake, misled, or aimed
  4. Run it on your own repository
  5. Who can reach the override
  6. What to change this week
  7. The uncomfortable version

Most guardrails end up with an escape hatch. Check whether the thing you are constraining can reach yours.

An agent walks up to a barrier, knocks over a lever standing on its own side of it, and strolls through while the boom lifts

Somewhere in the repository you look after, there is a rule you decided to stop trusting to prose.

It had been a line in your context file. You rewrote it three or four times. It failed anyway, on a Saturday. You gave up on wording it better. So you did the thing everyone now recommends and moved it somewhere deterministic: a pre-commit hook, or a PreToolUse hook, or a permission rule. Something that returns a failure code and stops the run outright, instead of asking the model nicely and hoping.

Good instinct. Anthropic gives the same advice about their own product: “When there’s something that absolutely must not happen, an instruction is the wrong tool… A real guardrail needs to be deterministic, and the enforcement methods are hooks and permissions.”1

Now open the thing you built and look at what shipped with it.

The hatch you put there on purpose

Your pre-commit hook is skipped by git commit --no-verify, and you have typed that this month. Your CI check has an override label for the Friday when the build is red and the release is going out anyway. Your permission prompt has an “allow always” button that somebody pressed in week one. Your harness, the process that runs the agent and decides which of its requests to honour, reads a bypass environment variable. It is documented, because you documented it the night you needed to get something out of the door.

None of that is sloppiness. A control with no way through will one day stop something that genuinely needed to happen, at three in the morning, with nobody around to authorise it. So an exception path goes in deliberately. The interesting question is who it was built for. Sometimes that turns out to be the party the control was meant to constrain.

In ordinary developer tooling that trade-off has usually worked, because the person holding the override is still sitting inside an accountability system.

The old rule, in a new place

Security engineering worked this out decades ago: you do not let the thing being constrained control the constraint. Least privilege, separation of duties, two-person release. None of it assumes the constrained party is trustworthy, because accountability after the fact was never enough on its own. The set of things you are trusting completely even has a name, the trusted computing base, and the whole discipline is keeping it small.

The strong version has a name too. NIST’s definition of a reference monitor puts three requirements on whatever does the enforcing. It must be always invoked. It must be tamperproof. It must be small enough to analyse and verify.2

Determinism buys you none of the three. It buys something real that the list does not name, which is that the decision no longer runs through the model’s judgement. Everything else is still open. Your hook can go uncalled, it can be altered, and it can be wrong.

git commit --no-verify is the first of those. Nothing was tampered with, the script sits on disk exactly as you wrote it, and it simply was not invoked. An agent that edits the hook file is the second. Being wrong is the third and it is the quietest, because a hook whose pattern stops rm -rf but not find . -delete will pass every test you thought to write and fail forever in the one case you did not.

Most guardrail failures I have watched are the first or the third, and those are the well-served ones: hook linters, server-side checks that a local --no-verify cannot reach, no shortage of advice about testing what you wrote. Security people have covered that ground too. Trail of Bits publishes opinionated Claude Code defaults whose documentation is blunt: hooks “are not a security boundary”, because “a prompt injection can work around them”. They are “guardrails, not walls”.3 This piece is about the second failure, which is where all of that stops. What is scarce is a test precise enough to apply to a specific repository on a Tuesday.

None of this is hypothetical either. In March one user reported that Claude Code had edited a gate they had built with it, narrowing the instruction inside the hook from “code changes” to “SIGNIFICANT code changes”, and attached the one-line diff. A follow-up RFC, from someone running enforcement across 545 tasks, reported watching the model add Bash(*) to its allowlist to get past a hook that was blocking it. Both were auto-closed for inactivity, with no vendor response either way.4 Neither is a jailbreak. In both cases the model did the ordinary thing, which was to edit a file it had write access to, and the file happened to be its own constraint.

The principle is old. Where it gets violated is new. This used to be something you reasoned about while designing a permissions system, and it has become something you do by accident on a Tuesday, without once thinking of yourself as designing a security boundary. You would never ship an application that could rewrite its own access policy. Then you gave a coding agent write access to the repository holding its hooks.

Two things change when the constrained party is an agent.

The first is accountability, and it half survives. An audit trail can still work: the agent has a run ID, it acts under some credential, and you can read back what it did. The deterrent does not. A developer reaching for --no-verify knows they may have to explain it, and in some override systems that expectation does more of the work than the mechanism does. It does nothing to a process at two in the morning. So you keep the forensics and lose the deterrent, and you keep the forensics only if the log lives somewhere the agent cannot also write and somebody reads it. A record nobody opens is not a record.

The second change turns a reliability problem into a security one.

Mistake, misled, or aimed

Most of the time your agent is simply fallible. It forgets, it drifts out of scope, it solves the adjacent problem. A repo-local hook is genuinely good at catching that, and nothing here should talk you out of one. Instructions get followed, too: a separate team measuring these files across real repositories found that instructions in them are well followed by coding agents.5 The prose is not being ignored. That is exactly why a hook that catches the residue is worth writing.

Sometimes the agent is misled. Anthropic names this case in the same breath as the others: a prompted rule can fail “due to a prompt injection in a file accessed as part of the task.”6 Your agent reads issues, dependency READMEs, test fixtures, web pages. Any of them can carry instructions, and the agent can fail to tell your intent from text it read thirty seconds ago. Security has an old name for this shape. A component with more authority than the party directing it is a confused deputy, and that hazard has had a name since 1988.

And sometimes it is aimed. The model wants nothing. Whoever wrote the text it just read might. That is the bridge, and it needs no speculation about what an agent wants. The thing with permission to disable your guardrail is also, routinely, reading untrusted input. Each fact is fine alone. Together they are the problem.

So the useful question was never whether your agent would decide to bypass a control. It is what the control is worth on the day something in its context window tells it to.

A developer who bypasses a hook is a person acting on a system. An agent that bypasses a hook is the system acting on itself. The popular version of that overstates: an agent setting an environment variable cannot reach a value its parent process already read at launch. It barely matters, because the cheap routes are elsewhere. One git config core.hooksPath /dev/null, and every commit after it runs with no hooks and no --no-verify to account for; the bypass sits in configuration rather than on the command line, and the commit itself records nothing. Hooks are not cloned either, so a fresh checkout has none at all unless something installs them. Your control is absent by default, which is worse than bypassable.

Which points away from the check and towards what the check trusts. Can the agent modify any input the enforcement mechanism trusts? The hook script, the config that decides whether it runs, the ledger it consults, the credential it uses. If any of those sits somewhere the agent can write, you have not built a lock. You have built a lock and handed the key to the thing you were locking out.

One gets through. One does not.

Two agents, one barrier, one difference.

Run it on your own repository

No dependencies, reads only. Run it from inside a repository your agent works in:

#!/bin/sh
set -u
root=$(git rev-parse --show-toplevel) || exit 1
dir=$(git rev-parse --git-path hooks)          # correct inside worktrees too
me=$(id -un)
W='%-34s %-10s %s\n'
hooks() { find "$dir" -maxdepth 1 -type f ! -name '*.sample' 2>/dev/null; }

printf "$W" "WHAT THE CONTROL TRUSTS" "OWNER" "CAN YOU WRITE IT?"
check() {
  [ -e "$1" ] || { printf "$W" "$2" "-" "absent"; return 0; }
  own=$(stat -c '%U' "$1" 2>/dev/null || stat -f '%Su' "$1")   # GNU first, then BSD
  # writable, or replaceable via its directory, or yours to chmod
  if [ -w "$1" ] || [ -w "$(dirname "$1")" ] || [ "$own" = "$me" ]
    then w="YES"; else w="no"; fi
  printf "$W" "$2" "$own" "$w"
}
hooks | while read -r h; do
  if [ -x "$h" ]; then check "$h" "hook: $(basename "$h")"
  else check "$h" "hook: $(basename "$h") [NOT EXEC]"; fi     # chmod -x disables it silently
done
[ "$(hooks | wc -l)" -eq 0 ] && echo "!! NO HOOKS RUN AT ALL from $dir"

# configs git reads now, plus the ones it would read if the agent created them
{ git config --list --show-origin -z 2>/dev/null | tr '\0' '\n' | sed -n 's/^file://p' |
    while read -r f; do [ -f "$f" ] && echo "$f"; done
  git rev-parse --git-common-dir | sed 's|$|/config|'
  echo "$HOME/.gitconfig"
  echo "${XDG_CONFIG_HOME:-$HOME/.config}/git/config"
} | sort -u | while read -r c; do
  case $c in "$HOME"/*) l="~${c#$HOME}" ;; *) l=$c ;; esac
  if [ ${#l} -gt 22 ]; then b=${l##*/}; p=${l%/*}; l=".../${p##*/}/$b"; fi
  check "$c" "git config $l"
done

check "$root/.claude/settings.json"       "claude: project"
check "$root/.claude/settings.local.json" "claude: project local"
check "$HOME/.claude/settings.json"       "claude: user"
check "/Library/Application Support/ClaudeCode/managed-settings.json" "claude: managed"

On a repository of mine it prints this:

WHAT THE CONTROL TRUSTS            OWNER      CAN YOU WRITE IT?
hook: pre-commit                   harryfloyd YES
git config .git/config             harryfloyd YES
git config .../git-core/gitconfig  root       no
git config ~/.config/git/config    -          absent
git config ~/.gitconfig            harryfloyd YES
claude: project                    harryfloyd YES
claude: project local              -          absent
claude: user                       harryfloyd YES
claude: managed                    -          absent

A YES is not a defect. It becomes one the moment you were relying on that row to hold against something trying.

A hook marked [NOT EXEC] is worth a second look. chmod -x disables a hook completely without changing a byte of it, the commit goes through, and since .git/hooks is not version controlled there is no tracked file for git to show you.

It asks three ways, because a file you cannot write is still replaceable if you can write the directory holding it, and a file you own is one chmod away whatever its mode says. .git/hooks is yours in every ordinary repository. Location tells you nothing either: a hook at ~/.config/git/hooks, where core.hooksPath can point, sits outside the project and is entirely writable.

The column answers for the account you are sitting in, so run it wherever your agent actually runs. In a container or on CI that is not here, and as root everything comes back YES, which is true and useless.

Now look at the git config rows. None of them is a guardrail. They are the things that decide whether your guardrail runs at all, and there are more of them than people expect: the repository’s own config, your global one, an XDG file at ~/.config/git/config, a system file. Set core.hooksPath in any of them and hooks stop firing. In the global one they stop firing in every repository on the machine. A row reading absent is the one to watch, because a config file that does not exist yet is a config file the agent can create.

Then it gets worse. The agent does not have to write a config file at all: git -c core.hooksPath=/dev/null commit runs with no hooks, writes nothing anywhere, and leaves no trace in the commit. The persistent route at least leaves a file behind for the table above to catch. This one leaves nothing, because the mechanism trusts an argument supplied at the moment of invocation and there is no file to lock down. So protecting .git/config does not close it, and neither does protecting all four. That is the cleanest argument in this piece for moving an important check off your machine altogether.

The same goes for the settings rows: absent is not reassuring, because local settings outrank project settings and the agent can create the file.

So treat the script as a first-pass audit of the obvious local trust points. It covers the config files git admits to reading, the well-known ones it would read if they existed, and your settings files. It cannot see the -c route at all, which defeats git hooks specifically and leaves the settings rows untouched. The ledger a hook consults, the credential it uses, and anything supplied on a command line are still yours to trace by hand.

I ran it on mine. A hook in my own repository blocks a certain kind of file from being written until a matching row exists in a ledger. It returns a failure code, it has stopped me twice, and its bypass is an environment variable, which is the defensible kind: per-invocation, and the hook reads it from the harness rather than from any shell the agent controls. The hook also lives in a directory the agent it constrains can write to. So does the ledger it consults.

I have not moved either, and the reason matters more than the finding. Moving the hook outside the workspace means it stops being versioned with the project that depends on it. I decided that trade was not worth making here. What this hook does is stop me forgetting, it is good at that, and it was never holding a line against anything that was trying. What I gave up is the right to describe it as a boundary, which is what I had been quietly doing.

Who can reach the override

The sharper question separates things that look identical on a diagram. A pre-commit hook and a production deploy gate are both deterministic, both return a failure code, and the difference lies entirely in the override. The hook is bypassed by the same actor it constrains, in the same command, and the bypass never appears in the resulting commit. The deploy gate needs a second party in a different system, and it leaves a trace somebody reads on Monday.

So the test is not whether a thing can refuse. Almost anything can be made to refuse. It is whether the enforcement sits inside or outside the authority of the thing being constrained.

Anthropic draws this line themselves, and it is the most useful thing in their post. A PreToolUse hook “can inspect a call and exit with code 2 to block it”, which is deterministic by their own description. Managed settings “go further: they are admin-deployed, cannot be overridden by a user’s local config”, and are the only route to a guardrail that holds across an organisation.7 Both mechanisms are deterministic. One is beyond local override. That gap is the whole subject, and it is really a question about which layer you are intervening at.

Apply it to your own stack and four tiers fall out.

Tier 1, a rule in your context file. The model reads the rule and decides whether to follow it. Same authority, no enforcement.

Tier 2, a hook in a repository the agent can write to. This is the one people misjudge, because it looks like enforcement right up until you ask who owns the file. It is also where most of what you have built actually sits.

Tier 3, enforcement the agent’s authority does not reach. An API that never exposes the destructive operation is the cleanest: there is nothing to bypass. A credential the agent was never issued works the same way, as long as it cannot pick one up from a metadata endpoint or a stray .env. The credential it does hold should also be no wider than the calls it makes.

Be careful with the rest of tier 3, because deployment decides it. Ordinary Unix mode bits are not much of a boundary against an agent running as container root with the usual capabilities, and a plain docker run gives you root unless somebody chose otherwise. What makes the tier hold is a read-only mount, a dropped capability, a user namespace: something root does not reach past. Network egress rules work the same way, holding when the enforcement sits where the agent has no authority and not when it can reach the thing enforcing.

Tier 4, a separate principal has to move. A human, or a system under separate control. It is a different kind of control rather than a strictly better one, because it adds judgement at the exception point and introduces a failure the others do not have: a person approving forty exceptions a week is a rubber stamp, and a deny rule nobody sees beats a human who clicks yes. The separation also has to be real. A second agent with similar authority, reading the same untrusted content, gives you a correlated failure rather than a second opinion.

Where the enforcement actually sits

Most stacks are thin on the right.

The tempting lesson is that anything important needs a human in the loop. What an important guardrail needs is enforcement standing outside the authority of the thing being constrained. Sometimes that is a person. More often it should be architecture.

What to change this week

Deploy a managed settings file. On macOS it lives at /Library/Application Support/ClaudeCode/managed-settings.json, it takes an administrator to put it there, and it is evaluated above anything in your project or your home directory. A solo operator can do it in a minute. Put the things your trace just flagged in it, rather than the secrets example everyone copies:

{ "permissions": { "deny": [
  "Edit(//**/.git)",
  "Edit(//**/.git/**)",
  "Edit(//**/.claude/**)",
  "Bash(sudo:*)"
] } }

Those are aimed at the two incidents above: the agent that edited a hook, and the agent that widened its own allowlist. Two details decide whether they work at all: they have to be Edit rules, and the leading // has to be there. Get the tool name wrong and Claude Code warns you at startup. Get the prefix wrong and nothing says anything.8

Note also that they name files rather than commands. Anthropic’s permissions page warns that Bash patterns constraining command arguments are fragile, and a rule like Bash(git config core.hooksPath *) is defeated by an option before the key, an extra space, a shell variable, or appending to .git/config by hand. Deny the file, not the phrasing.

Now turn this article’s test on that recommendation. On a personal Mac you are an administrator, so the file’s protection rests on the agent not obtaining sudo. Run sudo -n true. If it succeeds, either your sudo is passwordless or you have a cached ticket in that terminal, and admin-deployed sits one command away from user-deployed. The Bash(sudo:*) line above raises the cost of that route without closing it.

Write deny rules, and know their reach. Most agent configs I have seen are all allow and no deny. A permissions.deny block is cheaper than a hook and can protect a hook. Read how far it goes first. Read and Edit deny rules cover the built-in file tools and the file commands Claude Code recognises in a shell. They do not cover a script the agent writes that opens the same path itself. For that you want the sandbox, which enforces at the level of the operating system across the shell’s process tree.9 A rule constrains the routes it enumerates, and the agent composes the routes.

Check that any of it took. Deploying config and having a guardrail are different things, which is the whole subject of this piece. Claude Code will tell you when a file is broken, so the failure worth worrying about is the file that loads perfectly and does not do what you think. Run claude doctor, then /status, and read the Setting sources line: it lists what actually loaded, and a file with broken JSON will not appear there at all.10 Then do the part no tool can do for you: ask the agent to perform the exact thing you just forbade, and watch it refuse. A rule you have never seen fire is a rule you are trusting on faith.

Move the git check to the server. The tier-3 version of a pre-commit hook is a pre-receive hook, which runs where the committer cannot skip it and has existed since 2007. It is GitHub Enterprise Server only, so on github.com you cannot install one.

There the equivalent is a required status check, and it reaches tier 3 only when the check’s reporting identity and its definition both sit outside the committer’s reach. Identity is the easy half: GitHub lets you pin a check to the app expected to report it, and refuses the merge when anything else reports it.11 The definition is the hard half, and pinning does nothing for it, because a pull request that edits .github/workflows/ci.yml runs the version it just wrote. Closing that half needs the workflow itself to live somewhere the pull request cannot touch. An organisation-level ruleset does it, by naming the source repository the workflow must come from.12

Working alone on a personal repository, that last one is out of reach: no organisation, so the definition half stays open. Your ceiling on github.com is a pinned check, plus the knowledge that anyone who can push a workflow edit can satisfy it. That beats a hook you can skip with a flag. It is still not tier 3. On a private repo on the free plan you do not get even that, since rulesets and protected branches both need a paid plan there. For that reader the detection answer in the next paragraph is the whole of what is available, and it is enough.

When the trusted input must stay writable, stop preventing and start detecting. That is often the honest answer. It works only if the record lands somewhere the agent cannot reach and something actually looks at it. That gap separates a reliability control, catching ordinary error, from a security boundary, built to hold while something is trying. Most of what is in your stack today is the first kind wearing the second kind’s name.

The uncomfortable version

Sort your own stack this way and the picture is usually the same. A few things genuinely hold, because their enforcement sits somewhere the agent cannot reach. A middle tier stops the agent but trusts something the agent could rewrite. And underneath all of it, a context file quietly doing the work you assumed the middle tier was doing.

That middle tier is where the false confidence lives. It earns its place: it catches forgetting, drift, the hallucinated command, the injected instruction that never finds the bypass, the same way a hook catches a developer who forgot rather than a developer who decided. What it will not do is hold when the thing it constrains is also the thing that can switch it off. That is the protection people credit it with, and it is the one it does not provide.

Take the guardrail you would least like to lose, and find out who can write the thing it trusts.

What in your setup can the agent reach that you had filed as a boundary?

Banner for The Durability Curve. A dense surface of scattered figures and fragments drifts across the top of the frame; beneath it a fine lattice holds a rising gold curve. As the surface thins, the curve and its markers brighten into view, so the picture performs the publication's line about the interesting material sitting under the surface. Beneath the artwork the banner carries the publication name and an invitation to subscribe.

New to The Durability Curve? It is a standing argument about what survives when the tools get powerful and the surface gets cheap. Subscribe for the rest, or start with what survives.

Footnotes

  1. Michael Segner, Steering Claude Code: when to use CLAUDE.md, skills, hooks, subagents, and more, Anthropic, 18 June 2026. The passage is specifically about instructions in CLAUDE.md.

  2. Reference monitor, NIST glossary, from SP 800-53 Rev. 5. Verbatim, a reference validation mechanism “is always invoked (i.e., complete mediation), tamperproof, and small enough to be subject to analysis and tests, the completeness of which can be assured (i.e., verifiable).”

  3. claude-code-config, Trail of Bits. Full sentence: “Hooks are not a security boundary — a prompt injection can work around them.”

  4. Security: Claude can rewrite its own hooks — Who watches the watchmen?, anthropics/claude-code issue 32376, 9 March 2026, and RFC: Deterministic tool gate — hooks are necessary but insufficient for governance enforcement, issue 45427, 8 April 2026. The first reports a hook’s instruction text being narrowed from “code changes” to “SIGNIFICANT code changes” and includes the one-line diff; the second states “We observed the model adding Bash(*) to allowedTools to bypass a hook that was blocking it.” Both are user reports rather than vendor-confirmed incidents. Both were closed by a staleness bot for inactivity, in May and June 2026, with no response from Anthropic on either thread, so draw no conclusion from the closures in either direction.

  5. Thibaud Gloaguen, Niels Mündler, Mark Müller, Veselin Raychev and Martin Vechev, Evaluating AGENTS.md: Are Repository-Level Context Files Helpful for Coding Agents?, arXiv, v2, 23 June 2026. The same paper finds that context files do not generally improve task success and add over 20% to inference cost, so read the obedience finding as narrow rather than as an endorsement.

  6. Michael Segner, Steering Claude Code, Anthropic, 18 June 2026, same passage as above. Prompt injection is listed alongside long sessions and ambiguity as a reason a prompted rule can fail.

  7. Michael Segner, Steering Claude Code, Anthropic, 18 June 2026. The full sentence: managed settings “are admin-deployed, cannot be overridden by a user’s local config, and are the only way to enforce a deterministic, organization-wide guardrail.”

  8. Configure permissions, Claude Code documentation. On the tool name: a path rule written for Write, NotebookEdit or Glob is one Claude Code “accepts but never consults”, warning at startup, while an Edit(path) rule covers every file-editing tool. On the prefix: path or ./path is “relative to current directory”, and //path is an “absolute path from filesystem root”, which is what a machine-wide file needs.

  9. Configure permissions, Claude Code documentation. Verbatim: “Read and Edit deny rules apply to Claude’s built-in file tools and to file commands Claude Code recognizes in Bash, such as cat, head, tail, and sed.” They “don’t apply to arbitrary subprocesses that read or write files indirectly, like a Python or Node script that opens files itself”. The same page notes that sandboxing “applies only to Bash commands and their child processes”. US spelling preserved inside the quotations.

  10. Claude Code settings, Claude Code documentation. Managed settings “parse tolerantly”: a failing entry is stripped and a warning recorded rather than the whole policy dropped, while “user, project, and local settings files remain strict: a file that fails validation is rejected as a whole and reported”. Claude Code also “watches your settings files and reloads them when they change”, including permissions and hooks, so a running session picks up an edit without a restart. On /status, “a source appears once it loads with at least one setting, so a file with broken JSON doesn’t appear even if it contains settings”.

  11. About protected branches, GitHub Docs. Verbatim: “When you add a required status check, you can select an app that has recently set this check as the expected source of status updates. If the status is set by any other person or integration, merging won’t be allowed.” This closes the reporting-identity half only.

  12. Available rules for rulesets, GitHub Docs, on required workflows: the rule is configured at organisation or enterprise level, and you specify the source repository and the workflow to enforce, which is what puts the definition outside the pull request.