Repository navigation
Description
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.txtThe 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_statementonly 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'stext, 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.txtImpact 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/coreV2, ARM64, Linux- measured against
ShellParse.scan(command, "bash", cwd)directly, default
scanLegacypath scanPortable(behindexperimental.portable_shell_scanner) was not
verified; this report makes no claim about it
Activity
github-actions commentedon Sep 30, 2026
This issue does not fully meet our contributing guidelines.
What needs to be fixed:
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:
If you believe this was flagged incorrectly, please let a maintainer know.
abelenko commentedon Sep 30, 2026
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 interveninglist/pipelinenode atparse.ts:195— are in a comment there.