Skip to content

bash permission rules non-deterministic for rm *, mv *, cp * patterns #39001

Closed as not planned

Description

@iamjalberto

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

  1. Configure bash rules:
    {
      "permission": {
        "bash": {
          "*": "allow",
          "rm *": "ask",
          "mv *": "ask",
          "cp *": "ask"
        }
      }
    }
  2. 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
  3. Observe that rm /tmp/test_$i.txt is 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 allStructured parser (packages/opencode/src/permission/wildcard.ts) doesn't match the head/tail split correctly for all bash -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 --yolo or --auto in unattended mode is unsafe — bash rules cannot be relied upon to block destructive operations.

Activity

  1. github-actions commented on Jul 26, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

    If your issue is distinct from all of the above, please add a comment clarifying the difference.

  2. aanbrn commented on Sep 17, 2026

    @aanbrn

    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):

    git switch -c probe-nginx-121-on-x86 --no-track origin/main 2>&1 | tail -1 | sed 's/^/  /'
    git push -u origin probe-nginx-121-on-x86 2>&1 | tail -2 | sed 's/^/  /'

    with "permission": {"bash": {"*": "allow", "git push*": "ask"}} on 1.18.31 (macOS). In the log, the push
    line's head was committed to the previous line's sed, and the line's own tail split into further units, so
    the string the permission check received begins with sed:

    permission=bash pattern="sed 's/^/  /'\ngit push -u origin probe-nginx-121-on-x86"  -> action.pattern=* action=allow
    permission=bash pattern="tail -2"                                                    -> action.pattern=* action=allow
    

    (condensed from ~/.local/share/opencode/log/opencode.log; the evaluated payloads are exact.) The anchored git push* cannot match a string starting with sed, so the catch-all decides, and no prompt appears. A push of the
    same shape, reduced with git push … 2>&1 as its own unit, does match:

    permission=bash pattern="git push -u origin capture-cnb-fixed-timestamp 2>&1"        -> action.pattern="git push*" action=ask
    

    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: once
    the echo and the push were separate units and the push matched git push* and prompted, and once the two were
    merged into one unit and the push was silently allowed:

    permission=bash pattern="echo \"  committed\""          -> action.pattern=*            action=allow
    permission=bash pattern="git push -u origin fix-per-service-compose-tasks 2>&1" -> action.pattern="git push*" action=ask
    permission=bash pattern="echo \"  committed\"\ngit push" -> action.pattern=*            action=allow
    

    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 of git push* — since the rule no longer
    requires 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.

  3. JasperTeng commented on Sep 30, 2026

    @JasperTeng

    I just tested OpenCode 1.18.33.
    "git push*": "deny" does not block ... && git push ...
    "*git push*": "deny" does not block git push ...

  4. mahirhir commented on Oct 6, 2026

    @mahirhir

    I traced this on dev (f03046d) with the real ShellTool: a bun test that captures the bash permission request and runs each pattern through Permission.evaluate with {"*": "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-sitter command node (source() / collect() in src/tool/shell.ts). When the model passes the command through verbatim, the pattern is bash -c 'rm ...', which rm * can't match, so *: allow wins. When the model unwraps it, the pattern is rm /tmp/test.txt and the rule fires. That's the prompt shown in the report (permission requested: bash (rm /tmp/test.txt)). Wildcard.allStructured isn't on this path; the only place it's referenced is test/util/wildcard.test.ts.

    It's the same root cause for these other spellings, which also run rm and evaluate to allow. Only the control also raises external_directory for /tmp; none of the other rows do, because tokens[0] isn't rm:

    command pattern(s) evaluated verdict
    rm /tmp/x (control) rm /tmp/x ask
    bash -c 'rm /tmp/x' bash -c 'rm /tmp/x' allow
    sudo rm /tmp/x sudo rm /tmp/x allow
    \rm /tmp/x \rm /tmp/x allow
    /bin/rm /tmp/x /bin/rm /tmp/x allow
    echo /tmp/x | xargs rm echo /tmp/x, xargs rm allow
    git push origin main git push origin main allow (raw text is matched, not tokens)

    2. @aanbrn's sed ... + newline + git push case 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, and hasError stays false. Minimal case: a | b | c + newline + rm -rf /tmp/x 2>&1 gives the patterns a, 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 in 2>&1. In that case tokens[0] is c, so the external_directory check in collect() is skipped for that rm as 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 keep source(node) as it is and add normalized patterns. Permission.ask asks if any pattern asks, so adding patterns can only make a rule stricter. The added patterns:

    • the tokens joined by single spaces;
    • the head with a leading \ or a path prefix stripped;
    • the argv left after stripping wrappers (sudo, doas, command, builtin, exec, env, nice, nohup, time, timeout, xargs, stdbuf) and their options and VAR=x prefixes;
    • the result of re-parsing the string argument of sh/bash/zsh -c and eval, and the argv after find -exec/-execdir/-ok;
    • for the grammar bug: when a token in a command node 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.ts if @kitlangton wants it. Otherwise, feel free to use the table.

  5. samvallad33 commented on Oct 8, 2026

    @samvallad33

    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-lite

    on mac and linux it installs for opencode. it starts in shadow, records everything and blocks nothing.

    npx operator-lite mode enforce

    that turns blocking on, from your own terminal.

  6. thdxr commented on Oct 11, 2026

    @thdxr
    Member

    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 𝕺𝖕𝖊𝖓𝕮𝖔𝖉𝖊

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions