Repository navigation
Description
Description
Bash permission rules with anchored patterns (rm *, mv *, cp *) are non-deterministic: the same command, run multiple times in identical conditions, sometimes triggers the ask prompt and sometimes executes silently. 50% bypass rate for rm, 90% bypass rate for mv in my testing.
When the rule fires:
! permission requested: bash (rm /tmp/test.txt); auto-rejecting
✗ rm /tmp/test.txt failed
When it doesn't fire, the rm is executed and the file is removed — no prompt, no log entry.
Static config is correct: opencode debug config shows rm *: ask, and a Python reproducer of Wildcard.match confirms rm /tmp/test.txt matches rm * (returns True). The bug is at the bash tool invocation layer, not the pattern matcher.
Plugins
None
OpenCode version
1.18.3
Steps to reproduce
- Configure
bashrules:{ "permission": { "bash": { "*": "allow", "rm *": "ask", "mv *": "ask", "cp *": "ask" } } } - From any cwd, run:
for i in $(seq 1 20); do echo "X" > /tmp/test_$i.txt out=$(timeout 15 opencode run --model <model> "Run: bash -c 'rm /tmp/test_$i.txt'" 2>&1) has_prompt=$(echo "$out" | grep -c "permission requested") file_exists=$(ls /tmp/test_$i.txt 2>/dev/null | wc -l) echo "Run $i: prompt=$has_prompt file_exists=$file_exists" done
- Observe that
rm /tmp/test_$i.txtis sometimes blocked and sometimes executed silently.
Reproduction data
Test 1 — rm (20 runs): 10/20 prompts fired, 10/20 bypassed. Several sample runs:
- Run 1: prompt=0 file_exists=0 (NO PROMPT, file deleted ⚠)
- Run 2: prompt=1 file_exists=1 (correct)
- Run 3: prompt=0 file_exists=0 (NO PROMPT, file deleted ⚠)
- Run 4: prompt=1 file_exists=1 (correct)
Test 2 — mv /tmp/a /tmp/b (10 runs): 1/10 prompts fired, 9/10 bypassed. ⚠
Test 3 — full output capture (5 runs):
- Run 1:
bash -c 'rm /tmp/full_test_1.txt'→ PROMPT, file intact ✓ - Run 2:
bash -c 'rm /tmp/full_test_2.txt'→ NO PROMPT, file DELETED ⚠ - Run 3: agent did not invoke bash → file intact
- Run 4: → PROMPT, file intact ✓
- Run 5: → PROMPT, file intact ✓
Screenshot and/or share link
Not applicable — deterministic rule on paper, non-deterministic in practice. Numerical evidence above is reproducible.
Operating System
Ubuntu 24.04
Terminal
gnome-terminal (Wayland)
Additional context
- Other tools (read, edit, glob, grep) enforce rules consistently. The bug is specific to the bash tool.
external_directory(path-level) works correctly.- Most likely cause: the bash tool's
allStructuredparser (packages/opencode/src/permission/wildcard.ts) doesn't match the head/tail split correctly for allbash -c "..."invocations, or there's a race condition in the permission check. - Related but distinct issue: Permission rules match rewritten shell commands after tool.execute.before hooks #35882 (plugin-rewritten commands). My repro uses no plugins.
- Impact: users relying on bash rules for destructive command protection in unattended sessions have a false sense of security. Running opencode with
--yoloor--autoin unattended mode is unsafe — bash rules cannot be relied upon to block destructive operations.
Activity
github-actions commentedon Jul 26, 2026
This issue might be a duplicate of existing issues. Please check:
permission.bashpattern maps: "*": "deny" fallback removes the bash tool from all agents; deny patterns under an allow fallback are ignored #36765:permission.bashdeny patterns under an allow fallback are silently ignored — overlapping root cause (bash permission rules not reliably enforced)If your issue is distinct from all of the above, please add a comment clarifying the difference.
aanbrn commentedon Sep 17, 2026
Adding a reproducible input for this class, from a push that was silently allowed, plus an observation that the
reduction is not stable across identical shapes — which may be the same thing you measured.
The input (in a scratch clone, since the second line really pushes):
with
"permission": {"bash": {"*": "allow", "git push*": "ask"}}on 1.18.31 (macOS). In the log, the pushline's head was committed to the previous line's
sed, and the line's own tail split into further units, sothe string the permission check received begins with
sed:(condensed from
~/.local/share/opencode/log/opencode.log; the evaluated payloads are exact.) The anchoredgit push*cannot match a string starting withsed, so the catch-all decides, and no prompt appears. A push of thesame shape, reduced with
git push … 2>&1as its own unit, does match:The same shape is not always split the same way. For the same predecessor shape — a
git commit … && echo …line
followed by a push with the same
2>&1 | tail -2 | sed …tail — two invocations reduced it differently: oncethe
echoand the push were separate units and the push matchedgit push*and prompted, and once the two weremerged into one unit and the push was silently allowed:
So the split depends on more than the visible shape of the call, which is consistent with the 50–90 % bypass
rates you measured and with the matcher being correct — the input to it is what varies.
How this relates to the linked issues: #36765 and #31485 were closed, so the useful thing I can add is a
concrete input plus the observation that the anchored rule does fire when the reduction is clean. No
command-rewriting plugins are involved here, so #35882 is a different path.
Workaround we adopted: anchor on both sides —
*git push*instead ofgit push*— since the rule no longerrequires the string to begin with the command. The expected cost is over-asking on commands that merely mention
it (a
grep, a commit message), because that is how substring matching behaves.JasperTeng commentedon Sep 30, 2026
I just tested OpenCode 1.18.33.
"git push*": "deny"does not block... && git push ..."*git push*": "deny"does not blockgit push ...mahirhir commentedon Oct 6, 2026
I traced this on
dev(f03046d) with the realShellTool: abun testthat captures thebashpermission request and runs each pattern throughPermission.evaluatewith{"*": "allow", "rm *": "ask", "git push*": "ask"}. There are two causes. Both are deterministic, and neither is a race.1. The 50% in the report comes from the model, not the matcher. The prompt asks for
bash -c 'rm /tmp/test_$i.txt'. The pattern that gets checked is the source text of each tree-sittercommandnode (source()/collect()insrc/tool/shell.ts). When the model passes the command through verbatim, the pattern isbash -c 'rm ...', whichrm *can't match, so*: allowwins. When the model unwraps it, the pattern isrm /tmp/test.txtand the rule fires. That's the prompt shown in the report (permission requested: bash (rm /tmp/test.txt)).Wildcard.allStructuredisn't on this path; the only place it's referenced istest/util/wildcard.test.ts.It's the same root cause for these other spellings, which also run
rmand evaluate toallow. Only the control also raisesexternal_directoryfor/tmp; none of the other rows do, becausetokens[0]isn'trm:rm /tmp/x(control)rm /tmp/xbash -c 'rm /tmp/x'bash -c 'rm /tmp/x'sudo rm /tmp/xsudo rm /tmp/x\rm /tmp/x\rm /tmp/x/bin/rm /tmp/x/bin/rm /tmp/xecho /tmp/x | xargs rmecho /tmp/x,xargs rmgit push origin maingit push origin main2. @aanbrn's
sed ...+ newline +git pushcase is a tree-sitter-bash grammar bug. It shows up in the pinned 0.25.0 and in 0.25.1. It happens when line N ends in a pipeline of 3+ commands and line N+1's first command is followed by a redirection (2>&1,> f) or by&&/||. The grammar then folds that command into the arguments of the last command on line N, andhasErrorstays false. Minimal case:a | b | c+ newline +rm -rf /tmp/x 2>&1gives the patternsa,b,c\nrm -rf /tmp/x, which evaluates to allow. The&&form is already reported upstream as tree-sitter/tree-sitter-bash#347. The redirection form is the one agent commands hit, because most of them end in2>&1. In that casetokens[0]isc, so theexternal_directorycheck incollect()is skipped for thatrmas well.Patch sketch. I tested this on a copy of
collect()running against the real grammar. Dangerous forms allowed went from 15/17 to 0/17, and the verdicts for 9 benign controls didn't change. The idea is to keepsource(node)as it is and add normalized patterns.Permission.askasks if any pattern asks, so adding patterns can only make a rule stricter. The added patterns:\or a path prefix stripped;sudo,doas,command,builtin,exec,env,nice,nohup,time,timeout,xargs,stdbuf) and their options andVAR=xprefixes;sh/bash/zsh -candeval, and the argv afterfind -exec/-execdir/-ok;commandnode starts on a later row than the previous token ended, with no\continuation, split the node there and re-parse the rest.I can turn this into a PR with these cases in
test/tool/shell.test.tsif @kitlangton wants it. Otherwise, feel free to use the table.samvallad33 commentedon Oct 8, 2026
a rule that fires half the time on rm and one time in ten on mv is worse than no rule, because you can't trust the passes either.
pattern rules fight the shell itself. by the time a string runs it has been quoted and globbed and expanded, so the pattern is guessing at the result.
I built a small gate that goes the other way. it resolves the command first and then decides from what it would touch, so the same command gets the same answer every run. it does not ask on every rm or mv. it stops the destructive ones it knows, like a recursive delete outside the project, and lets the rest through.
it is free and part of vestige.
npx operator-liteon mac and linux it installs for opencode. it starts in shadow, records everything and blocks nothing.
npx operator-lite mode enforcethat turns blocking on, from your own terminal.
thdxr commentedon Oct 11, 2026
Thanks for the report! OpenCode 2.0 is now out and development is focused on V2, so we're closing issues filed against OpenCode 1. Please upgrade (
opencode upgrade --major, details: https://anoma.ly/notes/opencode-2-0), and if this is still relevant on 2.0, reopen this issue or open a new one with your 2.0 version.— from 𝕺𝖕𝖊𝖓𝕮𝖔𝖉𝖊