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 (1)

Collapse
 
raknaos profile image
Raknaos

I ran a similar experiment on our agent fleet's hooks 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?