Most agent guardrails ship a command blocklist. You give the agent a shell tool, you list the strings you never want to see, and you match every proposed command against that list. It feels solid. It is not, and the reason is mechanical rather than clever.
A blocklist matches strings. It sees the text rm -rf and refuses it. But the shell does not run the text you filtered. It runs the text after it has expanded quotes, variables, and substitutions. An attacker only has to write a command that means rm -rf to the shell while looking like something else to your filter.
The rewrite tricks
None of these are exotic. They are ordinary shell behavior, and the shell resolves all of them at parse time, before the program ever starts:
r''m -rf puts an empty quoted string in the middle of the word. The shell strips it, so the process that runs is plain rm, but your filter saw r''m and did not match.
rm$IFS-rf uses $IFS, the internal field separator, which expands to whitespace. The shell reads it as rm -rf. Your filter read a single unbroken token.
$(echo rm) -rf is command substitution: the shell runs echo rm first and pastes the result into the command line. ${x:-rm} is parameter-default expansion, which yields rm whenever x is unset. Backslash escapes like r\m do the same job. Every one of these arrives at the operating system as the exact command your blocklist was built to stop.
This is not a corner case. Any guardrail that matches the proposed command as a string, without modeling how the shell will rewrite it, is exposed to this whole family of tricks. The only filters that reliably sidestep it are the ones that refuse shell metacharacters wholesale, which is a blunt tradeoff rather than a filter that understands the shell.
Layer one: normalize before you match
The screening fix is to stop matching the raw string. Before InnerWarden compares a proposed command against its patterns, a pure, non-executing normalize_command step de-obfuscates the rewrite: it removes empty quotes, turns $IFS back into a space, unwraps $(...), resolves ${x:-default}, and collapses backslash escapes. Critically, it does this by parsing, not by running anything. r''m -rf normalizes to rm -rf, and only then does pattern matching run.
Because that happens inside the PreToolUse hook, the rewritten command is denied before it executes at all. Same idea as any other InnerWarden guardrail: catch the mistake cheaply and early. This is the layer that does the everyday work, and it is the same argument we make in runtime guardrails, not prompt guardrails. A filter that does not model the shell is guarding a language it does not speak.
Layer two: see the real command at the syscall
Normalization is good, but no de-obfuscator is ever provably complete. The shell grammar is large, and a determined attacker can keep inventing forms. So the honest position is: assume text matching can eventually be bypassed, and make sure something below the text still sees the truth.
That something is the sensor. At execve, the kernel already holds the fully expanded argument vector. Whatever obfuscation the attacker used is gone by then, because the shell has already resolved it. The process really is rm with the real flags. That is the ground truth, and it does not care what your string filter thought.
There was a real bug in reading it. For short-lived processes like rm and find, our userspace collector used to rebuild the argument vector from /proc/PID/cmdline. But a fast command is already gone by the time userspace opens that file, so the read came back nearly empty. Across a test run, most of those short-lived execs showed argc=1: we could see that something ran, but we were argv-blind to what.
GuardFall, shipped in v0.16.0, fixes this by reading the arguments in-kernel, inside the eBPF program at execve entry, before the process can exit. On the same workload argc went from 1 to 3, and the real post-rewrite command became visible. A filter-aware data-destruction detector then flags it: a find_delete_bulk sub-kind that only fires on unfiltered destructive commands, so a normal cleanup does not trip it but a bulk delete that slipped past screening does.
Two layers because one is not enough
This is defense in depth stated plainly. Layer one normalizes the text and denies the rewrite before it runs, which handles the common case at near-zero cost. Layer two watches the syscall and catches the real command even when the text games win, because at execve there is no text left to game. On Linux both layers are live, sitting alongside the same kernel Execution Gate we describe in denied is denied. Screening is cross-platform; the in-kernel argv capture and gate are Linux-only.
We validated this on real hosts, not a slide. Rewritten commands that a text filter waved through produced rm_rf_user_data and find_delete_bulk incidents on the true command, argc climbed from 1 to 3, and no new false positives showed up. The recordings live on the proof page. The lesson is short: filters guard the text, the shell rewrites the text, so you normalize before you match and you watch the real command at the syscall.