Skip to content

feat(permission): parse shell commands for permission analysis - #250

Merged
hakula139 merged 3 commits into
mainfrom
feat/shell-parsing
Oct 16, 2026
Merged

hakula139 merged 3 commits into
mainfrom
feat/shell-parsing

Conversation

@hakula139

@hakula139 hakula139 commented Oct 16, 2026 •

Copy link
Copy Markdown
Owner

Summary

The bash gate used to match rules against the raw command string. An allow rule refused any command containing ;, |, >, or $(, and a deny rule saw only text split on separators, so "rm" -rf x or /bin/rm -rf x slipped past bash(rm -rf:*). The gate now parses each command with tree-sitter-bash and judges the simple commands it runs and the files it writes.

  • Parsing: the new permission::shell module builds a CommandTarget from the command. It splits it into simple commands across ;, &&, ||, &, pipes, line breaks, subshells, and compound statements. The wrappers builtin, command, env, exec, nice, nohup, time, and timeout are stripped with their options, words are unquoted, and a leading ~ expands. Output redirections (>, >>, >|, >&, &>, &>>) become edit-class path targets resolved like a path tool's, except /dev/null and the standard streams.
  • Assignments: a leading VAR=value drops out of the text a rule matches only for a short list of variables that change output alone, such as RUST_LOG, LANG, and NO_COLOR. Others, such as PATH, LD_PRELOAD, or GIT_PAGER, stay in the text and rule out the known-safe set, so GIT_PAGER=sh git log doesn't match bash(git log:*). Deny rules still see the program that runs.
  • Opaque commands: a command is opaque when the parse cannot see everything it runs or writes. That covers a parse error, command or process substitution, arithmetic expansion, [[ and (( tests, a non-literal command name, a shell (sh -c, | sh) or a code-running builtin (eval, source, ., trap, let), an unknown wrapper option, and a non-literal or glob redirect target. The list also covers assigning a variable the shell reads (PATH, HOME, IFS, ...) or exporting one outside the short list, and cd, pushd, popd, or env -C combined with a relative write or known-safe read. Deny rules still apply to an opaque command, including the simple commands inside a literal sh -c script or eval, but no rule allows it.
  • Allow: a command is allowed only when it is not opaque, each simple command matches an allow rule or a shipped known-safe read-only command, and each write sits in the workspace or matches an edit allow rule. The known-safe set is echo, false, pwd, and true, plus cat, head, ls, tail, wc, find without -exec, -delete, or a file-writing action, grep without recursion or -d, and sed without -i, -I, or -f and with a script that only prints or rewrites text. Long options match by any prefix GNU tools accept, so sed --in counts as --in-place. These need literal, glob-free arguments, and an argument a read deny rule matches makes the command ask.
  • Deny: a deny rule matches the whole command or any simple command, compared as written, unquoted, and with the command name reduced to its bare name. A write is also checked against edit deny rules (so .git/** stays denied) and asks when its target is protected.
  • Session grants: for a single simple command, Rule::for_call names it without its wrappers, redirections, and output-only assignments, so RUST_LOG=debug cargo test 2>&1 offers bash(cargo test).
  • Segment API: each Segment exposes the text a rule must name and the unquoted words() of the program that runs, so Configure permissions with per-class deny, ask, and allow tables, plus manual and auto modes #174 can match exec word patterns against segments without changing the parser. words() drops every assignment, so an allow pattern there still needs the text's kept assignments to count.
  • REPL re-ask: the REPL test for unknown answers now also feeds a typed-ahead prompt line and checks that it re-asks and never counts as an answer.
  • Docs: the permissions design doc gains a Command Targets section and updated rule-matching, grant, and headless text. The configuration and quickstart guides, the README Features line, and the module map are updated too.

Ask rules don't exist yet, so the criterion that ask rules match when any segment matches stays open for #174 to settle.

Closes #175

Test plan

  • cargo fmt --all --check and cargo clippy --all-targets -- -D warnings passed on macOS.
  • cargo test: 2668 + 4 tests passed on macOS.
  • permission::shell tests cover each separator, assignment stripping and unquoting, tilde expansion, every wrapper with its options (including exec, command, and builtin), unknown wrapper options, assignments kept in the rule text, shell-read and exported variables, each opaque form with non-opaque counterexamples, nested sh -c / eval scripts, redirect writes and skipped and unplaceable redirects, and the find, grep, and sed guards.
  • Policy::decide tests cover segment-wise allow, opaque commands that a deny rule still denies, redirect targets zoned as edits, a known-safe read of .env that asks, and the single-command session grant.
  • cargo deny check licenses bans sources passed with the new tree-sitter dependencies.
  • pnpm format, pnpm lint, and pnpm spellcheck passed.

@hakula139 hakula139 added this to the v0.1.0 milestone Oct 16, 2026
@hakula139 hakula139 added enhancement A new user-facing capability or an extension of one (feat) area: permission Touches permission rules, modes, or the approval gate area: tools Touches the built-in tools such as bash, read, edit, grep, and glob labels Oct 16, 2026
@hakula139 hakula139 self-assigned this Oct 16, 2026
@codecov

codecov Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.71070% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/oxide-code/src/permission/shell.rs 99.60% 3 Missing ⚠️

📢 Thoughts on this report? Let us know!

Bash rules matched the raw command string, so an allow rule refused any
chained or redirected command and a deny rule saw only text split on
separators. Parse the command with tree-sitter-bash into simple
commands, with leading assignments and transparent wrappers stripped,
output redirections as edit targets, and an opaque flag for anything
the parse cannot see into.

Allow now needs every simple command to match an allow rule or a
shipped known-safe read-only command, and every write to be in the
workspace or edit-allowed. Deny matches any simple command, including
those inside a literal `sh -c` script or `eval`. Opaque commands can be
denied but never allowed.

Closes #175
`exec`, `command`, and `builtin` ran the rest of their words unseen, so
`exec rm -rf /` escaped deny rules and `exec sh -c` escaped the opaque
check. They are now transparent wrappers. `env -C` counts as a
directory change, and a cd now also guards relative known-safe reads.

A leading assignment drops out of the rule text only for variables that
change output alone. Others, like PATH, LD_PRELOAD, or GIT_PAGER, stay
in the text and rule out the known-safe set. Assigning a variable the
shell reads, or exporting one outside that list, makes the command
opaque.

sed and grep guards accept GNU long-option abbreviations and BSD
`sed -I`, and sed reads count only the files past the script.
@hakula139
hakula139 merged commit f355457 into main Oct 16, 2026
16 checks passed
@hakula139
hakula139 deleted the feat/shell-parsing branch October 16, 2026 10:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: permission Touches permission rules, modes, or the approval gate area: tools Touches the built-in tools such as bash, read, edit, grep, and glob enhancement A new user-facing capability or an extension of one (feat)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Parse shell commands for permission analysis

1 participant