What is the issue?
A shell command that the scanner finds no commands in skips the permission check entirely and runs unchecked. A bare redirect is the clearest case: > file is valid POSIX, truncates or creates the file, and parses to zero commands.
packages/core/src/tool/plugin/shell.ts only asserts when the scan produced something:
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute, { portable })
…
if (parsed.commands.length > 0)
yield* permission.assert({ … })
So with
permission:
shell:
"*": "deny"
> important.txt still empties the file — no prompt, no denial, no permission record.
Why it happens
Two things line up:
1. Both scanners report zero commands for a bare redirect. Checked against ShellParse.scan directly, which is what the tool calls:
| command |
legacy |
portable |
| > victim.txt |
0 commands |
0 commands |
| >> victim.txt |
0 commands |
0 commands |
| echo hi > out.txt |
1 |
1 |
| rm -rf x |
1 |
1 |
Command substitutions are handled correctly — FOO=$(whoami) yields 1 — so this is specific to a redirect with no command word.
2. An empty resource list evaluates to allow. In packages/core/src/permission.ts:
const effects = input.resources.map((resource) => evaluate(input.action, resource, all).effect)
const effect = effects.includes("deny") ? "deny" : effects.includes("ask") ? "ask" : "allow"
With resources: [], effects is [], so neither branch matches and the result is allow — even under { action: "*", resource: "*", effect: "deny" }. I added an assertion for exactly that in the existing permission test harness and it passes, so the fail-open is real rather than theoretical.
The commands.length > 0 guard means the second condition is never reached from this path today, but it is the reason the guard is load-bearing.
Reproduction
printf 'important data\n' > victim.txt # 15 bytes
/bin/sh -c '> victim.txt'
wc -c < victim.txt # 0
Then the same command through the shell tool with permission.shell "*": "deny" — it is not denied.
Expected behavior
A command the scanner cannot decompose should still be evaluated, using the raw invocation as the resource, so a deny rule applies and an explicit allow can still permit it. Failing closed seems clearly right for a permission boundary.
I have a small patch and can open a PR.
What is the issue?
A shell command that the scanner finds no commands in skips the permission check entirely and runs unchecked. A bare redirect is the clearest case: > file is valid POSIX, truncates or creates the file, and parses to zero commands.
packages/core/src/tool/plugin/shell.ts only asserts when the scan produced something:
So with
> important.txt still empties the file — no prompt, no denial, no permission record.
Why it happens
Two things line up:
1. Both scanners report zero commands for a bare redirect. Checked against ShellParse.scan directly, which is what the tool calls:
Command substitutions are handled correctly — FOO=$(whoami) yields 1 — so this is specific to a redirect with no command word.
2. An empty resource list evaluates to allow. In packages/core/src/permission.ts:
With resources: [], effects is [], so neither branch matches and the result is allow — even under { action: "*", resource: "*", effect: "deny" }. I added an assertion for exactly that in the existing permission test harness and it passes, so the fail-open is real rather than theoretical.
The commands.length > 0 guard means the second condition is never reached from this path today, but it is the reason the guard is load-bearing.
Reproduction
Then the same command through the shell tool with permission.shell "*": "deny" — it is not denied.
Expected behavior
A command the scanner cannot decompose should still be evaluated, using the raw invocation as the resource, so a deny rule applies and an explicit allow can still permit it. Failing closed seems clearly right for a permission boundary.
I have a small patch and can open a PR.