anomalyco / anomalyco/opencode

shell: bare redirect bypasses the permission check (scanner returns no commands)

Open
#49,948 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
209k
Forks
27.5k
PR merge metrics
PR metrics pending

Description

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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in packages/core/src/tool/plugin/shell.ts and trace how ShellParse.scan results reach permission.assert; then read packages/core/src/permission.ts and the existing permission test harness. Done means bare redirects are evaluated against the raw invocation, denied under a matching shell rule, explicitly allowed when configured, and covered by tests.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
authorization, cli, security
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
76/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.