Your Claude Code deny list is not a security boundary

Why ~/.claude/settings.json is not good enough as a security boundary and you should isolate Claude code completely.

I had python3 in the deny block of my ~/.claude/settings.json. Claude Code ran curl <your_safe_script.com/safe.sh> | python3 […] and the rule did not fire.

To its credit, it noticed it and drafted a bug report. I sent it, but the interesting part isn't the miss itself. It is what it tells you about what the deny list is actually for.

Why it doesn't fire

The permission matcher works on the command string as a whole, with prefix semantics. Bash(python3:*) matches a command that starts with python3. It does not match one where python3 appears after a pipe.

That is a class, not an instance. The same reasoning covers &&, ;, $(…), backticks, and every wrapper that takes a command as an argument — xargs, env, sh -c, nohup. The documentation acknowledges the shape of this on the allow side: exec wrappers such as watch, setsid, ionice and flock cannot be auto-approved by a prefix rule, and find with -exec isn't covered by a Bash(find *) rule either.

This issue isn't very new, there are a lot of reports, most of them written from the allow side. Where allow lists don't fire on piped commands, so they get prompted for things already approved. While that is an annoyance, the deny side is an opposite failure mode that might just run a worm in a poisoned dependency of that script. So a deny to mitigate that problem still runs unopposed.

Those are different severity classes and they deserve different treatment.

The config is writable by the code it constrains

And it gets worse. The ~/.claude/settings.json file is an ordinary file on disk, and hooks are ordinary scripts that fire on ordinary events. So there are two failures stacked. The rule may not match the command, and the rule may not be there when the command runs.

This is not hypothetical. The Mini Shai-Hulud campaign of May 2026 does precisely this: its payload writes a SessionStart hook into .claude/settings.json, puts a copy of itself alongside it, and probes Claude Code's own authentication context. Same file I put my deny rule in.

Not a careless agent. A poisoned dependency.

Which sent me looking at ~/.claude/plugins/, where I found rather more Python than I expected — five plugins, most of which I installed once and forgot about.

Plugins bundle slash commands, subagents, hooks and MCP servers into one install. They run as fully trusted code inside the session: skills execute shell commands, MCP servers install binaries, hooks intercept every tool call. There is no sandboxing, no signing, and no central vetting — installing one means trusting the developer and their entire release pipeline with the access you hold yourself.

This is not theoretical either. Pluto Security disclosed a flaw in Hookify — distributed through the official marketplace — which read rule files out of the project directory into the hook subsystem's trusted channel, so planting a file in a repository gave an attacker a steering channel into the model for anyone who had it installed.

The threat I actually care about

Why is the agent running python or node, pip or npm scripts a problem? Take Shai-Hulud 2.0 (https://www.microsoft.com/en-us/security/blog/2025/12/09/shai-hulud-2-0-guidance-for-detecting-investigating-and-defending-against-the-supply-chain-attack/).

Merely depending on a poisoned package is enough for it to execute and start exfiltrating your passwords and/or SSH keys.

One npm install or pip install of a compromised package, in a scratch script the agent wrote and I didn't read, and my SSH keys, cloud tokens, .npmrc and .git-credentials are gone. That risk predates agents entirely. Agents just install things far more often than I do, and I read far fewer of them.

Against that threat, a name-based deny list does nothing whatsoever. It is a guardrail against accident — useful for stopping the agent from wandering somewhere expensive, worthless as a boundary.

What I moved to

A VM for all my development, and I'll admit up front it is more work than I would like to admit.

No mounts. No SSH agent forwarding. No credentials copied in. Go, Java, Node, Docker and the rest installed inside, from a provision script in the Lima YAML — so the VM is disposable rather than a second pet. If I'm reluctant to delete it, the config will drift but I will have myself to blame. Claude Code runs inside, which means the plugin cache and its hooks are in there too. That only works if the agent itself is inside the boundary; a VM with Claude Code running on the host is decoration.

git push happens from the host. The host fetches from the VM over SSH and I read the diff before pushing — lockfiles and CI config, not just source. Nothing reaches the real repo without a human step on the far side of the boundary.

The last one is the part I expect to fight. Reviewing your own diff before pushing used to be automatic; commits got cheap and the habit went. Making it one alias instead of three commands is the difference between doing it and skipping it on a Friday afternoon.

Where next?

I've only just started on VM isolation this week, and the real solution is going to take a lot more than string and prefix semantics. The power of the shell is where Claude Code is becoming a victim of its own success. So until the deny list gets a serious makeover, the boundary will be the VM.