Auto-approval & command allowlists.
An allowlist that matches shell strings is a second shell parser, and the moment it disagrees with the real one the approval is worthless — which is why nine advisories hit one coding agent's approval gate between March and October 2026 without a single one of them being a mistake in the list's contents. Approve the object you will actually execute, usually an argv vector, and make the sandbox rather than the list carry the consequence of being wrong.
Step 1What auto-approval is for, and what it quietly becomes.
Every agent that can act has to decide, per action, whether to act or ask. Asking every time destroys the point: a coding agent that stops for permission on git status is slower than doing the work yourself. So harnesses ship a pre-approval mechanism — a list of commands, tools or path prefixes the user has blessed in advance, after which the agent proceeds silently. Claude Code's permission rules, Cursor's command allowlist, and the "always approve execute" setting in editor agents are all this feature.
It starts life as a convenience: fewer prompts for actions the user already trusts. Within about a week it has become a security boundary, because the thing on the other side of it is a shell running with the developer's own SSH agent, cloud profile and repository write access. Nobody decides to make that promotion; it happens because the list is the only thing standing between model output and execution. The two roles want opposite designs. A convenience feature should be permissive and forgiving. A boundary should be exact, and exactness is the thing a string match cannot deliver.
The test for which role your list is playing: if the list were bypassed on the next call, would you need an incident response? If yes, it is a boundary, and it needs the properties of a reference monitor — non-bypassable, always-invoked, small enough to verify — rather than the properties of a config file.
Step 2Why matching a command string fails as a class.
A shell command is a program in a language with operators, quoting, substitution, redirection, globbing and comments. To decide whether foo is the command being run, you have to parse that language. If your gate parses it one way and the shell you then hand the string to parses it another way, the gate approved a command that never ran and the shell ran one that was never approved. CWE-436 calls this an interpretation conflict, and it is the precise name for the whole category.
Roo Code is the worked example because somebody enumerated it in public. Nine GitHub advisories between 30 March and 13 October 2026 — five published on that single October day — all land on one decision path, with seven researcher writeups naming seven different constructs: redirects, glob tokens, quote boundaries, comment boundaries, the pipe-both operator, command substitution, and output used as a command. One of them is small enough to read in full. The gate stripped redirections before matching, using /\d*>&\d*/ so that a harmless 2>&1 would not break the prefix check. Both digit groups are optional, so the pattern also eats a bare >& — which in bash is not a descriptor duplication but "send both streams to the file named next":
ALLOW = ["echo"] DENY = ["touch"] # gate strips ">&", sees `echo touch ...`, matches ALLOW, approves. # bash reads: redirect both streams to a file named "echo", then run touch. >&echo touch /tmp/PWNED gate: auto_approve bash: touch runs
A second variant sits one layer lower. The tokeniser used a general-purpose shell-quote library that begins a comment at a # even mid-word, and returned the rest as one comment token the gate's loop then dropped. Bash starts a comment only at the start of a word, so in echo hi#;touch /tmp/PWNED the # is literal, the ; is a real separator, and the gate never saw the second command at all. Three layers — bash, the tokenising library, the gate's own regex normalisation — each held a different theory about where the command began.
The lesson is not "write a better regex". Each individual fix is correct and closes exactly one finding. The set of shell constructs is defined by bash, it is larger than your test suite, and it grows. A control whose correctness depends on out-parsing a shell you do not own has no stable state.
Step 3Approve the object you execute.
The fix is structural, and it is cheap: make the thing the gate inspects be the thing the operating system runs. In practice that means having the model emit an argv vector and executing it directly, with no shell in the path.
# Fragile: one string, two readers, and only one of them executes. decision = gate(cmd_string) if decision == "approve": subprocess.run(cmd_string, shell=True) # Sound: the gate inspects argv[0] and the flags that will actually be exec'd. argv = ["git", "status", "--short"] if gate(argv): subprocess.run(argv, shell=False) # no shell: no redirects, # globs, substitution, comments
With no shell, the constructs that caused all nine advisories do not exist. Redirection, piping and globbing become explicit tool parameters when the agent genuinely needs them — {"cmd": [...], "stdout_to": "out.txt"} — which also makes them visible to the gate instead of hidden inside a string. Three properties follow for free: there is exactly one reading of the command, the gate's reading is the executed reading, and a reviewer can tell from the log what ran.
Argv matching removes the parsing class; it does not remove argument abuse. git -c core.sshCommand=…, find -exec, and anything with a --eval flag are shells by another route, so a serious list allows flags as well as binaries, or keeps the dangerous binary out of the environment entirely. That is a finite, enumerable problem about a specific tool, which is exactly what the string-matching version never was.
Step 4Put the risk where parsing cannot be wrong.
Even a correct allowlist is the wrong place to hold the consequence. A list is a statement about syntax; what you actually care about is effects — what gets read, written, or sent. Effects are enforceable by the kernel, carry no parsing risk, and hold whatever string gets through:
- Run it in a sandbox, not on the laptop. A bypass inside a container with no credentials, a read-only mount and default-deny egress is a nuisance. The same bypass on a developer workstation reaches an SSH agent and a cloud profile — see agents on developer workstations and sandboxing & code execution.
- Size the list to the blast radius, not the other way round. Pre-approval is only as safe as what it fronts; the question is never "is
echosafe" but what an approved call can reach. - Assume the command came from an attacker. The published CVSS vectors for the October cluster record passive user interaction and no privileges: the developer asks the agent to read an issue or a changelog, and the text contains the command. That is ordinary indirect prompt injection, and an agent with untrusted input, private data and a shell is the lethal trifecta with the allowlist as its only mitigation.
- Fail closed, and log the pair. An unparseable or unrecognised command asks the human; see fail-closed vs fail-open. Then record the proposed command, the verdict, and the exit status together — without that pairing a bypass is indistinguishable from a normal approval, which is how one goes unnoticed for seven months.
Scope matters too, and it is usually set too wide: a rule granted for one task tends to persist for the session. Prefer per-task grants over per-session ones, re-ask when the scope changes, and keep the grant readable to the user — the UX half of this is approval & confirmation UX, and the policy half is policy as code.
Do three things this week. Switch the gate's input from a command string to an argv vector and execute with shell=False. Move the agent's shell into a container with no ambient credentials and default-deny egress, so a bypass costs you a rebuild rather than an incident. Then add one log line per decision pairing the proposed command with the verdict — if you cannot reconstruct, from logs alone, every command your allowlist approved last week, you do not have a boundary, you have a setting. The field evidence is in the allowlist and the shell disagreed.