An exception in one layer deleted a block in another
Contents
Builds on My guard blocked bash -c. Models write bash -lc., which explains the guard's command-position rules and lists $(echo rm) -rf victim among the things a regex can't see.
At 20:01 I committed a convenience: my coding agents could now delete reverse-engineering output without asking. At 20:14 I committed a new deny rule for a command my own guard post had filed under "what a regex still can't see". In the thirteen minutes between them, one end-to-end run showed that the convenience had quietly switched off the only thing covering that command, in the one agent where anything covered it at all.
An rm exemption for extraction folders#
The guard in my agent-config templates hard-denies destructive shell commands for six coding agents (OpenCode V1 and V2, pi, omp, Hermes Agent and Claude Code), even under auto-approval. Recursive deletes are on its list. Reverse-engineering work produces a lot of directories meant to be thrown away: binwalk's _<file>.extracted, an extracted, an out. Every rm -rf out/ was refused, so the rm-recursive rule got an exemption: an rm whose arguments are only flags and those names, as plain relative paths, runs. Anything else among its arguments, ~, .., a glob, a quote, --, any other path, is still denied.
The guard isn't the only layer. Each agent also has its own permission lists, which the project generates from one source, and some of them can't express exceptions. Claude Code's and Hermes' lists had a native rm -rf * deny glob, which would block rm -rf out no matter what the guard decided. In that case the config generator drops any native deny glob that would block a command the shared rules say must run. Both lost their rm -rf * glob, and Hermes its rm -Rf * as well. The guard hook still denies every other recursive delete there, so nothing looked lost.
The Hermes case that failed#
Then the full end-to-end run, every agent driven through every case with a fake model. Five agents passed everything. Hermes passed 43 of 44.
The failing case was $(echo rm) -rf victim, where the command name only exists once the shell has run echo. A guard that reads command text can't see an rm there, and the case was in the suite as a documented limitation: every agent expected "executed". Every agent but Hermes. Hermes resolves command substitution before it matches its own deny globs, so for Hermes the glob saw rm -rf victim, and its expectation was "blocked".
That morning there had been two exceptions, not one. pi also expected "blocked", and my earlier posts on the guard and the test rig say so. pi's block was never a rule, though: in print mode pi's guard refuses any ask because nobody can answer it. At 14:54 the suite started approving pi's asks, to test pi the way the other agents are tested, and pi's expectation became "executed" with everyone else's. Both earlier posts now carry an update.
So by evening the only block on that command, in any of the six agents, was a Hermes glob. Making an unrelated rule friendlier had deleted it, through a generator working exactly as designed. Nobody had decided to give up that block, and the only thing that noticed was a test expectation written for one agent.
I'd like to report that I spotted it reading the generator's diff. I did read it. I even wrote the reassurance into the commit message: the guard hook still denies every other recursive delete there. Every other one, except the one it had never been able to see.
Three ways to close it#
Ask instead of deny. Treat any command name built at run time as a question. That was the first proposal, and it is useless here: the suite approves asks in four of the six agents (--auto in OpenCode V1 and V2, --yolo in Hermes, a guard setting in pi), so there an ask is a yes. It was dropped before anything was built, which is the cheapest time to find out that a fix only works when someone is watching.
Deny any $(...) in command position. Closes the hole and blocks a common way to start a Java build. "$(git rev-parse --show-toplevel)"/gradlew build is a perfectly ordinary way to run Gradle from a subdirectory, and it starts with exactly that.
Teach rm-recursive what a disguised rm looks like. A command name given as $(...) or backquotes, quoted or not, counts as rm when rm's recursive flags follow: -r, -R, -rf, --recursive, and flags made only of rm's own letters. That's the one I took. A first prototype accepted any flag containing an r, and it promptly denied $(which java) -jar app.jar and $(which java) -version Main; the -jar and the -version both have an r in them. The prototype was caught before it was committed, and the letter rule replaced it.
Here is the real rule, loaded from the guard's rule file before and after the fix, run over the case and its neighbours. It's that one rule only, in Python's re; the guard has others:
$ python3 -I rm_rule_demo.py
before after line
- deny $(echo rm) -rf victim
- deny `echo rm` -rf victim
- - $(which java) -jar app.jar
- - $(which grep) -rn x .
- - $(git rev-parse --show-toplevel)/gradlew build
- deny $(which cp) -rf a b
Node 22.22.0 gives the same rows from the same file, which matters because the TypeScript half of the guard compiles the same pattern as a JavaScript regex. The last row is a false positive I left in on purpose: $(which cp) -rf a b is a recursive copy, and the rule can't know which will print cp. It's rare, it fails safe, and the alternative was guessing what $(...) prints, which is the problem the rule exists to avoid.
The end-to-end case now expects "blocked" in all six agents, and gets it. On the rules from 20:01 it ran in all six. Five of those six results matched what the suite expected, because the suite expected the hole.
The block nobody knew was load-bearing#
An exception in one layer can remove a block in another, and the generator that keeps the layers in sync will do it without asking. What caught it here was one odd cell in a table of expectations: {"default": "executed", "hermes": "blocked"}. On a tidying day I'd have deleted that "hermes" key as an inconsistency, and the hole would have stayed open in all six agents with every test green.
Filed under: ai-agents, security, bash, testing
Read next#
- My guard blocked bash -c. Models write bash -lc.: the guard's command-position rules, and the first list of what a regex can't see.
- Tests that could not fail: another layer that said no before the guard could, from the other side.
- The test rig was the unsafe part: why a suite that runs
$(echo rm) -rf victimon purpose lives in a container.