# Hooking the agent that built the hook

**Blog:** [vschroeder.blog](https://vschroeder.blog)  
**Author:** Victor Schroeder  
**Published:** 2026-06-08  
**Tags:** [ai](/tags/ai.md), [security](/tags/security.md), [bash](/tags/bash.md), [shell](/tags/shell.md)

> I got tired of Claude using `cd` despite being told a hundred times not to. So I asked Claude to build a hook that would block it. Then I asked Claude to break that hook. The recursive game taught me more about Bash, parsing, and red-teaming than any tutorial would.


[View as HTML](/posts/20260608-hooking-the-agent-that-built-the-hook/)

---

I have a small operational rule when coding: **never use `cd`**. When working on
a project _everything_ should be possible to run from the project root. This is
not something I want only for agents, I impose this rule even to myself. Since
decades.

This kind of obsessive rule brings a lot of benefits in code ergonomics. And it
proves its value in the agentic era. It gets really interesting when applied to
AI agents.

I do not want Claude wandering off into a sibling directory, doing work, and
then forgetting where it started. There are perfectly safe alternatives like
`git -C <path> …`, `make -C <path>`, `pytest --rootdir`, and so on. Almost every
tool I touch supports per-invocation working-directory flags. If they don't,
just write a script, ffs.

I have told this to Claude in `CLAUDE.md`. I have told it to write `MEMORY.md`.
I have told it _in the actual chat_, in many sessions, with increasing emphasis.
The rule survives roughly as long as it takes for the agent to forget. Then it
wanders off again and runs `cd /tmp` like nothing happened.

So I did what any reasonable person does after the same problem appears
ninety-six times: I asked Claude to build me a guardrail. And then, because the
situation begged for it, I asked Claude to **break the guardrail it just
built**. Multiple times.

This post is about that recursive afternoon.

## The contract

Claude Code supports **PreToolUse hooks**: little programs that run before a
tool is invoked, with stdin carrying the tool name and its input. The hook can
return exit code `2` to block the call, sending its stderr back to the model as
a system reminder. Perfect substrate for a guardrail.

What I wanted was deceptively small:

> Before any `Bash` invocation, parse the command. If it tries to run `cd`,
> reject it with a clear message.

How hard could that be?

## Round one: the obvious regex

The first take was a four-line Python script with a regex. The regex matched
`cd` at the start of the command, or after a shell separator like `;`, `&&`,
`||`, `|`, `&`, `(`, or a newline. Followed by a space or end-of-string. We also
had to make sure it did not catch `cdk` or `cdrom` or paths containing `cd`.

This worked for the textbook cases:

```shell
$ cd /tmp                # blocked
$ ls && cd /tmp          # blocked
$ (cd /tmp && pwd)       # blocked
$ mkdir foo              # allowed
```

Smart right? Boy, I couldn't be more wrong...

I asked Claude to try to break it. The response was disarming. Within seconds,
we had a list of bypasses:

```shell
$ 'cd' /tmp              # quoted command word, Bash still runs cd
$ "cd" /tmp              # same
$ \cd /tmp               # backslash escape, Bash strips it
$ $'cd' /tmp             # ANSI-C quoting
$ c"d" /tmp              # quote-splitting the word, Bash collapses to cd
$ c'd' /tmp              # same
$ c\d /tmp               # in-word escape
$ FOO=bar cd /tmp        # env-var-prefix, still a real cd
```

All of these executed `cd` in the real shell. None of them were caught by my
regex. Bash's quote-removal phase happily turned `c"d"` back into `cd` before
resolving the command name. My matcher only ever saw the literal string.

I patched the regex. I added optional quotes around `cd`. I added an optional
leading backslash. I added an env-var-prefix grammar.
[The regex grew teeth and a beard](https://stackoverflow.com/a/1732454).

Then Claude suggested:

```shell
$ x=cd; $x /tmp
```

And I knew the regex was a lost cause.

## Round two: shlex tokenization

The honest move was to stop pretending shell syntax could be regex'd. I rewrote
the matcher around Python's `shlex` in POSIX mode. `shlex.split` handles quoting
correctly:

```python
>>> shlex.split('c"d" /tmp')
['cd', '/tmp']

>>> shlex.split("c'd' /tmp")
['cd', '/tmp']

>>> shlex.split('c\\d /tmp')
['cd', '/tmp']
```

This handled the entire quote/escape splitting tier for free. With a few small
additions for ANSI-C quoting (`shlex` does not understand `$'…'`), backtick
rewriting (`cmd` becomes `$(cmd)`), and simple variable-assignment tracking
(`x=cd; $x` resolves `x`), we caught most of round one's evasions.

I asked Claude to try again. It introduced new creative bypasses:

```shell
$ eval cd /tmp           # eval runs its arg as code
$ command cd /tmp        # 'command' bypasses functions/aliases
$ pushd /tmp             # changes cwd, just like cd
$ source ~/.bashrc       # could contain cd
$ . ~/.bashrc            # same
$ bash -c "cd /tmp"      # opaque subshell payload
$ echo cd /tmp | bash    # pipe to shell
```

Some of these were easy: I added `eval`, `command`, `pushd`, `popd`, `chdir`,
`builtin`, `source`, and `.` to a `BLOCKED_KEYWORDS` set and the matcher handled
them uniformly. The same quote-splitting protection applied to all of them.

Others, the ones that **delegate to a fresh subshell**, were harder. We deferred
those to a later round, on the observation that they do not actually change the
_parent_ shell's working directory. They run `cd` in a child process that
immediately exits, so Claude Code's persisted `cwd` is unaffected. They only
violate the _spirit_ of the rule, not the letter.

## Round three: bashlex

The `shlex` matcher kept growing helpers: a quote-aware newline rewriter, a
backtick rewriter, a separator splitter, a custom segment walker. At some point
I asked the question that should have been asked from the start:

> Isn't there a Bash command that resolves the full invocation, including
> variables and quotes?

The honest answer is **no, not safely**. Bash conflates expansion with
execution: once you let it expand `$(…)`, you have already run code. The `-n`
flag parses but does not expand. The `-x` flag traces, but requires execution.
There is no "dry run mode" because, in Bash, expansion _is_ execution.

The right tool for static analysis is a **Bash parser**. There is one for
Python: [`bashlex`](https://pypi.org/project/bashlex/). It returns a real AST:
`CommandNode`, `PipelineNode`, `ListNode`, `CommandsubstitutionNode`,
`ParameterNode`, `AssignmentNode`. Pure parsing, no execution.

Switching to `bashlex` collapsed a hundred lines of regex and shlex plumbing
into one AST walk. Quote handling vanished as a problem. Command substitution
recursion became natural. Variable assignments were exposed as their own node
type.

Almost too good to be true, but there was one catch.

## The bug

Bashlex has an
[open issue from 2020](https://github.com/idank/bashlex/issues/54): it cannot
parse `&&` or `||` _inside_ `$(…)`. The grammar's correction hack only patches
`;`-terminated lists. So:

```python
>>> bashlex.parse("a && b")           # works
>>> bashlex.parse("$(a ; b)")         # works
>>> bashlex.parse("$(a && b)")        # ParsingError: unexpected ')'
>>> bashlex.parse("`a && b`")         # works (different code path)
```

Five years, no fix. The workaround that the issue thread suggests is the same
one I landed on: pre-process the command and rewrite unquoted `&&` and `||` to
`;`. We do not care about short-circuit semantics for our check. We care about
command-position structure, and that is preserved.

```bash
$(echo a && cd /tmp)     # bashlex gets confused

# rewritten as:
$(echo a ; cd /tmp)      # parses cleanly, walker finds cd
```

This is one of those parser bugs that does not get noticed in the original
project because the library was probably built for command auditing on
already-known inputs, not for adversarial use. The moment you start treating the
input as untrusted, every corner case becomes relevant.

## The red team

With the AST walker in place, I asked Claude one more time to enumerate
bypasses. This time the list was long. Bash is a surprisingly rich target
surface.

We probed forty-five different evasions. The full table:

| Trick                            | What it does                  | Initial state |
| -------------------------------- | ----------------------------- | ------------- |
| `cd /tmp`                        | the baseline                  | blocked       |
| `'cd' /tmp`                      | quoted command word           | blocked       |
| `c"d" /tmp`                      | quote-splitting               | blocked       |
| `c\d /tmp`                       | escape-splitting              | blocked       |
| `$'cd' /tmp`                     | ANSI-C quoting                | blocked       |
| `` `cd /tmp` ``                  | backtick substitution         | blocked       |
| `$(cd /tmp && pwd)`              | dollar-substitution with `&&` | blocked       |
| `FOO=bar cd /tmp`                | env-var prefix                | blocked       |
| `eval cd /tmp`                   | eval runs arg as code         | blocked       |
| `command cd /tmp`                | bypass functions/aliases      | blocked       |
| `pushd /tmp` / `popd`            | sibling builtins, same effect | blocked       |
| `chdir /tmp`                     | another sibling               | blocked       |
| `builtin cd /tmp`                | force-call the builtin        | blocked       |
| `source script` / `. script`     | run external code             | blocked       |
| `x=cd; $x /tmp`                  | variable indirection          | blocked       |
| `if true; then cd /tmp; fi`      | conditional                   | blocked       |
| `for i in 1; do cd /tmp; done`   | loop                          | blocked       |
| `{ cd /tmp; }`                   | command group                 | blocked       |
| `( ( cd /tmp ) )`                | nested subshell               | blocked       |
| `f() { cd /tmp; }; f`            | function definition           | blocked       |
| `${x:1:2}` (x=zcdz)              | substring expansion           | blocked       |
| `${x#z}` (x=zcd)                 | strip-prefix expansion        | blocked       |
| `${x%z}` (x=cdz)                 | strip-suffix expansion        | blocked       |
| `"${x,,}"` (x=CD)                | lowercase case-mod            | blocked       |
| `${x/x/c}` (x=xd)                | replace expansion             | blocked       |
| `${x:-cd}`                       | default-value expansion       | blocked       |
| `${!name}` (name=x, x=cd)        | indirect expansion            | blocked       |
| `${a}${b}` (a=c, b=d)            | parameter concatenation       | blocked       |
| `${x}d` (x=c)                    | parameter + literal           | blocked       |
| `c${x}` (x=d)                    | literal + parameter           | blocked       |
| `time cd /tmp`                   | pipeline-time prefix          | blocked       |
| `! cd /tmp`                      | logical-not prefix            | blocked       |
| `{cd,ls} /tmp`                   | brace expansion               | blocked       |
| `c{d,p} /tmp`                    | partial brace expansion       | blocked       |
| `./cd /tmp`, `/usr/bin/cd /tmp`  | pathed command word           | blocked       |
| `case x in *) cd /tmp ;; esac`   | case statement                | blocked       |
| `declare x=cd; $x /tmp`          | declare-style assignment      | blocked       |
| `export x=cd; $x /tmp`           | export-style assignment       | blocked       |
| `f() { "$@"; }; f cd /tmp`       | function dispatches `$@`      | blocked       |
| `f() { $1 /tmp; }; f cd`         | function dispatches `$1`      | **slips**     |
| `printf -v x cd; $x /tmp`        | printf writes to var          | **slips**     |
| `read x <<< cd; $x /tmp`         | read writes from herestring   | **slips**     |
| `trap 'cd /tmp' DEBUG; :`        | trap registers code           | **slips**     |
| `echo cd \| xargs -I {} {}`      | xargs runs constructed cmd    | **slips**     |
| `echo cd \| tee f.sh; bash f.sh` | write script, run it          | **slips**     |

That is forty-five tricks. Thirty-nine blocked. Six still escape. Each one of
the escapes deserves a paragraph.

## What still gets through

**`f() { $1 /tmp; }; f cd`** is the cleanest remaining evasion. The function
body sees `$1` as the command. Statically, there is no way to know what will be
passed at the call site. To catch this, we would need to track function
definitions, detect that their body executes a positional, and then check what
the call site passes. Not impossible, just nontrivial.

**`printf -v x cd; $x /tmp`** uses `printf`'s lesser-known `-v` flag to write
into a variable. `read x <<< cd; $x /tmp` does the same trick with a herestring.
Both are runtime assignments that our static check does not see.

**`trap 'cd /tmp' DEBUG`** registers a string as code that Bash will run before
every command. Effectively, `trap` is `eval` with a trigger. We would need to
recurse into the trap's first argument as code.

**`echo cd | xargs -I {} {}`** and **`echo cd | tee f.sh; bash f.sh`** are the
family of bypasses that build a payload and feed it to another shell or
command-runner. They are Group B in my notes: "indirection via a fresh shell".
Catching them means either blocking pipe-into-shell outright (too aggressive) or
recursively analyzing the LHS as code (basically impossible in general).

The rest of the bypasses changed _outer-shell state_, which is what we care
about. The Group B family changes _child-process_ state, which dies with the
child. Less dangerous in practice. Still violates the spirit of the rule.

## The meta-observation

Here is what struck me most about the afternoon.

I was not the one finding the bypasses. **Claude was**. Every iteration, I would
say _"find the next thing that breaks this,"_ and Claude would generate ten
more. Many of these were techniques I would not have thought of within an hour
of effort: parameter expansion with `${!name}` indirect references, the `time`
reserved word causing a parse error in `bashlex`, the difference between
`;`-separated and `&&`-separated lists inside `$(…)`.

This is the genuinely useful loop: I think of the rule, the agent implements it,
the agent attacks it, I review the attacks, I direct the next iteration. Six
rounds in, the hook is far more robust than anything I could have written in a
focused day by hand.

There is a deeper irony. The hook exists because the agent _kept ignoring the
rule_ stated in plain English. The "fix" was to make the rule into something the
harness enforces, mechanically, before the agent's tool call reaches the shell.
Memory and instructions are advisory. Hooks are **hard rules**. If you actually
want a behavior, you have to enforce it outside the agent's loop, not request it
inside.

In my daily work, this setup has already proved to be incredibly helpful. It is
not perfect, and as we saw above, a determined (or deeply confused) agent could
still find a way to slip through. There are no absolute guarantees in static
analysis. But in practice, it is remarkably handy. Because the blocking message
is printed to stderr, Claude Code feeds it right back into the model's context
as a system prompt. The agent sees its own mistake, gets a firm reminder of the
rules, and immediately reinforces the prohibition in its next turn.

In Star Trek terms, the model is the bridge crew and the hook is the ship's
safety interlocks. You want the crew to follow protocol. You also want the warp
core to refuse to engage if the inertial dampeners are off, because the crew, in
the heat of the moment, will forget. The interlocks are not an insult to the
crew. They are an acknowledgement that humans (and frontier LLMs for that
matter) operate on a different latency than the systems they control.

## What I would do differently

A few things stood out as I reflect on the process.

AST is complicated, but extremely powerful. I knew shell quoting was complex and
regexes had limits. I reached for the obvious tool anyway, because the obvious
tool felt cheap and the AST parser felt heavy. By the third round I had
reinvented half of `bashlex` in defensive helpers. Should have started there.

## Why the hell was all this needed?

You might be wondering why I had to go through all this trouble. Doesn't Claude
Code have built-in permissions to control what commands can run?

It does, but it gets the permission rules entirely backwards. There is currently
no way to say: "for the Bash tool, block everything except these explicitly
allowed commands."

In Claude Code, "allow" simply means "execute without user approval," and "deny"
means "this tool is forbidden." Because the "allow" layer is applied first and
the "deny" layer is applied afterward, you cannot compose a strict, explicit
allowlist for command execution. This violates the **least privilege
principle**, a fundamental security design concept that almost all modern agent
harnesses seem to get wrong. Deny rules must be evaluated first, and only then
should allow rules be checked.

Contrast this with **Pi**, another agent harness I use. In Pi, fixing this is
incredibly simple because the platform is designed to be fully customizable.
Instead of wrestling with a giant, dangerous, all-powerful bash tool, you can
completely deactivate the generic bash tool. Then, you can easily create
specific, highly-scoped tools for each job you intentionally want to allow: one
tool for Git, one for Make, one for NPM, and so on. You can even do this on a
per-project basis.

It is incredibly clean. In fact, you would be surprised I have been working in
Pi with the generic bash tool _completely deactivated_ for months now. And
honestly, it is soooo relieving not having to worry about an agent wandering off
in the background. But in Claude Code, we are stuck with the all-powerful _Bash_
tool, so we have to build our own safety interlocks.

## Code

The final, production-ready implementation of this guardrail is published on
GitHub at [schrodervictor/dot-claude][dot-claude].

Instead of a single raw script, it is structured as a proper Python project
named `guard-bash` managed by `uv`. The hook script itself (`guard_bash.py`) is
about 250 lines of clean Python, depending only on `bashlex`. Because of the
`uv` setup, running the hook is as simple as configuring Claude Code to run:

```shell
$ uv run --project hooks/guard-bash guard-bash
```

The execution is incredibly fast, running in under 150ms per Bash invocation
(mostly just Python and `bashlex` parser startup time).

The test suite in `test_guard_bash.py` is equally robust. It runs over 170 e2e
and unit test cases covering every single trick, escape, and bypass we
discovered during our adversarial sessions. Every bypass that is fully blocked
has a test case.

There is something quietly satisfying about a guardrail whose test suite is
longer than its implementation. It means the guardrail spent more time being
attacked than being written.

TDD (with AI assistance) for the win, baby!

[dot-claude]: https://github.com/schrodervictor/dot-claude

---

Previous: [Why Coding Agents Love Layered Baklava Code](/posts/20260517-why-coding-agents-love-layered-baklava-code.md)  
Next: [The $1.75T SpaceX IPO: Lessons from the Ultimate Anticlimax](/posts/20260612-the-spacex-ipo-anticlimax.md)
