Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
b2b5077
fix(core): treat a word-initial # as a comment when splitting shell c…
yiliang114 Sep 14, 2026
53fd793
fix(core): do not start a comment after an escaped boundary
yiliang114 Sep 14, 2026
9a977d8
fix(core): only treat IFS whitespace as a comment word boundary
yiliang114 Sep 14, 2026
f3cff68
Merge origin/main into fix/issue-11815-shell-comment-splitting
yiliang114 Sep 14, 2026
50459ae
fix(core): keep a # literal where bash does not tokenize a comment
yiliang114 Sep 14, 2026
84bd8ee
fix(core): scope comment detection to substituted bodies (#11821)
yiliang114 Sep 14, 2026
f8f5fef
fix(core): keep an arithmetic `)` out of the substitution depth
yiliang114 Sep 14, 2026
9023b45
Merge branch 'main' into fix/issue-11815-shell-comment-splitting
wenshao Sep 15, 2026
20d53ca
test(core): pin that a process substitution is not a substituted body
yiliang114 Sep 15, 2026
5cdf8ca
fix(core): strip heredoc bodies on Bash-rule paths and drop comment-o…
yiliang114 Sep 15, 2026
5eb2dbe
style(core): satisfy the Prettier gate in permission-manager.test.ts
yiliang114 Sep 16, 2026
93fe54e
fix(core): keep Bash-rule evaluation from failing open on collapsed s…
yiliang114 Sep 16, 2026
4d1eb20
fix(core): keep the comment scan honest about continuations and neste…
yiliang114 Sep 16, 2026
7098e33
fix(core): refuse the heredoc projection when the marker line is cont…
yiliang114 Sep 16, 2026
9895c2a
Merge origin/main into fix/issue-11815-shell-comment-splitting
yiliang114 Sep 16, 2026
6492faf
fix(core): keep a body cd and an odd backslash run from hiding a deny
yiliang114 Sep 16, 2026
93a4101
fix(core): keep the expansions bash runs in an unquoted heredoc body
yiliang114 Sep 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 200 additions & 0 deletions packages/core/src/permissions/permission-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,147 @@ describe('splitCompoundCommand', () => {
expect(splitCompoundCommand(command)).toEqual(['echo hi']);
},
);

// A word-initial `#` outside quotes opens a comment that runs to the end of
// the physical line, so every operator inside it is inert. Confirmed against
// bash: each of these traces a single `+ echo a`, and
// `bash -xc "echo 'a' # comment ; echo B"` traces a single `+ echo a`.
//
// The `'a\'` rows passed before the comment state existed too, but only by
// accident — the backslash inside the quotes was read as an escape that
// swallowed the closing `'`, so the scanner sat in an unterminated string
// that masked the operator. They keep passing once #11765 makes that
// backslash literal, because the comment then masks the operator for the
// right reason.
it.each([
[`echo 'a' # comment ; echo B`],
[`echo 'a\\' # note: use ; carefully`],
[`echo 'a\\' # trailing && touch /tmp/x`],
[`echo 'a\\' # trailing | touch /tmp/x`],
['echo hi # nothing here'],
[`git status # don't ; echo B`],
['echo a # c && rm -rf /tmp/x'],
['echo a # c || rm -rf /tmp/x'],
['echo a # c | rm -rf /tmp/x'],
['echo a # c & rm -rf /tmp/x'],
])(
'does not split %s, where the operator is inside a comment',
async (command) => {
expect(splitCompoundCommand(command)).toEqual([command]);
},
);

// Over-correction guard: `#` starts a comment only at the start of a word, so
// bash really does run two commands here.
it('still splits echo a#b ; echo B, where # is not at a word start', async () => {
expect(splitCompoundCommand('echo a#b ; echo B')).toEqual([
'echo a#b',
'echo B',
]);
});

// Same guard for the whitespace side. bash's word separators are the three
// default `IFS` characters — space, tab, newline — and not JavaScript's `\s`,
// which also matches `\r`, `\v`, `\f` and `\u00a0`. After any of those four the
// `#` is mid-word and literal, so the `;` stays a real boundary and bash runs
// both commands: `bash -xc "$(printf 'echo a\r#c ; echo DANGER')"` traces
// `+ echo $'a\r#c'` and then `+ echo DANGER`. Reading the `#` as a comment
// folded the line into one segment, so the second command lost its rule check.
it.each([
[
'a carriage return',
'echo a\r#c ; echo DANGER',
['echo a\r#c', 'echo DANGER'],
],
[
'a vertical tab',
'echo a\v#c ; rm -rf /tmp/x',
['echo a\v#c', 'rm -rf /tmp/x'],
],
[
'a form feed',
'echo a\f#c ; rm -rf /tmp/x',
['echo a\f#c', 'rm -rf /tmp/x'],
],
[
'a no-break space',
'echo a\u00a0#c ; rm -rf /tmp/x',
['echo a\u00a0#c', 'rm -rf /tmp/x'],
],
])(
'still splits after %s, which does not end a bash word',
async (_separator, command, expected) => {
expect(splitCompoundCommand(command)).toEqual(expected);
},
);

// An escaped boundary does not end a word: `bash -xc 'echo a\ #b ; echo B'`
// traces `+ echo a #b` then `+ echo B` — the `#` is mid-word and literal.
// Reading it as a comment would swallow the `;` boundary and drop the second
// command's rule check.
it('does not read a comment after an escaped space', async () => {
expect(splitCompoundCommand('echo a\\ #b ; rm -rf /tmp/x')).toEqual([
'echo a\\ #b',
'rm -rf /tmp/x',
]);
});

it('does not read a comment after an escaped operator', async () => {
expect(splitCompoundCommand('echo a\\;#c ; rm -rf /tmp/x')).toEqual([
'echo a\\;#c',
'rm -rf /tmp/x',
]);
});

// A word starts after an operator as well as after whitespace:
// `bash -xc "echo a;#c ; rm -rf /tmp/x"` traces a single `+ echo a`. The
// leftover `#c …` segment is pre-existing behaviour — bash runs one command
// here, not two — and is harmless in the fail-closed direction.
it('reads a # straight after an operator as a comment', async () => {
expect(splitCompoundCommand('echo a;#c ; rm -rf /tmp/x')).toEqual([
'echo a',
'#c ; rm -rf /tmp/x',
]);
});

// Inside quotes a `#` is literal, so the operator after the closing quote is
// a real boundary.
it('does not treat a quoted # as a comment', async () => {
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
expect(splitCompoundCommand(`echo '# c' ; rm -rf /tmp/x`)).toEqual([
`echo '# c'`,
'rm -rf /tmp/x',
]);
});

// `#` at index 0 is at a word start, so a shebang line is a comment and the
// newline after it still bounds the real command.
it('treats a leading # as a comment', async () => {
expect(splitCompoundCommand('#!/bin/sh\necho hi')).toEqual([
'#!/bin/sh',
'echo hi',
]);
});

// The comment ends at the *physical* newline, and that newline stays a
// boundary: `bash -xc $'echo hi # c\nrm -rf /tmp/x'` traces both commands, so
// the `rm` must keep its own segment and its own rule check.
it('keeps the newline that ends a comment as a boundary', async () => {
expect(splitCompoundCommand('echo hi # c\nrm -rf /tmp/x')).toEqual([
'echo hi # c',
'rm -rf /tmp/x',
]);
});

// …and a backslash does not reach past that newline. bash does not continue a
// line inside a comment, so it runs the `rm` here; letting the existing
// `escaped` flag find the newline folded both commands into one segment and
// the `rm` lost its rule check.
it('does not let a trailing backslash extend a comment', async () => {
expect(splitCompoundCommand('echo hi # foo \\\nrm -rf /tmp/x')).toEqual([
'echo hi # foo \\',
'rm -rf /tmp/x',
]);
});
});

// ─── splitCompoundCommandSegments ────────────────────────────────────────────
Expand Down Expand Up @@ -2396,6 +2537,65 @@ describe('PermissionManager', () => {
'deny',
);
});

// #11815 — bash runs only the `echo` here, and `Bash(echo *)` covers it, so
// the commented-out tail must not drag the whole line into a prompt.
it('operator inside a comment: treated as single command', async () => {
pm = new PermissionManager(
makeConfig({
permissionsAllow: ['Bash(echo *)'],
}),
);
pm.initialize();
expect(
await pm.evaluate({
toolName: 'run_shell_command',
command: `echo 'a' # comment ; rm -rf /tmp/x`,
}),
).toBe('allow');
});

// The comment stops at the physical newline and bash runs the `rm` on the
// next line, so the backslash before that newline must not extend the
// comment over it and cost the `rm` its deny check.
it('command after a comment line: deny still fires', async () => {
pm = new PermissionManager(
makeConfig({
permissionsDeny: ['Bash(rm *)'],
}),
);
pm.initialize();
expect(
await pm.evaluate({
toolName: 'run_shell_command',
command: 'echo hi # foo \\\nrm -rf /tmp/x',
}),
).toBe('deny');
});

// Only `walkCompoundCommand` strips heredoc bodies; the Bash-rule paths
// split the raw command, so this `#` line reaches the comment state. bash
// hands that line to `cat` as data and never runs the `rm` —
// `bash -xc $'cat <<EOF\n# hi ; rm -rf /\nEOF'` traces only `+ cat` — so the
// deny that fired before this change was a false positive on text that is
// never executed. Making these paths heredoc-aware is #9417's job, not
// this change's.
it('heredoc body comment line: no deny on text bash never runs', async () => {
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
pm = new PermissionManager(
makeConfig({
permissionsDeny: ['Bash(rm *)'],
}),
);
pm.initialize();
// `cat` with a heredoc has no rule hitting it; the rm text is data for
// cat, not a command. Name the verdict rather than only ruling deny out.
expect(
await pm.evaluate({
toolName: 'run_shell_command',
command: 'cat <<EOF\n# hi ; rm -rf /\nEOF',
}),
).toBe('ask');
});
});

describe('file path evaluation', () => {
Expand Down
64 changes: 64 additions & 0 deletions packages/core/src/permissions/rule-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -863,6 +863,51 @@ function isAsyncOperator(command: string, index: number): boolean {
return true;
}

/**
* Characters that end a bash word, so a `#` after one of them starts a word and
* so a comment: the three default `IFS` whitespace characters plus the operators
* that separate commands. The start of the input is handled by
* {@link isCommentStart} itself.
*
* The whitespace half is an explicit list rather than `\s`. JavaScript's `\s`
* also matches `\r`, `\v`, `\f` and `\u00a0`, and bash treats none of those as
* word separators: `bash -xc "$(printf 'echo a\r#c ; echo DANGER')"` traces two
* commands, because the `#` is mid-word and literal while the `;` after it is a
* real boundary. Reading that `#` as a comment folded the line into one segment,
* so the command bash ran second never got its own rule check.
*
* Confirmed against bash for the operators: `echo a;#c` and `echo a&#c` each run
* one command, and `echo a|#c` is a syntax error because the `#` comments out
* the right-hand side of the pipe. Redirection operators and `(` are
* deliberately absent: bash starts a word there too, but leaving them literal
* only over-splits, which stays fail-closed, and no reported shape needs them.
*/
const COMMENT_WORD_BOUNDARIES = [' ', '\t', '\n', ';', '&', '|'];

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Verified the mechanical half of this claim against the code at f3cff68906: the comment branch is entered unconditionally, and grep -rE 'getShellConfiguration|ShellType|isWindows|process.platform|win32' packages/core/src/permissions still hits only test files, so the bash assumption does reach every shell. echo hi # c & del /f /q x is one folded segment there, so an allow on Bash(echo *) does cover a Bash(del *) deny for a cmd.exe user.

I am not fixing this in this PR, and I am not resolving this thread either, because the fix as described is wider than the PR: the flag has to be threaded through splitCompoundCommand / splitCompoundCommandSegments (permissions/rule-parser.ts), extractShellOperationsAcrossCommand / walkCompoundCommand (permissions/shell-semantics.ts) and their callers in permission-manager.ts (four sites) and autoMode.ts - and gating only the comment state would still leave the splitter bash-shaped for cmd in exactly the ways your own sweep measured (; over-splits for cmd at the merge base too). So the open question is a design one, not a missing guard: which owner holds shell attribution for a splitter that is deliberately shell-agnostic. Recorded with your witness in #11882.

Leaving this open for a maintainer call rather than half-gating one of the two consumers.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Not taking this one inside this PR — asking for a ruling instead, because the fix is the design question this repo already tracks rather than a local patch.

The rule is documented as bash's own at rule-parser.ts (COMMENT_WORD_BOUNDARIES), and that comment records this exact platform gap and the deferral: "the rule is applied on every platform although cmd.exe has no # comment character. Converging the two splitters, and deciding how a shell-agnostic splitter learns the reported shell, is tracked in #11882."

The measurement is not in dispute: folding echo hi # c & del /f /q x into a single segment on a cmd shell is a real fail-open, and your gate keying on getShellConfiguration().shell === 'bash' rather than on the OS is the right key (Git Bash reports bash, and a Windows gate would regress #11815 for those users).

What a closeout pass cannot settle is which of the two designs #11882 should land: an explicit { comments?: boolean } on both splitCompoundCommandSegments and splitCompoundCommand, threaded through every permission caller, or a shell-derived default read inside the splitter — which changes the behaviour of an exported function for every caller, including the non-permission ones, and is precisely why the explicit-option form exists. That decision moves a security-relevant boundary on Windows for both the permission path and the shell-confirmation path, so it wants a maintainer ruling rather than a worker guess. Asking for it here; the head keeps the comment state unconditional, as it was when this was filed in round 1.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Re-verified at f8f5fefc6a (the commit that landed after my last reply, "keep an arithmetic ) out of the substitution depth"). The measurement is unchanged and the comment state is still unconditional, so I am leaving this thread unresolved.

Witness, running the real splitCompoundCommand from both trees plus PermissionManager.evaluate (merge base 87437db784 vs this head):

base segs  ["echo hi # c","del /f /q x"]
head segs  ["echo hi # c & del /f /q x"]
evaluate allow Bash(echo *) + deny Bash(del *) = allow   (base: deny)
evaluate deny-only Bash(del *)                 = ask     (base: deny)
control "echo hi & del /f /q x" head segs = ["echo hi","del /f /q x"] verdict = deny

The path is intact at this head: permission-manager.ts:336 (also :956, :1112, :1210) takes the single-segment branch into evaluateSingle over the whole string, and shellExecutionService.ts:792-806 spawns getShellConfiguration()'s executable with windowsVerbatimArguments: isWindows && shell === 'cmd', so the same string reaches cmd.exe verbatim. grep -rnE 'getShellConfiguration|ShellType|isWindows|process\.platform|win32|ComSpec' packages/core/src/permissions/*.ts (non-test) still returns zero hits. cmd.exe itself was not executed on this Linux runner — its tokenization remains inferred from the verbatim spawn path and this repo's own cmd metacharacter guidance, as in your round-2 note.

Why this pass does not fix it locally. Nothing but the command string reaches the splitter today (rule-parser.ts:944 splitCompoundCommandSegments(command: string), :1145 splitCompoundCommand(command: string)), so a shell-aware comment state has only two shapes:

  1. Your explicit { comments?: boolean }, threaded through rule-parser.ts, all four permission-manager.ts sites and shell-semantics.ts:2266, plus both test files — five files, past the three-file ceiling this closeout pass works under. Gating only permission-manager.ts would fit the ceiling but would leave the two consumers of one splitter disagreeing on cmd, which is a new divergence rather than a fix.
  2. Reading ambient getShellConfiguration() inside rule-parser.ts. That fits the ceiling file-wise, but it changes the behaviour of an exported, deliberately shell-agnostic function for every caller based on process environment, and it makes this PR's bash-shaped rows machine-dependent (there is a Test (windows-latest, Node 22.x) lane at ci.yml:1653). That is exactly one of the two designs shell comment semantics: gate on the reported shell, and converge the two compound-command splitters #11882 exists to choose between, so shipping it here would pre-empt the ruling rather than implement it.

One correction to my earlier reply: every call site is in packages/core (permission-manager.ts ×4, shell-semantics.ts ×1), not across two packages — the scope problem is the file count and the design fork, not a package boundary.

#11882 is open (priority/P1, type/bug, category/security, need-discussion) and its section 2 is this fail-open with the same witness and the same threading requirement. The ask is unchanged: either a maintainer ruling on which design lands there (or that this PR should carry the explicit-option form as an approved workaround), or a recorded acceptance of the deferral. I am not resolving a fail-open Critical on my own scope call.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Not fixed, and I am leaving this open — the divergence is real but the fix is the architectural one this very diff already documents and defers.

The COMMENT_WORD_BOUNDARIES doc comment added by this PR says it plainly: the sibling splitCommands in ../utils/shell-utils.ts has no # state at all and disagrees on inputs like echo hi # ; rm -rf /tmp/x, and "the rule is applied on every platform although cmd.exe has no # comment character. Converging the two splitters, and deciding how a shell-agnostic splitter learns the reported shell, is tracked in #11882." That issue is OPEN with need-discussion, priority/P1, category/security, scope/shell.

Making packages/core/src/permissions/ shell-aware means threading the reported shell (or the tool's spawn decision) into splitCompoundCommand and all four of its consumers, and settling what a shell-agnostic splitter should do when the shell is unknown. That is an architecture and public-surface change well past this PR's promise, and it would visibly expand the diff — so it belongs to #11882, not to a closeout pass here.

Baseline measured at 4d1eb20 so the next round does not have to re-derive it: your witness is unchanged by the two fixes I landed — echo hi # c & del /f /q C:\Users\me\notes.txt with allow ['Bash(echo *)'] + deny ['Bash(del *)'] still folds to one segment and evaluates to allow.

One gap in my own evidence, stated rather than papered over: this round's oracle was GNU bash 4.2.46 on Linux. I did not verify the cmd.exe leg on a Windows host, so I am not disputing your trace — only declining to redesign the shell-awareness boundary in this PR.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Verdict unchanged, no code this round — see my 2026-09-16T04:19:27Z reply on this thread rather than re-arguing it; nothing in it has moved.

What this round changed: nothing that touches the comment predicate. 93a4101 is confined to the heredoc projection (stripHeredocBodies / getHeredocDelimiters in shell-semantics.ts) and to which text evaluateCompoundCommand hands the AST classifier; COMMENT_WORD_BOUNDARIES and the word-initial-# rule in rule-parser.ts are byte-identical. Your re-measurement of echo hi # c & del /q C:\temp\x still reproduces on this head for the same reason it did before.

Still a maintainer call, still deferred to #11882 (OPEN, need-discussion, priority/P1, category/security, scope/shell): making packages/core/src/permissions/ shell-aware means threading the reported shell into splitCompoundCommand and all four of its consumers and settling what a shell-agnostic splitter does when the shell is unknown. Leaving this unresolved.


/**
* Whether the `#` at `index` opens a comment rather than being a literal.
*
* bash only starts a comment at the start of a word, so `echo a#b` prints
* `a#b` while `echo a #b` prints `a`. The same word rule keeps the arithmetic
* base prefix in `echo $(( 8#17 ))` literal.
*
* The boundary check is escape-aware: a backslash-escaped space or operator
* does not end a word, so the `#` after `a\ ` or `a\;` is mid-word and
* literal — treating it as a comment would swallow the rest of the line and
* merge a real `;` boundary away, dropping the rule check the following
* command would have got.
*/
function isCommentStart(command: string, index: number): boolean {
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
if (index === 0) {
return true;
}
const previous = command[index - 1]!;
return (
COMMENT_WORD_BOUNDARIES.includes(previous) &&
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
precedingBackslashCount(command, index - 1) % 2 === 0
);
}

/**
* One segment of a compound command, together with the operator that ended it.
*/
Expand Down Expand Up @@ -919,6 +964,24 @@ export function splitCompoundCommandSegments(
continue;
}

// A word-initial `#` opens a comment that runs to the end of the physical
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
// line, so an operator inside it is not a boundary: bash runs
// `echo 'a' # comment ; echo B` as a single `echo` (#11815).
//
// This scan deliberately ignores `escaped`. A backslash does not continue a
// line inside a comment — bash really does run the `rm` in `echo hi # foo \`
// followed by a newline and `rm -rf /` — so honouring the escape would fold
// that second command into the comment's segment and cost it its own rule
// check. For the same reason the newline is left for the operator scan
// below, and stays a boundary.
if (ch === '#' && isCommentStart(command, i)) {
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
while (i < command.length && command[i] !== '\n') {
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
i++;
}
i--; // -1 because the loop will i++, landing back on the newline
continue;
}

if (ch === '(' && command[i + 1] === '(') {
arithmeticDepth++;
i++;
Expand Down Expand Up @@ -971,6 +1034,7 @@ export function splitCompoundCommandSegments(
* "git status && rm -rf /" → ["git status", "rm -rf /"]
* "ls -la | grep foo" → ["ls -la", "grep foo"]
* "echo 'a && b'" → ["echo 'a && b'"] (inside quotes)
* "echo 'a' # c ; echo B" → ["echo 'a' # c ; echo B"] (inside a comment)
* "a && b || c" → ["a", "b", "c"]
* "git status & rm -rf /" → ["git status", "rm -rf /"] (async operator)
* "build &> log.txt" → ["build &> log.txt"] (redirection, not async)
Expand Down
Loading