My guard blocked bash -c. Models write bash -lc.

Assumes you've run a coding agent with auto-approval on, or written a hook that vets its shell commands.

The guard looked at bash -lc 'rm -rf build' and said allow.

It denied bash -c 'rm -rf build'. It denied bash -l -c 'rm -rf build', the same two flags passed as separate arguments. Pack the l and the c into one argument and the guard waved the recursive delete through without so much as a question. The guard had applied its rule exactly as written. The rule had been written for a different spelling of the same command.

The guard is the part of my agent-config templates that hard-denies destructive shell commands even when the agent runs with --auto, --yolo or --dangerously-skip-permissions. It covers six harnesses: OpenCode V1 and V2, pi, omp, Hermes Agent and Claude Code. One shared file of regexes is read by two implementations, a TypeScript guard for OpenCode, pi and omp and a Python guard for Hermes and Claude Code. A case file runs every command through both, so they can't drift apart. The case file was green. The end-to-end suite, which drives the real harness binaries with a fake model, was green on all six. Both were right about everything they tested.

Why the guard only looks at command position#

An earlier version of the rules matched forbidden words anywhere in a command line, and it denied ordinary work: grep -r sudo, a docker run --rm … -race, commit messages that mentioned the wrong word. So the rules anchor on the places where a command can start: the beginning of a line; after ;, &, |, (, { or a backtick; after a wrapper such as env, nice or timeout; and after sh -c '. A word in one of those positions is a command. Anywhere else, it's just a word.

That anchor is one shared prefix in front of the rules for rm -r, sudo, shutdown, shred and a few more. Its main part, the group that finds where a command can start, read like this before the fix:

(?:^|[;&|(`{]\s*|\b(?:then|do|else|xargs|env|nohup|time|exec|nice|command|builtin|watch|timeout\s+\S+)\s+(?:-\S+\s+)*|\b(?:ba|z|da|k)?sh\s+(?:-\S+\s+)*-c\s+['"]?\s*)

Read at the top level, it is four alternatives separated by |: a line start; a separator; a wrapper word followed by options; a shell (sh, bash, zsh, dash, ksh) followed by options, then -c and an optional quote. Three of the four made one assumption too many.

Two smaller pieces sit between that group and the command name: (?:\w+=\S*\s+)*, which skips leading variable assignments such as X=1, and an optional backslash or path, so \rm and /bin/rm count too. The excerpts in this post show the group only. The assignment piece gets its own section further down.

Hole one: -c had to stand alone#

The shell alternative wants -c as an argument of its own. bash -l -c matches it. bash -lc doesn't: -lc is one argument, the options loop swallows it as just another option, and the pattern goes on waiting for a separate -c that never comes.

Bash doesn't care how the flags are packed. With echo standing in for anything that matters, on bash 5.3.9:

$ bash -lc 'echo ok'
ok
$ bash -euc 'echo ok'
ok
$ bash -xc 'echo ok'
+ echo ok
ok
$ bash -ce 'echo ok'
ok
$ bash -c -- 'echo hi'
hi
$ bash -o pipefail -c 'echo hi'
hi

Every one of those runs its command string. -euc is what a careful script looks like, -o pipefail is what a script written by someone who has been burned looks like, and -c -- is what paranoia looks like. The guard covered none of them. The bash man page says -c reads commands "from the first non-option argument command_string" and nothing about where the c may sit; the shell accepts it packed with other options, and that is the only spec that matters here.

Hole two: wrapper options that take a value#

The wrapper alternative allowed options after env or nice as (?:-\S+\s+)*: any number of tokens that start with a dash. An option's value usually doesn't start with a dash. nice -n 5 sudo id has -n, then 5, and the 5 is neither an option nor a command, so the match stops there and sudo is never in command position.

The details were pettier than that. With a minimal stand-in for the rm rule (more on that below), nice -n5 rm -rf build matched the old prefix, because -n5 is a single dash token, and nice -n 5 rm -rf build didn't. On the real guard, the spaced form (nice -n 10 rm -rf build) got a question instead of a hard deny. One space decided it, in exactly the place no human ever looks.

timeout had a special case of its own, timeout\s+\S+, which expected the duration straight after the word. timeout 10 rm -rf build was covered, and timeout -s KILL 10 rm -rf build wasn't, although the coreutils synopsis is plain about the order: timeout [OPTION]... DURATION COMMAND [ARG].... Four wrappers weren't on the list at all: ionice, stdbuf, busybox, and the ! that negates a pipeline's exit status. The bash man page on that one: "If the reserved word ! precedes a pipeline, the exit status of that pipeline is the logical negation of the exit status". The pipeline still runs. ! rm -rf build deletes build and exits 1, reporting failure for a job well done.

Claude Code's own permission matcher has the same problem to solve and solves it the same way. Its docs say a Bash rule matches "after Claude Code splits compound commands and strips wrappers", and they name the wrappers: "timeout, time, nice, nohup, and stdbuf, plus the shell builtins command and builtin, and zsh's noglob." Anyone matching command text ends up keeping a list like that, and it is never finished.

Why every test was green#

The end-to-end suite had exactly one case for a delete inside a shell string, bash -c 'rm -rf victim'. It passed on all six harnesses, because the guard covered bash -c. The docs said the guard covered rm -rf "inside bash -c", and that was true, to the letter. I had tested the sentence, not the attack.

An audit of the guard flagged the gap, and a second pass that re-checked each finding rated it major, because bash -lc is a very common way for models to call a shell. That last part is the audit's judgement; I haven't counted it across the models I run. What I can show is how deeply the shape is built into one harness. OpenAI's Codex CLI builds every shell call itself today, and in codex-rs/core/src/shell.rs at tag rust-v0.164.0 the flag is chosen like this:

let arg = if use_login_shell { "-lc" } else { "-c" };
vec![
    self.shell_path.to_string_lossy().to_string(),
    arg.to_string(),
    command.to_string(),
]

The login parameter of its shell tool "Defaults to true", so a call is <shell> -lc <command>, which with bash as the shell is bash -lc. It wasn't always the harness writing it. At rust-v0.20.0, the model wrote the argument vector itself: the shell tool's command parameter was an array of strings. The same version's safety check carried a special case for exactly one shape, with the comment "Support bash -lc "..." where the script consists solely of one or more "plain" commands". Nobody special-cases a shape the model never sends.

Before changing anything, I ran every form the audit named through the guard's own check command. Eight came back allow, eight came back ask, and the controls (bash -c, bash -l -c, a bare nice rm -rf) were denied, exactly as the audit said.

The fix, piece by piece#

After the fix, that group reads:

(?:(?:^|[;&|(`{])\s*(?:!\s+)?|\b(?:then|do|else|xargs|env|nohup|time|exec|nice|ionice|stdbuf|busybox|command|builtin|watch|timeout(?:\s+-\S+(?:\s+[^-\s]\S*)?)*\s+\S+)\s+(?:-\S+(?:\s+[^-\s]\S*)?\s+)*|\b(?:ba|z|da|k)?sh\s+(?:[-+]o\s+\w+\s+|-\S+\s+)*-[a-zA-Z]*c[a-zA-Z]*\s+(?:--\s+)?['"]?\s*)
  • -[a-zA-Z]*c[a-zA-Z]* accepts -c packed with other flags: -lc, -euc, -ce.
  • (?:--\s+)? accepts bash -c -- '…', and [-+]o\s+\w+\s+ accepts bash -o pipefail -c.
  • (?:-\S+(?:\s+[^-\s]\S*)?\s+)* lets each wrapper option take one value that doesn't start with a dash. env -i rm -rf x still matches: the optional value group first takes rm as the value of -i, the rest of the pattern then fails, and the regex backtracks and gives rm back to the command.
  • timeout gets its own options before the mandatory duration.
  • (?:!\s+)? covers ! rm -rf build, and only in command position.
  • ionice, stdbuf and busybox join the wrapper list.

One fix came free. The old line-start alternative was a bare ^ with no \s* after it, so rm -rf build with two leading spaces was never in command position and only got an ask from a weaker rule. Now it's denied.

To see the difference without the rest of the rule file, I wrote a stripped-down reproduction: the two groups above, verbatim, a minimal stand-in for the recursive-rm rule, and re.search. It isn't the guard's real rule set; it shows what the prefix alone does:

$ python3 prefix_demo.py
match  match  bash -c 'rm -rf build'
-      match  bash -lc 'rm -rf build'
-      match  sh -euc 'rm -rf build'
match  match  nice -n5 rm -rf build
-      match  nice -n 5 rm -rf build
-      match  timeout -s KILL 10 rm -rf build
-      match    rm -rf build
-      -      bash -lc 'ls -la'
-      -      env -S 'rm -rf build'

The left column is the old prefix, the right one the new. Keep an eye on the last line.

The pattern has to run unchanged in two regex engines, JavaScript's for the TypeScript guard and Python's re for the Python one. They disagree on enough that every construct gets tried in both:

$ python3 -c 'import re; re.compile(r"(?<=\s*)rm")' 2>&1 | tail -1
re.PatternError: look-behind requires fixed-width pattern
$ node -e 'new RegExp("a++b")' 2>&1 | grep SyntaxError
SyntaxError: Invalid regular expression: /a++b/: Nothing to repeat
$ node -e 'new RegExp("(?P<cmd>rm)")' 2>&1 | grep SyntaxError
SyntaxError: Invalid regular expression: /(?P<cmd>rm)/: Invalid group

That's Python 3.14.8 and Node 22.22.0. Python wants fixed-width lookbehinds, JavaScript has no possessive quantifiers (Python has had them since 3.11), and each spells named groups its own way. The prefix uses none of the three, and on every command I checked, before the fix and after it, the TypeScript guard and the Python guard agreed.

The case file grew from 360 to 392 Python checks and from 376 to 408 TypeScript ones: 32 new cases, 20 that must be denied and 12 near misses that must keep their verdict. A new end-to-end case, bash -lc 'rm -rf victim', makes the real harnesses prove the common form is blocked. On the rules after the second round of fixes below, all six harnesses blocked it, and every suite passed with exactly one case more than before: OpenCode V1 1.19.4 28/28, OpenCode V2 2.1.4 28/28, pi 1.4.0 28/28, omp 18.12.6 29/29, Hermes at afe3cbd 27/27 and Claude Code 2.1.309 33/33.

The near misses are half the work#

A guard that denies everything is easy to write and impossible to work behind. Every new deny needs a neighbour that must stay allowed, or the next fix buys its safety with grep -r sudo again. These kept their old verdicts:

CommandVerdict, before and after
bash -lc 'ls -la'allow
sh -euc 'go test ./...'allow
bash -x build.shallow: no -c, it runs a script
nice -n 10 go build ./...allow
env -u HOME go test ./...allow
env -u X sum file.txtallow: sum starts with su
nice -n 5 summary.shallow: so does summary.sh
timeout -s KILL 10 go test ./...allow
! grep -q TODO main.goallow
busybox lsallow
ionice -c3 ./gradlew buildallow
stdbuf -o0 go test ./...allow
bash -c -- 'echo hi'ask (dynamic-code)
ssh -c aes128-ctr host uptimeask (remote-shell)

The ssh row is the one that looks wrong at first. ssh -c takes a cipher name, and a pattern hunting for sh -c sounds like it should fire on it. It doesn't, because \bsh needs a word boundary before the s, and there is none between two s characters.

The fix had a hole of its own#

While writing this up, I ran the new prefix against 56 wrapper spellings, built from the coreutils and util-linux man pages and busybox's usage lines. The rule I was pleased with two sections ago, one value per option and the value doesn't start with a dash, assumes a value is a single word. In that run, two of env's options broke it. -S takes a whole command line as one string, and -C takes a directory, which can contain a space.

Against the real guard, env -S 'rm -rf x' came back allow, and env -C '/tmp/my dir' rm -rf x came back ask. It was the same mistake as the old -c, an assumption about how an argument is spelled, made in the very fix that was supposed to retire it. Both forms are denied now, with five more must-deny cases and five more near misses, which put the case file at 402 Python and 418 TypeScript checks after that round.

A quoted variable hid the command#

Then the assignment piece. \w+=\S*\s+ is a name, an equals sign and a value without spaces, and a shell value is allowed to have spaces as long as they're quoted. I found it while reusing the prefix for another rule, and by then I knew exactly what to type. On version 1.0.0 of the templates:

  • X="a b" shutdown now ran.
  • A='x y' B=2 shred f ran.
  • X='a b' rm -rf / only got a question, from a weaker rule that caught the rm on its own.

\S* takes "a, the space ends the value, and the pattern is left looking at b" for a command name. Bash, which has never once cared how my regex splits words, assigns a b to X and runs shutdown. Same kind of mistake as the old -c and the env -S string: an assumption about how an argument is spelled. The hole sat in five rules of a released version. Version 1.0.1 fixed it on 2026-10-26: the value may now contain quoted parts.

Checking that fix found the next one. A value can hide a space in more ways than quotes: a backslash, a command substitution, a parameter expansion. On 1.0.2, X=a\ b shutdown now, X=a\ b shred f, X=$(echo a b) shutdown now and X=${A:-a b} shutdown now all ran. Version 1.0.3, released the same day as 1.0.1, also skips backslash escapes, $(…), ${…} and backquotes inside a value, in all seven rules that share the prefix now. It came with seven new cases, and four of them fail on the old rules.

To see the three versions side by side, I put each release's assignment piece (1.0.2 shipped 1.0.1's unchanged) in front of a stand-in for the command and ran eight lines through them with re.match. It is a reduction, not the guard's rules:

import re

# The assignment part of the guard's command-position prefix, as released, in front of a
# stand-in for the command a rule looks for. Not the guard's full rules.
ROUNDS = {
    "1.0.0": r"""(?:\w+=\S*\s+)*""",
    "1.0.1": r"""(?:\w+=(?:'[^']*'|"[^"]*"|[^\s'"])*\s+)*""",
    "1.0.3": r"""(?:\w+=(?:'[^']*'|"[^"]*"|\\.|\$\([^)]*\)|\$\{[^}]*\}|`[^`]*`|[^\s'"\\`])*\s+)*""",
}
COMMAND = r"(?:shutdown|shred)\b"

LINES = [
    "X=1 shutdown now",
    'X="a b" shutdown now',
    "A='x y' B=2 shred f",
    r"X=a\ b shutdown now",
    "X=$(echo a b) shutdown now",
    "X=${A:-a b} shutdown now",
    "X=`echo a b` shutdown now",
    "X='a b' echo shutdown",
]

print("".join(f"{r:7}" for r in ROUNDS) + "line")
for line in LINES:
    cells = ["match" if re.match("^" + p + COMMAND, line) else "-" for p in ROUNDS.values()]
    print("".join(f"{c:7}" for c in cells) + line)
$ python3 -I assign_rounds.py
1.0.0  1.0.1  1.0.3  line
match  match  match  X=1 shutdown now
-      match  match  X="a b" shutdown now
-      match  match  A='x y' B=2 shred f
-      -      match  X=a\ b shutdown now
-      -      match  X=$(echo a b) shutdown now
-      -      match  X=${A:-a b} shutdown now
-      -      match  X=`echo a b` shutdown now
-      -      -      X='a b' echo shutdown

Node 22.22.0 prints the same nine lines from the same patterns. The backquote row is the reduction's own; I didn't run that form against the real 1.0.2. The last line is the near miss: shutdown as an argument to echo stays allowed in all three.

Three versions, one mistake: the regex treated a shell word as a single token without spaces, and the shell has at least six ways to disagree. Every change to the prefix now has to carry these five as cases: X='a b' cmd, X="a b" cmd, X=a\ b cmd, X=$(echo a b) cmd and X=${A:-a b} cmd.

Update, 2026-10-26: four more forms#

"At least six ways" turned out to be modest. Checking the 1.0.3 fix turned up four more assignment forms. Bash runs all four, and the guard's rules, still unchanged in 1.0.4, let them through:

  • X+=1 shutdown now, an appending assignment;
  • X[0]=1 sudo id, an indexed assignment;
  • X=$(echo $(echo a) b) shutdown now, a substitution nested inside another;
  • X=$(printf '%s' ')') shutdown now, a quoted ) inside the substitution.

With echo ran standing in for the command, bash 5.3.9 runs all four:

#!/bin/bash
# the four forms 1.0.5 fixes, with `echo ran` standing in for the command
X+=1 echo ran
X[0]=1 echo ran
X=$(echo $(echo a) b) echo ran
X=$(printf '%s' ')') echo ran
$ bash assign_more.sh
ran
assign_more.sh: line 4: `X[0]': not a valid identifier
ran
ran
ran

The second form is the best one. Bash says X[0] is "not a valid identifier", and then runs the command anyway. The 1.0.3 assignment piece, in the same kind of reduction as above, misses all four:

$ python3 -I assign_more.py
-     X+=1 shutdown now
-     X[0]=1 sudo id
-     X=$(echo $(echo a) b) shutdown now
-     X=$(printf '%s' ')') shutdown now

Version 1.0.5 fixes them in all seven rules that share the prefix. The name part is now \w+(?:\[[^\]]*\])?\+?=, which takes an index and a +, and $(…) may contain quotes and one level of nested parentheses. An end-to-end case for X[0]=1 sudo id ran in five of the six harnesses on the old rules. In the sixth, Claude Code, the old guard let it through too, and Claude Code's own Bash(sudo *) deny rule stopped it. The list of forms every prefix change must carry as cases is now nine: the five above, plus these four.

Forms to test your own guard against#

If your agents run behind a text-matching guard, these are the spellings that got past my deny rules at some point, some of them only as far as a question. Feed them to the guard's check command, not to a shell, and give each one a near miss of its own:

bash -lc 'rm -rf build'
sh -euc 'rm -rf build'
bash -o pipefail -c 'rm -rf build'
bash -c -- 'rm -rf build'
nice -n 5 sudo id
env -u X sudo id
timeout -s KILL 10 rm -rf build
timeout -k 5 10 shred -u secret.txt
ionice -c 3 reboot
stdbuf -o0 rm -rf build
! rm -rf build
busybox rm -rf build
  rm -rf build
env -S 'rm -rf x'
env -C '/tmp/my dir' rm -rf x
X="a b" shutdown now
A='x y' B=2 shred f
X=a\ b shutdown now
X=$(echo a b) shutdown now
X=${A:-a b} shutdown now
X+=1 shutdown now
X[0]=1 sudo id
X=$(echo $(echo a) b) shutdown now
X=$(printf '%s' ')') shutdown now

What a regex still can't see#

A command name built at run time. $(echo rm) -rf victim gets past a guard that only reads the text, and the end-to-end suite has a case for it that executes on four of the six harnesses. That case is one reason the test rig can't count on the guard to keep a machine safe.

Update, 2026-10-26 (evening): that count changed three times in one day. pi's block had been its print mode refusing an ask nobody could answer; once the suite started approving pi's asks (HARNESS_GUARD_NO_UI=allow), pi ran the case too. Then an unrelated exception, letting rm -rf out run for reverse-engineering work, removed the native rm -rf * rule that had been the only thing stopping it in Hermes, and it ran in all six.

The recursive-delete rule now also treats a command name built with $(...) or backquotes as rm when rm's recursive flags follow, and the case is blocked in all six. A runtime-built name without those flags, such as $(echo rm) -f x, still gets through. The story: An exception in one layer deleted a block in another.

Wrappers that aren't on the list, such as setsid, chrt or taskset. Checked against the guard after the second round, setsid rm -rf build, chrt -i 0 rm -rf build and taskset -c 0 rm -rf build each get a question from the plain delete rule, not the hard deny.

And one edge I noticed during the first fix and left for a later round. A bare rm notes.txt got an ask, because deleting a file is worth a question. bash -lc 'rm notes.txt' got an allow, because the ask rule for plain deletes didn't look inside shell strings. The deny rules had learned to read bash -lc. The rule that asks questions hadn't.

Filed under: ai-agents, security, bash, testing

Sources#