Skip to content

permissions: compound shell commands drop redirection, so an allowed command becomes an unprompted file write #52083

Closed

Description

@h0c0de

Description

A shell permission that is allowed becomes an unprompted arbitrary-file-write primitive when it is chained into a compound command, because the scanner splits compound commands and omits the redirection target from the permission resources it evaluates.

Minimal config (permissions in global opencode.jsonc):

{ "action": "shell", "resource": "pwd", "effect": "allow" }

A standalone command that redirects is correctly gated, and the full raw text is used as the resource:

permission requested: shell (pwd > /tmp/probe.txt); auto-rejecting

A compound command with the same redirect is not gated, and writes the file:

$ ls -la /tmp/probe.txt
-rw-rw-r-- 1 user user 5 ... /tmp/probe.txt

Server log for the compound case (no permission entry precedes it):

message="spawning process" command=/usr/bin/zsh args="[\"-c\",\"pwd && pwd > /tmp/probe.txt\"]"

Because the redirection target is dropped when the command is compound, every allowed shell rule is also a write primitive. pwd > /path/to/file and git status > /path/to/file are read-only commands on their own, but they overwrite arbitrary files with no approval.

This is not covered by the edit permission: that action governs the edit/write/patch tools, not writes performed by a shell redirection.

The same shape applies to project-scoped saved approvals created by "Allow always", since those are also allow rules for a shell command prefix.

Not yet tested: ; and | separators, $(...) command substitution, and whether an external_directory rule catches a redirection target outside the project tree.

Steps to reproduce

  1. In the global opencode.jsonc, allow a read-only shell command:

    {
      "$schema": "https://opencode.ai/config.json",
      "permissions": [
        { "action": "shell", "resource": "*", "effect": "ask" },
        { "action": "shell", "resource": "pwd", "effect": "allow" },
      ],
    }
  2. Run a standalone redirected command. The permission check fires and the non-interactive client auto-rejects it:

    opencode run "Run this exact shell command and report the result: pwd > /tmp/probe-a.txt"
  3. Run the same command chained with &&:

    opencode run "Run this exact shell command and report the result: pwd && pwd > /tmp/probe-b.txt"
  4. Compare: no permission request is emitted, and the file is created.

    ls -la /tmp/probe-a.txt /tmp/probe-b.txt

Consistently reproducible on every attempt.

OpenCode version

2.0.18

OS

Linux, kernel 7.0.0-31-generic, x86_64

Terminal

Zed (TERM_PROGRAM=zed, TERM=xterm-256color, COLORTERM=truecolor)

Plugins

@azumag/opencode-rate-limit-fallback@2.0.3

Additional Context

Activity

  1. github-actions commented on Sep 29, 2026

    @github-actions
    Contributor

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

  2. h0c0de commented on Sep 29, 2026

    @h0c0de
    Author

    Follow-up with more separator and target variants (2.0.18, same minimal config — only shell: pwd is allowed).

    Command Result
    pwd ; pwd > /tmp/oc-probe-semi.txt gated — resources evaluated: pwd, pwd > /tmp/oc-probe-semi.txt, auto-rejected
    pwd | pwd > /tmp/oc-probe-pipe.txt executed, file written (13 bytes)
    pwd && pwd > /tmp/oc-probe-external.txt executed, file written (13 bytes)

    Two additions to the original report:

    1. The bypass is separator-dependent. && and | drop the redirection from the evaluated resources, while ; preserves it and is correctly gated. So it is not a universal compound-command bypass.

    2. external_directory: deny in the global config did not gate the write when the redirection target was outside the project tree (/tmp/...). A shell redirection target appears to be outside the external_directory check, so read/edit/external_directory rules provide no coverage for it.

    Still untested: $(...) command substitution and backticks.

    Local workaround applied on my side: removing every allow rule for shell, so no allow-listed command exists to use as the write primitive.

  3. abelenko commented on Sep 30, 2026

    @abelenko

    Adding a root-cause diagnosis and a sharper statement of the rule, measured directly against ShellParse.scan(). Same defect as reported here — closing my duplicate (#52360) in favour of this one.

    The rule is narrower than "compound command"

    ; does not lose the redirection. &&, || and | do. And in a chain of three, only the first command keeps its own redirect:

    command resources destination visible
    echo one > /tmp/a.txt ["echo one > /tmp/a.txt"] yes
    echo one; echo two > /tmp/a.txt ["echo one", "echo two > /tmp/a.txt"] yes
    echo one && echo two > /tmp/a.txt ["echo one", "echo two"] no
    echo one > /tmp/a.txt && echo two > /tmp/b.txt ["echo one > /tmp/a.txt", "echo two"] first only
    echo one && echo two 2>&1 ["echo one", "echo two"] no

    So the shape to test is not "is it compound" but "is the redirected command inside an &&/||/| chain, and is it not the first command".

    Cause

    packages/core/src/shell/parse.ts:195:

    resource: (node.parent?.type === "redirected_statement" ? node.parent.text : node.text).trim(),

    It tests the immediate parent of the command node. In an && chain, tree-sitter interposes a list (or pipeline) node, so the redirected_statement is the grandparent. Read off the parse tree with the same web-tree-sitter grammar the scanner loads:

    echo one && echo two > /tmp/a.txt
    
    command("echo one")  > list > redirected_statement > program
    command("echo two")  > list > redirected_statement > program
                                              ^^^^^^^^^^^^^^^ one level above the check
    
    echo one > /tmp/a.txt && echo two > /tmp/b.txt
    
    command("echo one")  > redirected_statement > list > redirected_statement > program   -> taken
    command("echo two")  > list > redirected_statement > program                          -> not taken
    

    Same tree, same line, two different answers, decided only by whether a list node sits in between. There is no branch for that case.

    With ;, each statement is its own redirected_statement, so the immediate parent matches and it works.

    On a fix

    The naive "walk up to the nearest redirected_statement" is safe but degrades: in a multi-command chain that ancestor's text spans all of them, so every command would get the same resource string. Two shapes that keep per-command granularity:

    • take the enclosing redirected_statement only when the command is its redirected (last) descendant;
    • or read the redirection from the command's own subtree instead of an ancestor's text, which drops the dependence on node shape entirely.

    Either way these two should agree:

    echo one && echo two > /tmp/a.txt
    echo one;  echo two > /tmp/a.txt

    Untested

    scanPortable (behind experimental.portable_shell_scanner) was not verified. I make no claim about it.

    Environment: @opencode/core V2, ARM64, Linux, default scanLegacy path.

  4. liaochzh commented on Oct 23, 2026

    @liaochzh

    Following up on #52083 with the deny-side impact of the same scanner behaviour, a root-cause pointer, and a cross-tier reproduction on v2.1.2.

    Summary

    When a shell command is chained with &&, || or |, the trailing redirection is dropped from the permission resources that are evaluated, so a deny rule that targets the redirection (*>*, * > *, *|*) never matches. A hardening deny family that works for a standalone command silently stops working as soon as the same command is chained — and an already-allowed command can then write to arbitrary writable paths.

    Environment

    • opencode version: v2.1.2
    • OS: macOS 27.0.0 (darwin arm64)
    • Terminal: TERM=xterm-256color (TERM_PROGRAM / COLORTERM unset)
    • Shell: /bin/zsh
    • Install/channel: latest (binary at ~/.opencode/bin/opencode)
    • Active plugins: none found in config (no plugin key in ~/.config/opencode/opencode.jsonc, no ~/.config/opencode/plugin/ or plugins/ directory)

    Reproduction

    1. Configure a shell deny rule that targets redirection, e.g. in ~/.config/opencode/opencode.jsonc:
      { "action": "shell", "resource": "*>*", "effect": "deny" }
    2. Standalone command — rejected as expected:
      cat /etc/hosts > /tmp/probe.txt
    3. Same command, only chained with && — allowed, and the file is written:
      cd /tmp && cat /etc/hosts > /tmp/probe.txt
    4. Same first segment and same second command, but ; as the separator — rejected again:
      cd /tmp; cat /etc/hosts > /tmp/probe.txt
      Steps 3 and 4 differ only in the separator, so the separator is the variable, not the command.
    5. With a pipeline instead — allowed, and the file is written:
      ls /tmp | cat > /tmp/probe.txt
    6. With || instead of && — allowed, and the file is written:
      ls -la /path/to/worktree/.nonexistent-probe-dir || echo HELLO > /tmp/probe.txt
      The first segment points at a deliberately non-existent directory, so the second segment — and its redirection — really runs and the write becomes observable. (/path/to/worktree = your project root; a relative name such as .nonexistent-probe-dir works as well.)
    7. The same || chain, but with a first segment that succeeds — still allowed, although the second segment never runs:
      ls -la /path/to/worktree || echo HELLO > /tmp/probe2.txt
      Step 7 shows that the verdict comes from the static scan, not from what the shell actually executes.

    Expected Behavior

    Every segment of a compound command should be evaluated with the same rules as a standalone command — i.e. the redirection target of a trailing redirect should be part of the permission resources that are evaluated (per segment), so that *>* / * > * / *|* families keep denying inside compound commands.

    Actual Behavior

    • Standalone: rejected. Chained with &&, || or |: allowed, and the file is written. With ; as the separator: rejected again.
    • The verdict is decided while scanning, not while running: step 7 is allowed even though its redirection never executes.
    • Scope note: this is not "deny rules are ignored in compound commands". Command-word deny rules still apply inside a chain — ls /tmp && rm -rf /tmp/x is rejected. Only the redirection/pipeline-shaped deny families become unreachable.

    Root cause in v2.1.2:

    • packages/core/src/tool/plugin/shell.ts (the permission.assert call) evaluates permission with
      resources: parsed.commands.map((command) => command.resource) — the resources are the per-command resource strings produced by the shell scanner.
    • packages/core/src/shell/scan.ts states: "Trailing redirects wrap a whole list/pipeline in the legacy grammar, not its last command." The scanner therefore strips the redirect words from the last command (redirectWordCount), so that command's resource no longer contains > target.
    • packages/core/src/permission.ts decides deny by matching that resource text (denied() → resources.some((resource) => evaluate(action, resource, rules).effect === "deny")).

    ⇒ A trailing redirect belongs to the list, not to a command, so it never enters any command's resource and can never match a *>*-style rule.
    ⇒ && and || are both AND-OR lists in that grammar, so both take the list-level path; ; splits the line into separate commands, so the trailing redirect stays attached to the last one and is evaluated normally.

    Additional Context

    • Reproduced on v2.1.2 (latest) in two different agent tiers whose deny families use different shapes (*>* versus * > * / * >> *) — identical outcome in both, so this is engine-level rather than config-level.
    • The || case was measured separately (v2.1.2, tier with the * > * / * >> * deny family, echo as the payload): standalone redirect → rejected; ; → rejected; && → allowed and the file written; || → allowed and the file written; || with a succeeding first segment → allowed, no file written. The sentinel files were 6 bytes (HELLO + newline).
    • Blast radius. Not arbitrary command execution: commands that are not allowed are still rejected (touch <file> and cd /tmp && touch <file> are both rejected). It is "already-allowed commands can produce arbitrary side effects": cat / head / tail / grep are typically allowed, so they can overwrite arbitrary writable files once chained.
    • Suggested fixes (any one of these):
      (a) have the scanner emit an extra permission resource that includes the trailing redirect text for the last command;
      (b) emit the list-level redirect as its own shell permission resource;
      (c) document that shell permission evaluation does not cover trailing redirects, and route write protection through the edit / write tool face.
    • Workaround on the user side: do not rely on standalone-shaped redirection deny rules; keep commands with redirection capability (cat, head, tail, grep) out of allow lists if you rely on them as write protection.
    • Related: permissions: compound shell commands drop redirection, so an allowed command becomes an unprompted file write #52083 reports the same underlying scanner behaviour from the "allowed command becomes an unprompted file write" angle. This report adds (i) the deny-side impact — hardening deny families for redirection are structurally unreachable, (ii) the root-cause pointers into the scanner and the permission layer, (iii) the ; vs && contrast, and (iv) cross-tier reproduction on v2.1.2, including a separately measured || case.
  5. opencodejr commented on Oct 28, 2026

    @opencodejr

    Thanks for the detailed report. I could reproduce this on 2.0.18, and it still happens on the latest dev (86ce19d).

    I used a simulated model with your config (shell * → ask, shell pwd → allow):

    • pwd > probe-a.txt shows "Permission required", as expected.
    • pwd && pwd > probe-b.txt runs straight away with no prompt.

    Calling the shell scanner directly shows why. Both scanner paths (tree-sitter and portable) give the same permission resources:

    • pwd && pwd > p.txt → pwd, pwd (redirect dropped)
    • pwd | cat > p.txt → pwd, cat (redirect dropped)
    • pwd; pwd > p.txt → pwd, pwd > p.txt (redirect kept)

    So for the separators you hadn't tested yet: | is affected the same way as &&, while ; keeps the redirect. The recording and screenshot below show the compound command running without a prompt and the standalone one asking, and the script below reproduces it. Related: #42184 (earlier shell permission scanning work).

    recording.mp4

    Screenshot

    Reproduction script (drive.ts)
    // Repro for anomalyco/opencode#52083: with only `pwd` allowed for the shell tool,
    // `pwd > probe-a.txt` asks for permission, but `pwd && pwd > probe-b.txt` runs
    // (and writes probe-b.txt) without any permission prompt.
    //
    //   OPENCODE_DRIVE_MEDIA_DIR=/workspace/media opencode-drive run ./drive.ts           # opencode on PATH
    //   OC_DEV=/path/to/opencode OPENCODE_DRIVE_MEDIA_DIR=... opencode-drive run ./drive.ts  # from source
    import { Effect, Stream } from "effect"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    
    const lastMessage = (body: any) => {
      const messages = Array.isArray(body?.messages) ? body.messages : []
      return messages[messages.length - 1]
    }
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        tui: { recording: true },
        project: { git: true, files: { "README.md": "probe\n" } },
        config: {
          permissions: [
            { action: "shell", resource: "*", effect: "ask" },
            { action: "shell", resource: "pwd", effect: "allow" },
          ],
        },
      } as any,
      ({ ui, llm }) =>
        Effect.gen(function* () {
          // Route responses by request content so title-generation requests can't steal the tool call.
          yield* llm.serve((request: any) => {
            const body = request?.body ?? request
            if (!Array.isArray(body?.tools) || body.tools.length === 0) return Stream.make(Llm.text("Probe"))
            const last = lastMessage(body)
            const text = JSON.stringify(last ?? "")
            if (last?.role === "user" && text.includes("pwd && pwd > probe-b.txt"))
              return Stream.make(
                Llm.toolCall({ index: 0, id: "call_b", name: "shell", input: { command: "pwd && pwd > probe-b.txt", description: "compound redirect" } }),
                Llm.finish("tool-calls"),
              )
            if (last?.role === "user" && text.includes("pwd > probe-a.txt"))
              return Stream.make(
                Llm.toolCall({ index: 0, id: "call_a", name: "shell", input: { command: "pwd > probe-a.txt", description: "standalone redirect" } }),
                Llm.finish("tool-calls"),
              )
            return Stream.make(Llm.text("TOOL FINISHED"))
          })
    
          // 1. Compound command with a redirect: expected to ask, actually runs unprompted.
          yield* ui.submit("Run: pwd && pwd > probe-b.txt")
          yield* Effect.sleep("8 seconds")
          yield* ui.screenshot("compound")
    
          // 2. Standalone redirect: correctly asks for permission.
          yield* ui.submit("Run: pwd > probe-a.txt")
          yield* Effect.sleep("8 seconds")
          yield* ui.screenshot("standalone")
        }),
    )

    Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions