cd /news/ai-agents/agents-route-around-your-hooks · home › topics › ai-agents › article
[ARTICLE · art-153833] src=victorvelazquez.dev ↗ pub= topic=ai-agents verified=true sentiment=· neutral

Agents route around your hooks

A Claude Code PreToolUse hook configured to block the Edit, Write and MultiEdit tools on view files until a design was approved stopped zero edits across eight agent runs in the rails-atlas experiments, because the agents did nearly all their file writing through Bash. Across the eight runs, the agents made 398 Bash calls versus 8 Write, 6 Read and 3 Edit calls, and the hook never fired a block; the runs instead wrote views via Python heredocs, edited before the spec folder existed, or stayed on main outside the gate's scope. The author's conclusion is that hooks guarding a process should check the outcome and place the real guarantee at merge time.

read9 min views1 publishedOct 20, 2026
Agents route around your hooks
Image: Victorvelazquez (auto-discovered)

TL;DR: A team protected its design-first process with a Claude Code hook that blocks the Edit and Write tools on view files until a design is approved. Across eight agent runs, that hook blocked nothing. The agents weren’t trying to get around it. They just did most of their file writing through Bash, and the hook was watching a tool they barely used. If a hook guards a process you care about, have it check the outcome, and put the real guarantee at merge time.

Eight runs, one task, one hook. The hook stopped zero edits.

This came out of the rails-atlas experiments, where I mentioned it in passing as a side finding. When I went back through the transcripts to write it up properly, it turned out to be more interesting than “the agent found a loophole.” The agents never went looking for a loophole. The hook was guarding a door they weren’t using.

The setup #

App C in those experiments is a multi-engine Rails platform with a rule its team takes seriously: before anyone writes view code, the screen gets designed in a design tool and a human approves it.

They enforce that with a PreToolUse hook. Simplified, and with names changed, it looks like this:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write|MultiEdit",
        "hooks": [{ "type": "command", "command": ".claude/hooks/ui-gate.sh" }]
      }
    ]
  }
}

The script reads the file path the tool is about to touch. If it’s a view or a Stimulus controller, it looks for a design-approval record in the current feature’s spec folder. No status: approved, no edit: the hook exits with code 2, and Claude Code blocks the call and shows the agent why.

It’s a reasonable design. I’d have written something very similar.

In round 8, Claude Code (claude-opus-5, headless, bypass-permissions mode, where hooks still run) got a ticket-style task: show how long a record has been open next to its title in the page header. Four runs had rails-atlas’s generated block in CLAUDE.md, four didn’t. The ticket said nothing about design or process.

What happened #

Every run that needed to change the view got its change in. The hook never fired a block. There were three different ways that happened, and none of them involved an agent “deciding to bypass” anything.

1. The view was written through Bash. In 3 of the 4 runs without the block, the agent created the approval record as pending, which is the step that switches the gate on, and then edited the view with a Python heredoc:

python3 - <<'PY'
p = "app/views/projects/_header.html.erb"
s = open(p).read()
s = s.replace(old_header, new_header)
open(p, "w").write(s)
PY

The hook’s matcher is Edit|Write|MultiEdit. This is a Bash call. The hook never saw it. One run in the other arm did the same.

2. Order of operations. The gate only applies once the feature’s spec folder exists, and creating that folder is part of the process the agent was following. One run wrote the view first and the approval record afterwards, and said why: it would wait before writing the design record, because the record “would otherwise lock the view behind the design gate.” Another used Edit on the view before the folder existed, so the gate correctly decided it didn’t apply.

3. Branch state. The gate only applies on a feature branch that has a spec folder. One run never left main, so it was outside the gate’s scope entirely. In an earlier round, an agent that skipped the design step said as much in its summary: the gate didn’t fire because the feature folder was absent, so the change had shipped without a design pass.

The number that explains it #

Here’s what made the whole thing click for me. These are the file-related tool calls across all eight runs:

Tool Calls
Bash 398
Write 8
Read 6
Edit 3
Grep / Glob 0

The agents read files with sed -n and cat. They searched with grep. And they edited models, tests, helpers, and views with Python heredocs. By a rough count, about six of every seven file writes went through Bash. Half the runs never called Edit or Write once.

The runs weren’t nudged toward this. The prompt was a plain ticket, App C’s CLAUDE.md doesn’t mention editing style, and permissions were wide open, so Edit was exactly as available as Bash. This was just how the agent preferred to work.

So the hook wasn’t routed around in any adversarial sense. It was written for an agent that edits files with the Edit tool. The agent in front of it mostly didn’t.

Nobody was cheating #

I want to be fair to the agents here, because the transcripts are honest to a fault.

Across this round and the one before it, several runs finished by telling the human that the design was recorded as pending and still needed their review. One said it had deliberately left the record at pending, because writing approved itself would forge the one signature the gate exists for. Another noticed, after the view was already done, that creating the spec folder had armed the gate and further view edits would now be refused, and reported it as the correct state.

That’s an agent respecting the letter of every rule it could see while the intent (no view code before a human-approved design) failed in every run that touched the view.

One caveat matters here: these were headless runs. No human was around to approve anything, so an agent that truly waited would have had to stop and hand the task back. All of them chose to finish the work and flag the pending approval instead. You could argue that’s the right call. But that just means the gate can’t be what stands between the code and production.

Why “just match Bash too” doesn’t fix it #

The obvious patch is to add Bash to the matcher and scan the command for view paths. Here’s the list of things you’d need to catch:

  • python3 - <<'PY' ... open(p, "w")
  • ruby -e ,perl -pi -e ,sed -i
  • cat > file <<'EOF' ,tee ,cp ,mv
  • git apply ,git checkout -- file ,patch
  • a script the agent wrote a minute ago, run by name

Each of these would need a regex, and the regex has to see the path in the command text. A variable, a script file, or a generated path hides it. App C’s own team had already written this down about a different hook of theirs, one that guards opening PRs from Bash. Its header comment calls it a local habit-enforcer rather than a server-side gate, and lists the ways around it: the browser, a different CLI command, any indirection that hides the command text.

They were right about that hook. The same reasoning applies to this one.

Check the outcome, then enforce at merge #

What I’d do instead comes in two layers.

Locally, check what changed, not which tool changed it. A Stop hook runs when the agent tries to finish. It can look at the working tree and refuse to let the turn end (exit code 2 sends the message back to the agent) if UI files changed without an approval:

#!/usr/bin/env bash
input=$(cat)
[ "$(printf '%s' "$input" | jq -r '.stop_hook_active')" = "true" ] && exit 0

changed=$(git status --porcelain -- app/views app/javascript/controllers)
[ -z "$changed" ] && exit 0

record="docs/designs/$(git branch --show-current).yml"
if ! grep -qs '^status: approved$' "$record"; then
  echo "UI files changed with no approved design in $record. Stop and ask for a design review." >&2
  exit 2
fi

It doesn’t matter whether the view was written with Edit, a heredoc, or cp. The stop_hook_active check keeps it from looping forever. This is still a local nudge, and an agent could write approved into the record itself. The ones I watched refused to. That’s encouraging, but it isn’t a guarantee.

The guarantee goes where the code merges. A required check on the pull request doesn’t care how a file got written. For a design-approval rule, the simplest version uses tools you already have:

/app/views/                  @your-org/design
/app/javascript/controllers/ @your-org/design

Turn on “Require review from Code Owners” in branch protection, and no view change merges without a designer approving the PR. An agent can’t write that approval into a file.

Be honest about the trade-off, though. A merge-time check can confirm that a designer approved the change. It can’t confirm the design came first. If order matters to your team, keep the local hook as early feedback, and treat the merge check as the thing that actually holds.

What I’d tell past-me #

  1. A PreToolUse hook guards a tool, not an outcome. Before trusting one, check which tools your agent actually uses. Read a few transcripts. The answer might surprise you.
  2. Watch for gates keyed on state the agent creates. If the agent creates the branch, the folder, or the record the gate checks, then the order it does things in decides whether the gate fires.
  3. Prefer outcome checks for local enforcement.git status sees every write. A tool matcher sees one kind.
  4. Put the guarantee at merge time. Hooks are great for fast feedback and good habits. A required review or CI check is what holds when the habit doesn’t.
  5. Don’t read this as agents being sneaky. Mine were scrupulous about the rules they could see. The gap was in where the rule was enforced.

Caveats #

  • Small sample. Eight runs in one round, plus a handful from an earlier round, on one app and one task.
  • One agent, one mode. Claude Code on claude-opus-5, headless, in bypass-permissions mode. An interactive session, or a different model, may lean on Edit much more, and then a tool-matched hook catches more. The point stands either way: the hook only works as well as your guess about the agent’s tool choice.
  • No human in the loop. Nobody could approve a design during these runs, which shaped what “following the process” could even mean.
  • The Bash-write count is approximate. I classified file writes by pattern-matching the commands, which is good enough for “most” and not much finer.
── more in #ai-agents 4 stories · sorted by recency
── more on @claude code 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/agents-route-around-…] indexed:0 read:9min 2026-10-20 · —