DEV Community

Daniel Meshulam
Daniel Meshulam

Posted on

I bypassed my own Claude Code deny-list in eight ways. Only an allow-list held.

In April 2026 a coding agent at PocketOS hit a credential mismatch in staging, found an infrastructure token in an unrelated file, and deleted the production database and its volume backups in one API call. Thirty hours down. Every post-mortem named the same causes: a token within reach, no gate on destructive actions, backups in the same blast radius. Nobody blamed the model.

Most teams I talk to protect against this with a line in CLAUDE.md that says "never touch production". That is a sign, not a lock. So I built the lock, then spent an evening trying to break it. Here is what happened.

Round one: the deny-list

Claude Code has PreToolUse hooks: a small program that receives the tool call as JSON on stdin and answers with an exit code (0 allow, 2 deny). The obvious first guard for a protected folder is a deny-list. Block rm, mv, rmdir when the command mentions protected/. Ten lines. Done.

It took minutes to defeat. All of these wrote into the protected tree untouched:

Bypass Command
Other verbs cp, tee, dd, sed -i, install, rsync into protected/
Nested interpreter sh -c "rm protected/x", perl -e 'unlink "protected/x"'
Variable indirection P=protected; rm $P/x
Subshell (cd protected && rm x)
Acting flag find protected -delete
Pipeline `ls protected \
Clobber operator {% raw %}`echo x >\
Symlink {% raw %}ln -s protected alias; rm alias/x

Worse: two of the blocks the guard did produce came from a quoting parse error, not from detection. A guard that is wrong in both directions cannot be reasoned about at all.

Round two: invert the rule

The fix is not a longer deny-list. It is a different rule: referencing a protected path is denied unless every verb in the command is on a short read-only allow-list (cat, ls, grep, head, find without acting flags, and a few more). Anything unknown fails closed. A tool nobody predicted is covered by construction, because nothing has to be predicted.

Three details that only showed up under attack:

  1. Resolve paths before comparing. ../protected/x, symlinks and cd in the middle of a command all change what a relative path means. The guard tracks the session cwd that Claude Code sends and resolves every token with realpath before deciding. Using the hook process's own cwd made the same command allowed or blocked depending on where the hook happened to be launched from.
  2. Read every path key. The write guard originally read only tool_input.file_path. NotebookEdit sends notebook_path; MultiEdit nests paths under edits[]. Adding those tools to the matcher looked like coverage while the guard inspected nothing. A guard that is wired but cannot read its input is more dangerous than a missing one, because the settings file now lies to you.
  3. Fail closed on confusion. Unparseable quoting, a missing config file, a malformed policy, a payload with no path: every one of these denies. A guard that waves something through when confused is worse than no guard.

Round three: destructive commands that touch no path

The path rule says nothing about git push --force, git reset --hard, curl | sh, terraform destroy or DROP TABLE. Those get a second layer: regex deny patterns checked on the raw command string, before any path logic, so quoting tricks around the path parser cannot route around them.

Prove it or it does not exist

Every bypass above is now a permanent regression test. The suite feeds each hook the JSON Claude Code would send and asserts the exit code, 125 assertions, and it snapshots and restores everything it mutates so a Ctrl-C mid-run cannot leave the repo in a broken state (the suite even kills a nested copy of itself at the dangerous moment to prove the trap works).

Every block also writes one JSON line to an audit log and, if you set a Slack or Discord webhook, posts the block to the team channel in the same call. A small script turns a month of that log into a report: how many agent actions were blocked, by reason, by tool, by day. A control nobody can see is a control nobody funds.

Postscript: the built-in rules had bypasses too

The Claude Code 2.1.268 changelog fixed deny and ask rules that did not apply on symlinked directories, and a case where a Read or Edit deny rule did not apply when env -C, eval or a similar command the permission checker cannot analyze was on the same line. That is not a criticism of the permission system; it is the reason to put a tested hook layer in front of it and to re-run the tests on every release.

Take it

The kit is public: agent-guardrails-kit. Copy hooks/, gates/ and config.json into a repo, edit protected_paths and deny_patterns, wire two hooks in .claude/settings.json, run the tests.

If you want to know where your current setup stands before touching anything, there is a grader that reads your settings.json and CLAUDE.md in the browser and scores it out of 10: agent-guardrails.meshulam791.workers.dev/grade. Nothing is uploaded. Here is what the full exposure report looks like on a fictional repo.

I install and test-prove this for teams on Claude Code and Cursor. Details on the same site.

Top comments (5)

Collapse
 
raknaos profile image
Raknaos • • Edited

I ran a similar experiment on the hooks of my own agent tooling and hit the same wall: the deny-list becomes a cat-and-mouse game with shell grammar. Nested interpreters were our favorite one — an agent wrapped rm in a python -c 'os.remove(...)' completely dead-on to any verb matching.

Two things that helped us: resolving to realpath before matching (we use tool_input file paths where available instead of parsing argv), and failing closed on anything the parser cannot fully resolve. Out of curiosity — how did you handle the quoting parse errors? Did you fall back to deny-on-unparseable, or did you switch to a proper shell parser?

Collapse
 
glitchbound profile image
Daniel Meshulam •

Deny-on-unparseable, and your question made me go look at it properly, so thank you for that. It was hiding a bug.

The original tokeniser split the raw command on ;|&() first and ran shlex on each piece. That splits inside quotes, so your exact example arrived as two fragments with unbalanced quotes and was denied as unparseable. It looked like a detection and was not one: the same guard let these through untouched, because after argv splitting each is a single token that resolves to no path at all.

perl -e 'unlink "protected/important.txt"'   # allowed
sh -c 'rm protected/important.txt'           # allowed
Enter fullscreen mode Exit fullscreen mode

Not a full shell grammar, but a real lexer now: shlex.shlex(posix=True, punctuation_chars=True) over the whole command, so quotes are parsed before structure and ; | & ( ) < > come back as their own tokens. Then the payload of a quoted token is re-lexed, one level deep, when the verb is an interpreter. Deny-on-unparseable stays as the backstop, but it is no longer doing work it was never meant to do.

The interesting part was the false positive. Re-lexing every token catches python -c 'shutil.rmtree("secrets")', and it also blocks git commit -m "stop touching secrets", which is prose. A guard that blocks commit messages gets switched off by the team inside a week, so the deep scan is scoped to a policy list of interpreter verbs (sh, bash, python, perl, node, ruby, env, timeout, xargs and friends), while anything containing a path separator is checked for every verb. dd of=secrets/x needed the value after the first = checked too.

Sixteen payloads are permanent tests now, next to eleven commands that must stay allowed. Commit: github.com/danielhagever/agent-gua...

Two things I would still like to know from your fleet. What do you do about a bare name with a non-interpreter verb, something like a custom CLI mytool --target secrets, where nothing in the token looks like a path? And do you gate the agent's credentials separately, or is the hook layer the only thing between an agent and prod?

Collapse
 
richard_smith_154156d471ef profile image
Richard Smith •

The allow-list vs deny-list distinction is the real insight here. With deny you're always playing whack-a-mole. With allow you're safe by construction, even against tools nobody predicted yet.

Collapse
 
glitchbound profile image
Daniel Meshulam •

Thanks, Richard. One refinement from the week since I wrote it: the allow-list is only safe by construction as far as the guard reads the command the way the shell does. This week my own kit turned out to let a destructive command through when it sat on the second line of a multi-line command, because the parser treated a newline as a space and judged line two as arguments to a read-only verb on line one. Fixed in public, with a permanent test: github.com/danielhagever/agent-gua...

So the construction holds, but the parser is part of the construction, and that is the part worth attacking.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.