Skip to content

shell tool: redirections on compound commands are dropped from resources #52360

Closed as not planned

Description

@abelenko

Shell tool: redirections on compound commands are dropped from resources

Summary

A plugin that hooks permission.hook("evaluate") receives the shell action as
one resource per command. When a command carries a redirection, that
redirection is supposed to travel with it, so the plugin can see the destination
it is about to write to.

It is dropped whenever the redirected command is part of a compound statement
— one joined by &&, ||, or |. The redirection is still executed, the file is
still written, but no plugin ever learns the destination.

The bug is not positional. My first report claimed the last statement of a chain
was affected; that is wrong, and the wrong claim is what prompted the correct
diagnosis below.

The rule, measured

packages/core/src/shell/parse.ts → ShellParse.scan(), read directly:

command resources produced 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

; chains are fine. &&, || and | chains lose the redirection. In a chain of
three, only the first command keeps its own.

Reproduction

Agent runs the bash tool with exactly:

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

The file is created (4 bytes, two\n). The plugin sees:

resources = ["echo one", "echo two"]

Expected:

resources = ["echo one", "echo two > /tmp/a.txt"]

The control case, to show it is the composition and not the position:

echo one; echo two > /tmp/a.txt
# resources = ["echo one", "echo two > /tmp/a.txt"]   (destination present)

A real-world shape that matters more than echo:

cat ~/.ssh/id_rsa && echo done > /tmp/ok.txt
# resources = ["cat ~/.ssh/id_rsa", "echo done"]

The read is visible; the write destination is not.

Cause

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

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

This reads the immediate parent of the command node. For a simple statement
that parent is the redirected_statement, so it works. In a compound statement
tree-sitter puts a list or pipeline node between them, and the
redirected_statement is the grandparent:

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

command("echo one")  > list > redirected_statement > program
command("echo two")  > list > redirected_statement > program
                                          ^^^^^^^^^^^^^^^ one level too far up for the check

and, when only some commands are redirected:

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

command("echo one")  > redirected_statement > list > redirected_statement > program   → YES
command("echo two")  > list > redirected_statement > program                          → no

Same tree, same line, two different answers, decided purely by whether a list
node happens to sit in between. parse.ts:195 has no branch for that case.

This was read off the parse tree with the same web-tree-sitter grammar the
scanner loads, not inferred from the observed output.

Suggested direction

The constraint worth preserving: with more than one command in the compound, the
enclosing redirected_statement text spans all of them. So naively walking up
to the nearest redirected_statement would hand every command in the chain the
same resource string — the entire chain. That is safe for a permission check but
degrades the per-command granularity the current code provides.

Two shapes that satisfy both:

  • take the enclosing redirected_statement only when the command is its
    redirected (last) descendant — keeps one resource per command and attributes the
    redirect to the command that owns it;
  • or read the redirection from the command's own subtree instead of from an
    ancestor's text, which removes 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

Impact beyond plugins

save is built from the command's own tokens (prefix(tokens).join(" ") + " *")
and never contained the destination, so opencode's own approval prompt is equally
unable to show it. A plugin-based guardrail cannot compensate either, because the
information does not reach the plugin.

Environment

  • @opencode/core V2, ARM64, Linux
  • measured against ShellParse.scan(command, "bash", cwd) directly, default
    scanLegacy path
  • scanPortable (behind experimental.portable_shell_scanner) was not
    verified; this report makes no claim about it

Activity

  1. github-actions commented on Sep 30, 2026

    @github-actions
    Contributor

    This issue does not fully meet our contributing guidelines.

    What needs to be fixed:

    • Please use the Bug Report issue template and include its required Description field with real content. The current report has useful details, but uses custom headings and does not include that required field.

    Please edit this issue to address the above within 2 hours, or it will be automatically closed.


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

    • #52083: compound shell commands drop redirection from permission resources, allowing a redirected write to proceed without approval.

    If you believe this was flagged incorrectly, please let a maintainer know.

  2. abelenko commented on Sep 30, 2026

    @abelenko
    Author

    Duplicate of #52083, which reports the same defect and was filed a day earlier.

    Closing this rather than reformatting it to satisfy the template, since the useful
    content is better placed on the existing report. The root-cause diagnosis and the
    sharper statement of the rule — ; is unaffected, only the first command of an
    &&/||/| chain keeps its redirect, and the cause is an intervening list /
    pipeline node at parse.ts:195 — are in a comment there.

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

Metadata

Metadata

Assignees

Labels

needs:complianceThis means the issue will auto-close after 2 hours.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions