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:

$ 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:

$ '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.

Then Claude suggested:

$ 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:

>>> 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:

$ 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. 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: it cannot parse && or || inside $(…). The grammar’s correction hack only patches ;-terminated lists. So:

>>> 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.

$(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:

TrickWhat it doesInitial state
cd /tmpthe baselineblocked
'cd' /tmpquoted command wordblocked
c"d" /tmpquote-splittingblocked
c\d /tmpescape-splittingblocked
$'cd' /tmpANSI-C quotingblocked
`cd /tmp`backtick substitutionblocked
$(cd /tmp && pwd)dollar-substitution with &&blocked
FOO=bar cd /tmpenv-var prefixblocked
eval cd /tmpeval runs arg as codeblocked
command cd /tmpbypass functions/aliasesblocked
pushd /tmp / popdsibling builtins, same effectblocked
chdir /tmpanother siblingblocked
builtin cd /tmpforce-call the builtinblocked
source script / . scriptrun external codeblocked
x=cd; $x /tmpvariable indirectionblocked
if true; then cd /tmp; ficonditionalblocked
for i in 1; do cd /tmp; doneloopblocked
{ cd /tmp; }command groupblocked
( ( cd /tmp ) )nested subshellblocked
f() { cd /tmp; }; ffunction definitionblocked
${x:1:2} (x=zcdz)substring expansionblocked
${x#z} (x=zcd)strip-prefix expansionblocked
${x%z} (x=cdz)strip-suffix expansionblocked
"${x,,}" (x=CD)lowercase case-modblocked
${x/x/c} (x=xd)replace expansionblocked
${x:-cd}default-value expansionblocked
${!name} (name=x, x=cd)indirect expansionblocked
${a}${b} (a=c, b=d)parameter concatenationblocked
${x}d (x=c)parameter + literalblocked
c${x} (x=d)literal + parameterblocked
time cd /tmppipeline-time prefixblocked
! cd /tmplogical-not prefixblocked
{cd,ls} /tmpbrace expansionblocked
c{d,p} /tmppartial brace expansionblocked
./cd /tmp, /usr/bin/cd /tmppathed command wordblocked
case x in *) cd /tmp ;; esaccase statementblocked
declare x=cd; $x /tmpdeclare-style assignmentblocked
export x=cd; $x /tmpexport-style assignmentblocked
f() { "$@"; }; f cd /tmpfunction dispatches $@blocked
f() { $1 /tmp; }; f cdfunction dispatches $1slips
printf -v x cd; $x /tmpprintf writes to varslips
read x <<< cd; $x /tmpread writes from herestringslips
trap 'cd /tmp' DEBUG; :trap registers codeslips
echo cd | xargs -I {} {}xargs runs constructed cmdslips
echo cd | tee f.sh; bash f.shwrite script, run itslips

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.

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:

$ 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!