diff --git a/bun.lock b/bun.lock index 0a3751a1e1..8395e33805 100644 --- a/bun.lock +++ b/bun.lock @@ -99,6 +99,7 @@ "tar": "^7.5.16", "tinygradient": "^1.1.5", "tree-sitter-bash": "^0.25.0", + "tree-sitter-pwsh": "^0.38.1", "turndown": "^7.2.2", "typescript-language-server": "^4.0.0 || ^5.0.0", "undici": "^7.28.0", @@ -297,6 +298,7 @@ "strip-ansi": "^7.1.0", "tar": "^7.5.16", "tinygradient": "^1.1.5", + "tree-sitter-pwsh": "^0.38.1", "undici": "^7.28.0", "update-notifier": "^7.3.1", "wrap-ansi": "9.0.2", @@ -408,6 +410,7 @@ "simple-git": "^3.36.0", "strip-ansi": "^7.1.0", "tree-sitter-bash": "^0.25.0", + "tree-sitter-pwsh": "^0.38.1", "turndown": "^7.2.2", "undici": "^7.28.0", "vscode-jsonrpc": "^8.2.1", @@ -2888,6 +2891,8 @@ "tree-sitter-bash": ["tree-sitter-bash@0.25.1", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-7hMytuYIMoXOq24yRulgIxthE9YmggZIOHCyPTTuJcu6EU54tYD+4G39cUb28kxC6jMf/AbPfWGLQtgPTdh3xw=="], + "tree-sitter-pwsh": ["tree-sitter-pwsh@0.38.1", "", { "dependencies": { "node-addon-api": "^8.5.0", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-vf92XvOapTJkARHv2pB29yccIA0kdSTSOuuurhkxdaiKO0+FLzSUIPJwewNZKsU2RafdcJyn+ugeBf5btPROsQ=="], + "triple-beam": ["triple-beam@1.4.1", "", {}, "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg=="], "true-myth": ["true-myth@4.1.1", "", {}, "sha512-rqy30BSpxPznbbTcAcci90oZ1YR4DqvKcNXNerG5gQBU2v4jk0cygheiul5J6ExIMrgDVuanv/MkGfqZbKrNNg=="], @@ -3428,6 +3433,8 @@ "tree-sitter-bash/node-addon-api": ["node-addon-api@8.9.0", "", {}, "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q=="], + "tree-sitter-pwsh/node-addon-api": ["node-addon-api@8.9.0", "", {}, "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q=="], + "tsconfig-paths/json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], diff --git a/dev-docs/bun.md b/dev-docs/bun.md index 42d5b4ce39..a99e034c71 100644 --- a/dev-docs/bun.md +++ b/dev-docs/bun.md @@ -84,6 +84,12 @@ trusted. These are the 16 entries in `trustedDependencies`: When it was still a dependency it did not need trust: its platform binary was delivered by the separate `@esbuild/` package, which Bun installs directly without running a script. +- **`tree-sitter-pwsh`** — the shell validator loads only the package's + published `tree-sitter-powershell.wasm` with `web-tree-sitter`. Its install + script prepares the native Node binding, which the validator never imports + and which is deliberately runtime-gated out under Node because loading the + PowerShell grammar there is unstable. The published WASM does not require the + lifecycle script, so granting install-time trust would add unnecessary risk. - **`node-pty`** — not trusted because the runtime prefers `@lydell/node-pty` (see `packages/core/src/utils/getPty.ts`), whose native binary is supplied by the prebuilt `@lydell/node-pty-*` platform packages. `node-pty` is the diff --git a/docs/shell-replacement.md b/docs/shell-replacement.md index 66f9e2c46c..25dbdfb648 100644 --- a/docs/shell-replacement.md +++ b/docs/shell-replacement.md @@ -8,6 +8,30 @@ LLxprt Code controls how command substitution patterns (`$()`, `` ` ` ``, `<()`, | `all` | Allows all substitution unconditionally. Least restrictive. | | `none` | Blocks all command substitution. Most restrictive. | +## Per-Shell Parsing + +LLxprt Code selects a structural parser **matching the execution shell when +one is available** rather than applying one generic grammar to every command +(#3181): + +- **Bash** execution → `tree-sitter-bash` grammar. +- **PowerShell** execution under Bun → `tree-sitter-pwsh` grammar. +- **cmd.exe** execution → falls back to the Bash grammar (no dedicated cmd grammar exists; this is the same as pre-#3181 behavior). + +Parsing does **not** always match the execution shell: under Node, PowerShell +structural validation intentionally fails closed (the PowerShell WASM is unstable +under Node), and cmd.exe always uses the Bash legacy fallback. In these cases +validation preserves the documented fail-closed or legacy fallback behavior +instead of silently treating PowerShell as Bash. + +### PowerShell Substitution Semantics + +PowerShell substitution differs from Bash: + +- PowerShell **backticks** (`` ` ``) are escape/line-continuation characters, **not** command substitution. They are not treated as substitution in any mode. +- PowerShell **`$()`** subexpressions are substitution and follow the configured mode. +- PowerShell **`.NET` invocations** (e.g., `[System.Diagnostics.Process]::Start(...)`) are detected as expression targets. In strict allowlist mode they fail closed because they cannot be compared honestly against a command allowlist. + ## Configuring ### Session Setting @@ -41,6 +65,40 @@ In `allowlist` mode (the default), LLxprt Code uses tree-sitter to parse the com This gives you command substitution where it's safe while preventing unexpected commands from running inside substitutions. +## Runtime Compatibility + +The PowerShell grammar (`tree-sitter-pwsh`) loads under the **Bun** runtime — the shipped CLI runtime — where it is stable. Under **Node** (used by core library consumers, A2A, and other server paths), the PowerShell WASM causes a V8 out-of-memory crash at process shutdown. To prevent this, the codebase uses an `isBunRuntime()` guard so that: + +- **Bun**: Both Bash and PowerShell grammars load. PowerShell structural validation works. +- **Node**: Only the Bash grammar loads. PowerShell validation **fails closed** with a truthful diagnostic (`PowerShell command rejected because the structural parser is unavailable`). PowerShell commands are never silently accepted without validation. + +cmd.exe execution maps to the Bash grammar because no dedicated cmd grammar exists and cmd syntax is not PowerShell. This is the same behavior as before #3181 and does not make a false claim about the language. + +## Case-Insensitive Matching (PowerShell) + +PowerShell command resolution is case-insensitive. Blocklist and allowlist matching for PowerShell commands is therefore case-insensitive: `ShellTool(Get-Process)` matches `GET-PROCESS`, `get-process`, and `Get-Process`. Bash matching remains strictly case-sensitive. The case-insensitivity is PowerShell-scoped and does not affect Bash behavior. + +Literal call targets (`& 'C:/tools/tool.exe'`) and dot-source paths (`. ./script.ps1`) normalize to the basename before matching, so policy patterns do not require broad wildcards like `ShellTool(&)`. + +## Wrapper and Evaluator Bypass Prevention + +PowerShell evaluators and shell wrappers are recursively validated to prevent statically resolvable payloads from bypassing an allowlist or blocklist: + +| Construct | Literal payload | Dynamic payload | +| ---------------------------------------------- | ------------------------------------------------------ | ---------------------------------------------------- | +| `Invoke-Expression` / `iex` | Recursively parsed with PowerShell grammar | Fails closed under a strict allowlist | +| `powershell -Command` / `pwsh -Command` | Recursively parsed with PowerShell grammar | Fails closed under a strict allowlist | +| `bash -c` / `sh -c` | Recursively parsed with Bash grammar | Fails closed under a strict allowlist | +| `cmd /c` / `cmd.exe /c` | No dedicated grammar; treated as unresolved expression | Fails closed under a strict allowlist | +| `Start-Process` / `saps` / `start` | Static target extracted as command name | Dynamic target fails closed under a strict allowlist | +| Literal call-operator forms such as `& "pwsh"` | Handled like the corresponding direct wrapper | Fails closed under a strict allowlist | + +Ordinary quoted strings and static here-strings are decoded before recursive parsing. Statically resolvable nested blocklisted commands are therefore still checked when wrapped in these constructs. Dynamic payloads cannot be compared honestly with a command allowlist and are hard-denied when a strict global or session allowlist applies; an `excludeTools` blocklist alone is not a complete sandbox for dynamically generated command text. + +## Blocklist Recursion Across Modes + +Blocklist (`excludeTools`) checks recurse into all nested commands — script blocks, subexpressions, pipelines, and wrapper payloads — in every mode (`none`, `allowlist`, `all`). A blocklisted command nested inside `ForEach-Object { ... }` or `$(...)` is caught even in `all` mode, which only relaxes substitution restrictions, not blocklist enforcement. + ## Security Notes - **`none` mode** is appropriate if you're running untrusted code or want maximum safety — it blocks all substitution patterns entirely. diff --git a/package-lock.json b/package-lock.json index 2811a7253f..76beaaed82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -121,6 +121,7 @@ "tar": "^7.5.16", "tinygradient": "^1.1.5", "tree-sitter-bash": "^0.25.0", + "tree-sitter-pwsh": "^0.38.1", "turndown": "^7.2.2", "typescript-language-server": "^4.0.0 || ^5.0.0", "undici": "^7.28.0", @@ -18716,6 +18717,34 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/tree-sitter-pwsh": { + "version": "0.38.1", + "resolved": "https://registry.npmjs.org/tree-sitter-pwsh/-/tree-sitter-pwsh-0.38.1.tgz", + "integrity": "sha512-vf92XvOapTJkARHv2pB29yccIA0kdSTSOuuurhkxdaiKO0+FLzSUIPJwewNZKsU2RafdcJyn+ugeBf5btPROsQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.5.0", + "node-gyp-build": "^4.8.4" + }, + "peerDependencies": { + "tree-sitter": "^0.25.0" + }, + "peerDependenciesMeta": { + "tree-sitter": { + "optional": true + } + } + }, + "node_modules/tree-sitter-pwsh/node_modules/node-addon-api": { + "version": "8.9.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.1.tgz", + "integrity": "sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/triple-beam": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", @@ -20262,6 +20291,7 @@ "strip-ansi": "^7.1.0", "tar": "^7.5.16", "tinygradient": "^1.1.5", + "tree-sitter-pwsh": "^0.38.1", "undici": "^7.28.0", "update-notifier": "^7.3.1", "wrap-ansi": "9.0.2", @@ -20405,6 +20435,7 @@ "simple-git": "^3.36.0", "strip-ansi": "^7.1.0", "tree-sitter-bash": "^0.25.0", + "tree-sitter-pwsh": "^0.38.1", "turndown": "^7.2.2", "undici": "^7.28.0", "vscode-jsonrpc": "^8.2.1", diff --git a/package.json b/package.json index 1f64c2c36d..2cb9b52753 100644 --- a/package.json +++ b/package.json @@ -352,6 +352,7 @@ "tar": "^7.5.16", "tinygradient": "^1.1.5", "tree-sitter-bash": "^0.25.0", + "tree-sitter-pwsh": "^0.38.1", "turndown": "^7.2.2", "typescript-language-server": "^4.0.0 || ^5.0.0", "undici": "^7.28.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index 915f34d77b..4f19cc4dab 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -91,6 +91,7 @@ "strip-ansi": "^7.1.0", "tar": "^7.5.16", "tinygradient": "^1.1.5", + "tree-sitter-pwsh": "^0.38.1", "undici": "^7.28.0", "update-notifier": "^7.3.1", "wrap-ansi": "9.0.2", diff --git a/packages/cli/src/services/prompt-processors/shellProcessor.test.ts b/packages/cli/src/services/prompt-processors/shellProcessor.test.ts index 63e10f908a..717f838324 100644 --- a/packages/cli/src/services/prompt-processors/shellProcessor.test.ts +++ b/packages/cli/src/services/prompt-processors/shellProcessor.test.ts @@ -126,6 +126,7 @@ describe('ShellProcessor', () => { 'git status', expect.any(Object), context.session.sessionShellAllowlist, + expect.any(String), ); expect(mockShellExecute).toHaveBeenCalledWith( 'git status', @@ -324,11 +325,13 @@ describe('ShellProcessor', () => { 'cmd1', expect.any(Object), context.session.sessionShellAllowlist, + expect.any(String), ); expect(mockCheckCommandPermissions).toHaveBeenCalledWith( 'cmd2', expect.any(Object), context.session.sessionShellAllowlist, + expect.any(String), ); expect(mockShellExecute).toHaveBeenCalledTimes(2); expect(result).toBe('Run output1 and output2'); @@ -358,6 +361,7 @@ describe('ShellProcessor', () => { expectedCommand, expect.any(Object), context.session.sessionShellAllowlist, + expect.any(String), ); expect(mockShellExecute).toHaveBeenCalledWith( expectedCommand, @@ -397,6 +401,7 @@ describe('ShellProcessor', () => { command, expect.any(Object), context.session.sessionShellAllowlist, + getShellConfiguration().shell, ); expect(mockShellExecute).toHaveBeenCalledWith( command, @@ -627,6 +632,7 @@ describe('ShellProcessor', () => { expectedResolvedCommand, expect.any(Object), context.session.sessionShellAllowlist, + getShellConfiguration().shell, ); }); diff --git a/packages/cli/src/services/prompt-processors/shellProcessor.ts b/packages/cli/src/services/prompt-processors/shellProcessor.ts index 4237a34c10..cac8df5749 100644 --- a/packages/cli/src/services/prompt-processors/shellProcessor.ts +++ b/packages/cli/src/services/prompt-processors/shellProcessor.ts @@ -11,6 +11,7 @@ import { getShellConfiguration, ShellExecutionService, type ShellPermissionConfig, + type ShellType, } from '@vybestack/llxprt-code-core'; import type { CommandContext } from '../../ui/commands/types.js'; @@ -95,7 +96,12 @@ export class ShellProcessor implements IPromptProcessor { injections, userArgsEscaped, ); - this.checkPermissions(resolvedInjections, config, sessionShellAllowlist); + this.checkPermissions( + resolvedInjections, + config, + sessionShellAllowlist, + shell, + ); return this.executeInjections( prompt, @@ -125,6 +131,7 @@ export class ShellProcessor implements IPromptProcessor { resolvedInjections: ShellInjection[], config: ShellProcessorRuntime, sessionShellAllowlist: Set, + shell: ShellType, ): void { const commandsToConfirm = new Set(); for (const injection of resolvedInjections) { @@ -132,7 +139,7 @@ export class ShellProcessor implements IPromptProcessor { if (!command) continue; const { allAllowed, disallowedCommands, blockReason, isHardDenial } = - checkCommandPermissions(command, config, sessionShellAllowlist); + checkCommandPermissions(command, config, sessionShellAllowlist, shell); if (allAllowed !== true) { if (isHardDenial === true) { diff --git a/packages/core/package.json b/packages/core/package.json index f7ce7808e0..5c6747db6a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -611,6 +611,7 @@ "simple-git": "^3.36.0", "strip-ansi": "^7.1.0", "tree-sitter-bash": "^0.25.0", + "tree-sitter-pwsh": "^0.38.1", "turndown": "^7.2.2", "undici": "^7.28.0", "vscode-jsonrpc": "^8.2.1", diff --git a/packages/core/src/tools-adapters/CoreShellToolHostAdapter.test.ts b/packages/core/src/tools-adapters/CoreShellToolHostAdapter.test.ts index 91b5ad9b20..ae9c3e866e 100644 --- a/packages/core/src/tools-adapters/CoreShellToolHostAdapter.test.ts +++ b/packages/core/src/tools-adapters/CoreShellToolHostAdapter.test.ts @@ -13,6 +13,7 @@ import { debugLogger } from '../utils/debugLogger.js'; import type { ShellJob, ShellJobManager } from '../services/shellJobManager.js'; import { SettingsService } from '@vybestack/llxprt-code-settings'; import { ShellTool, type IToolMessageBus } from '@vybestack/llxprt-code-tools'; +import { initializeParser, isParserAvailable } from '../utils/shell-parser.js'; /** * Windows-only end-to-end coverage for the real background-job path that was @@ -109,6 +110,15 @@ function extractJobId(llm: string): string { return match[1]; } +// Ensure the PowerShell grammar is loaded so ShellTool.build() validation +// succeeds on Windows where the execution shell is PowerShell (#3181). +// Only initialize on Windows to avoid loading parsers unnecessarily on +// non-Windows CI runners. +const pwshAvailable = + os.platform() === 'win32' && + (await initializeParser()) && + isParserAvailable('powershell'); + describe.skipIf(os.platform() !== 'win32')( 'CoreShellToolHostAdapter -> real ShellJobManager (Windows end-to-end)', () => { @@ -203,3 +213,122 @@ describe.skipIf(os.platform() !== 'win32')( }); }, ); + +/** + * Finding 5 (#3181): Real adapter + ShellTool permission integration. + * + * These tests exercise the REAL CoreShellToolHostAdapter (not a fake host) + * through the REAL ShellTool.validateToolParamValues / build path. On Windows, + * getShellConfiguration().shell is 'powershell', so the adapter delegates to + * the real PowerShell parser. No parser/policy results are mocked. + * + * Coverage: + * (a) The exact issue #3181 reproduction command passes validation. + * (b) Malformed PowerShell is hard-denied by the real parser. + * (c) A blocklisted command nested in a script block is rejected. + * (d) isShellInvocationAllowlisted requires EVERY nested command to be allowed. + * (e) Dynamic/expression targets fail closed under a strict allowedTools set. + */ +describe.skipIf(os.platform() !== 'win32' || !pwshAvailable)( + 'CoreShellToolHostAdapter -> ShellTool permission integration (#3181)', + () => { + function makePermissionConfig( + allowedTools: string[] = [], + excludeTools: string[] = [], + ): { config: Config; adapter: CoreShellToolHostAdapter } { + const config = new Config({ + model: 'test-model', + question: 'test question', + embeddingModel: 'test-embedding', + targetDir: os.tmpdir(), + usageStatisticsEnabled: false, + sessionId: `perm-${Date.now()}-${++sessionIdCounter}`, + debugMode: false, + cwd: os.tmpdir(), + settingsService: new SettingsService(), + coreTools: allowedTools, + allowedTools, + excludeTools, + }); + return { config, adapter: new CoreShellToolHostAdapter(config) }; + } + + it('(a) exact issue #3181 reproduction command passes adapter validation', () => { + const { adapter } = makePermissionConfig(); + const issueCmd = + 'git status --short --branch; git checkout main; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }'; + const result = adapter.isCommandAllowed(issueCmd); + expect(result.allowed).toBe(true); + }); + + it('(a) ShellTool.build does not throw for the issue #3181 command', () => { + const { adapter } = makePermissionConfig(); + const tool = new ShellTool(adapter); + expect(() => + tool.build({ + command: + 'git status --short --branch; git checkout main; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }', + }), + ).not.toThrow(); + }); + + it('(b) malformed PowerShell is hard-denied with PowerShell diagnostic', () => { + const { adapter } = makePermissionConfig(); + const result = adapter.isCommandAllowed('Get-ChildItem |'); + expect(result.allowed).toBe(false); + expect(result.reason).toContain('tree-sitter-pwsh'); + }); + + it('(b) ShellTool.build throws for malformed PowerShell', () => { + const { adapter } = makePermissionConfig(); + const tool = new ShellTool(adapter); + expect(() => tool.build({ command: 'Get-ChildItem |' })).toThrow( + /tree-sitter-pwsh/, + ); + }); + + it('(c) blocklisted command nested in script block is rejected', () => { + const { adapter } = makePermissionConfig([], ['ShellTool(rm)']); + const result = adapter.isCommandAllowed('ForEach-Object { rm -rf /tmp }'); + expect(result.allowed).toBe(false); + }); + + it('(d) isShellInvocationAllowlisted requires all nested commands', () => { + const { adapter } = makePermissionConfig(['ShellTool(Get-Process)']); + // Get-Process is allowed but Where-Object is not + expect( + adapter.isShellInvocationAllowlisted( + 'Get-Process | Where-Object { $_.Name -eq "x" }', + ), + ).toBe(false); + }); + + it('(d) isShellInvocationAllowlisted returns true when all nested commands are allowed', () => { + const { adapter } = makePermissionConfig([ + 'ShellTool(Get-Process)', + 'ShellTool(Where-Object)', + ]); + expect( + adapter.isShellInvocationAllowlisted( + 'Get-Process | Where-Object { $_.Name -eq "x" }', + ), + ).toBe(true); + }); + + it('(e) dynamic call target fails closed under strict allowedTools', () => { + const { adapter } = makePermissionConfig(['ShellTool(git)']); + const result = adapter.isCommandAllowed('& $cmd'); + expect(result.allowed).toBe(false); + expect(result.reason).toContain('dynamic or expression'); + }); + + it('(e) .NET Process::Start fails closed under strict allowedTools', () => { + const { adapter } = makePermissionConfig(['ShellTool(git)']); + const result = adapter.isCommandAllowed( + '[System.Diagnostics.Process]::Start("cmd.exe")', + ); + expect(result.allowed).toBe(false); + expect(result.reason).toContain('dynamic or expression'); + }); + }, +); diff --git a/packages/core/src/tools-adapters/CoreShellToolHostAdapter.ts b/packages/core/src/tools-adapters/CoreShellToolHostAdapter.ts index b87cbd8a9d..48ba15f322 100644 --- a/packages/core/src/tools-adapters/CoreShellToolHostAdapter.ts +++ b/packages/core/src/tools-adapters/CoreShellToolHostAdapter.ts @@ -27,6 +27,7 @@ import type { ShellJob } from '../services/shellJobManager.js'; import { validatePathWithinWorkspace } from '../safety/index.js'; import { getCommandRoots, + getShellConfiguration, isCommandAllowed, stripShellWrapper, } from '../utils/shell-utils.js'; @@ -57,13 +58,18 @@ export class CoreShellToolHostAdapter implements IShellToolHost { } isCommandAllowed(command: string): { allowed: boolean; reason?: string } { - return isCommandAllowed(command, this.config); + return isCommandAllowed( + command, + this.config, + getShellConfiguration().shell, + ); } isShellInvocationAllowlisted(command: string): boolean { return isShellInvocationAllowlisted( { params: { command } } as AnyToolInvocation, this.config.getAllowedTools() ?? [], + getShellConfiguration().shell, ); } @@ -163,7 +169,7 @@ export class CoreShellToolHostAdapter implements IShellToolHost { } getCommandRoots(command: string): string[] { - return getCommandRoots(command); + return getCommandRoots(command, getShellConfiguration().shell); } stripShellWrapper(command: string): string { diff --git a/packages/core/src/utils/powershell-ast-security.test.ts b/packages/core/src/utils/powershell-ast-security.test.ts new file mode 100644 index 0000000000..ecd3b7ca4c --- /dev/null +++ b/packages/core/src/utils/powershell-ast-security.test.ts @@ -0,0 +1,447 @@ +/** + * @license + * Copyright 2026 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { expect, describe, it, beforeEach, afterEach, vi } from 'bun:test'; +import { checkCommandPermissions, isCommandAllowed } from './shell-utils.js'; +import { + initializeParser, + isParserAvailable, + parseCommandDetailsForLanguage, +} from './shell-parser.js'; +import type { Config } from '../config/config.js'; + +/** + * Security remediation tests for PR #3198 review findings (#3181). + * + * Each test exercises a concrete bypass vector identified by CodeRabbit/OCR. + * Tests are RED before the production fix and GREEN after. + */ +await initializeParser(); +const pwshAvailable = isParserAvailable('powershell'); +if (!pwshAvailable) { + throw new Error('PowerShell grammar failed to load under Bun'); +} + +const mockPlatform = vi.fn(); +void vi.mock('os', () => ({ + default: { platform: mockPlatform, homedir: vi.fn() }, + platform: mockPlatform, + homedir: vi.fn(), +})); + +function makeConfig( + overrides: Partial<{ + coreTools: string[]; + excludeTools: string[]; + shellReplacement: string; + }> = {}, +): Config { + return { + getCoreTools: () => overrides.coreTools ?? [], + getExcludeTools: () => overrides.excludeTools ?? [], + getAllowedTools: () => [], + getShellReplacement: () => + (overrides.shellReplacement ?? 'allowlist') as never, + getEphemeralSetting: () => undefined, + } as unknown as Config; +} + +describe.skipIf(!pwshAvailable)( + 'PowerShell security: -Command abbreviation payload extraction (#4)', + () => { + const blocklist: Config = makeConfig({ + excludeTools: ['ShellTool(rm)'], + }); + + beforeEach(() => { + mockPlatform.mockReturnValue('linux'); + }); + afterEach(() => { + vi.clearAllMocks(); + }); + + it('blocks blocklisted command behind -Comm abbreviation in blocklist mode', () => { + const { allowed } = isCommandAllowed( + 'powershell -Comm "rm -rf /tmp"', + blocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('blocks blocklisted command behind -Comma abbreviation in blocklist mode', () => { + const { allowed } = isCommandAllowed( + 'powershell -Comma "rm -rf /tmp"', + blocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('blocks blocklisted command behind pwsh -Comm abbreviation', () => { + const { allowed } = isCommandAllowed( + 'pwsh -Comm "rm -rf /tmp"', + blocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('blocks blocklisted command behind -Comm with bare payload', () => { + const { allowed } = isCommandAllowed( + 'pwsh -Comm rm -rf /tmp', + blocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + // PowerShell accepts -co as an unambiguous abbreviation of -Command. + it('blocks blocklisted command behind -co abbreviation in blocklist mode', () => { + const { allowed } = isCommandAllowed( + 'powershell -co "rm -rf /tmp"', + blocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('does not treat a bare command argument as the -Command flag', () => { + const command = 'powershell -File deploy.ps1 command Write-Output safe'; + const result = parseCommandDetailsForLanguage(command, 'powershell'); + + expect(result?.hasError).toBe(false); + expect(result?.details).toContainEqual( + expect.objectContaining({ nameKind: 'expression', text: command }), + ); + expect(result?.details.map((detail) => detail.name)).not.toContain( + 'Write-Output', + ); + }); + + it('blocks executable content supplied in an argument after a quoted -Command payload', () => { + const { allowed } = isCommandAllowed( + "powershell -Command 'Write-Output safe' '; rm -rf /tmp'", + blocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + }, +); + +describe.skipIf(!pwshAvailable)( + 'PowerShell security: -EncodedCommand payload decoding (#4)', + () => { + const blocklist: Config = makeConfig({ + excludeTools: ['ShellTool(rm)'], + }); + const strictConfig: Config = makeConfig({ + coreTools: ['ShellTool(git)'], + }); + + beforeEach(() => { + mockPlatform.mockReturnValue('linux'); + }); + afterEach(() => { + vi.clearAllMocks(); + }); + + function encodeCmd(cmd: string): string { + return Buffer.from(cmd, 'utf16le').toString('base64'); + } + + it('blocks blocklisted command decoded from -EncodedCommand', () => { + const encoded = encodeCmd('rm -rf /tmp'); + const { allowed } = isCommandAllowed( + `powershell -EncodedCommand ${encoded}`, + blocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + // PowerShell accepts -enc, -en, and even -e as -EncodedCommand. Each must + // be decoded and recursed so a hidden blocklisted command is caught. + it('blocks blocklisted command decoded from -enc abbreviation', () => { + const encoded = encodeCmd('rm -rf /tmp'); + const { allowed } = isCommandAllowed( + `powershell -enc ${encoded}`, + blocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('blocks blocklisted command decoded from -en abbreviation', () => { + const encoded = encodeCmd('rm -rf /tmp'); + const { allowed } = isCommandAllowed( + `powershell -en ${encoded}`, + blocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('blocks blocklisted command decoded from -e abbreviation', () => { + const encoded = encodeCmd('rm -rf /tmp'); + const { allowed } = isCommandAllowed( + `powershell -e ${encoded}`, + blocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('fails closed for -EncodedCommand in strict allowlist', () => { + const encoded = encodeCmd('rm -rf /tmp'); + const result = checkCommandPermissions( + `powershell -EncodedCommand ${encoded}`, + strictConfig, + undefined, + 'powershell', + ); + expect(result.allAllowed).toBe(false); + }); + + it('treats non-canonical Base64 as unresolved instead of decoding it permissively', () => { + const command = 'powershell -EncodedCommand cg!!BtAA=='; + const result = parseCommandDetailsForLanguage(command, 'powershell'); + + expect(result).toMatchObject({ hasError: false }); + expect(result?.details).toContainEqual( + expect.objectContaining({ + text: command, + nameKind: 'expression', + }), + ); + expect(result?.details).not.toContainEqual( + expect.objectContaining({ + name: 'rm', + }), + ); + }); + + it('fails closed for invalid base64 in -EncodedCommand strict allowlist', () => { + const result = checkCommandPermissions( + 'powershell -EncodedCommand @@@notbase64@@@', + strictConfig, + undefined, + 'powershell', + ); + expect(result.allAllowed).toBe(false); + }); + + it('decodes nested blocklisted command from -EncodedCommand in all mode', () => { + const encoded = encodeCmd('iex "rm -rf /tmp"'); + const allConfig = makeConfig({ + excludeTools: ['ShellTool(rm)'], + shellReplacement: 'all', + }); + const { allowed } = isCommandAllowed( + `powershell -EncodedCommand ${encoded}`, + allConfig, + 'powershell', + ); + expect(allowed).toBe(false); + }); + }, +); + +describe.skipIf(!pwshAvailable)( + 'PowerShell security: empty invocation target fail-closed (#15)', + () => { + beforeEach(() => { + mockPlatform.mockReturnValue('linux'); + }); + afterEach(() => { + vi.clearAllMocks(); + }); + + it('hard-denies empty & target in strict allowlist', () => { + const strictConfig = makeConfig({ coreTools: ['ShellTool(git)'] }); + const result = checkCommandPermissions( + "& ''", + strictConfig, + undefined, + 'powershell', + ); + expect(result.allAllowed).toBe(false); + expect(result.isHardDenial).toBe(true); + }); + + it('hard-denies empty & target under session allowlist', () => { + const sessionAllowlist = new Set(['git']); + const result = checkCommandPermissions( + "& ''", + makeConfig({ coreTools: [] }), + sessionAllowlist, + 'powershell', + ); + expect(result.allAllowed).toBe(false); + expect(result.isHardDenial).toBe(true); + }); + }, +); + +describe.skipIf(!pwshAvailable)( + 'PowerShell security: diagnostic naming references tree-sitter-pwsh (#6)', + () => { + beforeEach(() => { + mockPlatform.mockReturnValue('linux'); + }); + afterEach(() => { + vi.clearAllMocks(); + }); + + it('errorReason references tree-sitter-pwsh not powershell-tree-sitter', () => { + const config = makeConfig(); + const { allowed, reason } = isCommandAllowed( + 'Get-ChildItem |', + config, + 'powershell', + ); + expect(allowed).toBe(false); + expect(reason).toContain('tree-sitter-pwsh'); + expect(reason).not.toContain('powershell-tree-sitter'); + }); + }, +); + +describe.skipIf(!pwshAvailable)( + 'PowerShell security: dynamic Start-Process targets fail closed (#3)', + () => { + // When Start-Process is itself allowlisted, a dynamic (non-static) target + // such as a parenthesized member access must still fail closed under a + // strict allowlist. Static targets resolve to a name; dynamic targets must + // be classified as unresolved so no specific pattern can match them. + const strictConfig: Config = makeConfig({ + coreTools: ['ShellTool(start-process)'], + }); + + beforeEach(() => { + mockPlatform.mockReturnValue('linux'); + }); + afterEach(() => { + vi.clearAllMocks(); + }); + + it('fails closed for parenthesized member-access target', () => { + const result = checkCommandPermissions( + 'Start-Process ($obj.FullName)', + strictConfig, + undefined, + 'powershell', + ); + expect(result.allAllowed).toBe(false); + }); + + it('fails closed for element-access target', () => { + const result = checkCommandPermissions( + 'Start-Process $args[0]', + strictConfig, + undefined, + 'powershell', + ); + expect(result.allAllowed).toBe(false); + }); + + it('fails closed for string-concatenation target', () => { + const result = checkCommandPermissions( + 'Start-Process ("a" + $b)', + strictConfig, + undefined, + 'powershell', + ); + expect(result.allAllowed).toBe(false); + }); + + it('still allows a static quoted target when explicitly allowlisted', () => { + const allowTarget = makeConfig({ + coreTools: ['ShellTool(start-process)', 'ShellTool(notepad.exe)'], + }); + const result = checkCommandPermissions( + 'Start-Process "notepad.exe"', + allowTarget, + undefined, + 'powershell', + ); + expect(result.allAllowed).toBe(true); + }); + }, +); + +describe.skipIf(!pwshAvailable)( + 'PowerShell path command-name canonicalization', + () => { + it('normalizes a relative executable path to its basename', () => { + const command = String.raw`.\foo.exe --safe`; + const result = parseCommandDetailsForLanguage(command, 'powershell'); + expect(result).toMatchObject({ hasError: false }); + expect(result?.details).toContainEqual({ + name: 'foo.exe', + text: command, + canonicalText: 'foo.exe --safe', + nameKind: 'static', + }); + }); + + it('matches relative executable paths by canonical basename', () => { + const command = String.raw`.\foo.exe --safe`; + expect( + isCommandAllowed( + command, + makeConfig({ coreTools: ['ShellTool(foo.exe)'] }), + 'powershell', + ).allowed, + ).toBe(true); + expect( + isCommandAllowed( + command, + makeConfig({ excludeTools: ['ShellTool(foo.exe)'] }), + 'powershell', + ).allowed, + ).toBe(false); + }); + }, +); + +describe.skipIf(!pwshAvailable)( + 'PowerShell security: multi-byte text round-trips through AST offsets', + () => { + // web-tree-sitter exposes startIndex/endIndex as UTF-16 code-unit offsets + // (matching String.prototype.slice) in this runtime. A review finding + // claimed these were byte offsets that corrupt multi-byte text. This test + // locks in the correct behavior: a command containing 2-byte (é), 3-byte + // (€), and 4-byte/surrogate-pair (😀) characters must produce intact + // detail text, not garbled slices. If a future tree-sitter version ever + // changes offset semantics, this test will fail loudly. + it('extracts intact later command text after Unicode input', () => { + const command = + 'Write-Host "é-€-😀"; & "C:\\tools\\café-€-😀.exe" --safe'; + const result = parseCommandDetailsForLanguage(command, 'powershell'); + const detail = result?.details.find( + (candidate) => candidate.name === 'café-€-😀.exe', + ); + expect(detail).toBeDefined(); + // The later node starts after multi-byte/surrogate-pair input, so this + // catches byte-offset slicing as well as corruption within the basename. + expect(detail?.text).toBe('& "C:\\tools\\café-€-😀.exe" --safe'); + }); + + it('matches a later Unicode blocklisted target after Unicode input', () => { + const blocklist = makeConfig({ + excludeTools: ['ShellTool(café-€-😀.exe)'], + }); + const { allowed } = isCommandAllowed( + 'Write-Host "é-€-😀"; & "C:\\tools\\café-€-😀.exe"', + blocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + }, +); diff --git a/packages/core/src/utils/powershell-ast.ts b/packages/core/src/utils/powershell-ast.ts new file mode 100644 index 0000000000..b41bd6bbbc --- /dev/null +++ b/packages/core/src/utils/powershell-ast.ts @@ -0,0 +1,740 @@ +/** + * @license + * Copyright 2026 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * PowerShell AST extraction and security classification (#3181). + * + * Extracted from shell-parser.ts to keep that module within line limits. + * All public entry points are consumed by shell-parser.ts's language-aware + * dispatchers; this module holds no parser lifecycle state and relies on + * the caller to provide a recursion callback for nested-payload parsing. + */ + +import type { Tree, Node } from 'web-tree-sitter'; +import type { + ParsedCommandDetail, + CommandParseResult, + ParserLanguage, + SplitCommandsTreeOptions, +} from './shell-parser.js'; +import { extractPwshWrapperPayloadDetails } from './powershell-wrapper-payload.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Recursion callback used to parse a nested wrapper payload with the + * appropriate grammar. Provided by shell-parser.ts to avoid a circular + * module dependency. + */ +export type ParsePayloadFn = ( + payload: string, + language: ParserLanguage, +) => CommandParseResult | null; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const START_PROCESS_PARAMETERS = [ + 'argumentlist', + 'confirm', + 'credential', + 'debug', + 'environment', + 'erroraction', + 'errorvariable', + 'filepath', + 'informationaction', + 'informationvariable', + 'loaduserprofile', + 'nonewwindow', + 'outbuffer', + 'outvariable', + 'passthru', + 'pipelinevariable', + 'progressaction', + 'redirectstandarderror', + 'redirectstandardinput', + 'redirectstandardoutput', + 'usenewenvironment', + 'verb', + 'verbose', + 'wait', + 'warningaction', + 'warningvariable', + 'whatif', + 'windowstyle', + 'workingdirectory', +] as const; +const START_PROCESS_SWITCHES = new Set([ + 'confirm', + 'debug', + 'loaduserprofile', + 'nonewwindow', + 'passthru', + 'usenewenvironment', + 'verbose', + 'wait', + 'whatif', +]); + +/** + * Node types that represent dynamic (unresolvable) content and must never + * yield a static fragment through direct traversal. Listed explicitly so the + * contract is resilient to grammar changes (Finding 4, #3181). + */ +const DYNAMIC_BOUNDARY_TYPES = new Set([ + 'variable', + 'sub_expression', + 'array_literal', + 'expandable_string_literal', + 'expandable_here_string_literal', +]); + +// --------------------------------------------------------------------------- +// Small helpers +// --------------------------------------------------------------------------- + +export function findNamedChild(node: Node, type: string): Node | null { + for (let index = 0; index < node.namedChildCount; index += 1) { + const child = node.namedChild(index); + if (child?.type === type) { + return child; + } + } + return null; +} + +export function expressionDetail(text: string): ParsedCommandDetail { + return { name: '', text, nameKind: 'expression' }; +} + +function dynamicDetail(text: string): ParsedCommandDetail { + return { name: '', text, nameKind: 'dynamic' }; +} + +function resolveStartProcessParameter(raw: string): string | null { + const fragment = raw.replace(/^-+/u, '').split(':', 1)[0]?.toLowerCase(); + if (!fragment) { + return null; + } + const matches = START_PROCESS_PARAMETERS.filter((name) => + name.startsWith(fragment), + ); + return matches.length === 1 ? matches[0] : null; +} + +// --------------------------------------------------------------------------- +// Command splitting +// --------------------------------------------------------------------------- + +export function splitPwshCommandsWithTree( + tree: Tree, + options?: SplitCommandsTreeOptions, +): string[] { + const splitOnPipes = options?.splitOnPipes ?? true; + const commands: string[] = []; + + splitPwshCommands(tree.rootNode, commands, splitOnPipes); + return commands.filter((cmd) => cmd.trim().length > 0); +} + +function splitPwshCommands( + node: Node, + commands: string[], + splitOnPipes: boolean, +): void { + switch (node.type) { + case 'command': + commands.push(node.text); + break; + case 'pipeline': + if (splitOnPipes) { + for (const child of node.children.filter(isNode)) { + splitPwshCommands(child, commands, splitOnPipes); + } + } else { + commands.push(node.text); + } + break; + default: + for (const child of node.children.filter(isNode)) { + splitPwshCommands(child, commands, splitOnPipes); + } + break; + } +} + +function isNode(node: Node | null): node is Node { + return node !== null; +} + +// --------------------------------------------------------------------------- +// Command detail collection (recursive AST walk) +// --------------------------------------------------------------------------- + +/** + * Walk the PowerShell AST collecting all command details, recursing into + * script blocks, subexpressions, arrays, and control flow. + */ +export function collectPwshCommandDetailsFromTree( + tree: Tree, + source: string, + parsePayload: ParsePayloadFn, +): ParsedCommandDetail[] { + const details: ParsedCommandDetail[] = []; + const stack: Node[] = [tree.rootNode]; + + while (stack.length > 0) { + const current = stack.pop(); + if (!current) { + continue; + } + + if (current.type === 'command') { + const detail = extractPwshCommandDetail(current, source); + if (detail) { + details.push(detail); + } + + // Expand wrapper/evaluator payloads (Finding 4, #3181). + // Literal payloads are recursively parsed with the target grammar; + // dynamic payloads are classified as unresolved expressions. + details.push( + ...extractPwshWrapperPayloadDetails(current, source, parsePayload), + ); + } + + // Classify EVERY executable invocation_expression (instance method, + // static method, .NET member call, etc.) as an unresolved expression + // target. These cannot be statically matched against command allowlists + // and must fail closed in restricted policy (Finding 2, #3181). + if (current.type === 'invocation_expression') { + details.push( + expressionDetail( + source.slice(current.startIndex, current.endIndex).trim(), + ), + ); + } + + for (let i = current.namedChildCount - 1; i >= 0; i -= 1) { + const child = current.namedChild(i); + if (child) { + stack.push(child); + } + } + } + + return details; +} + +// --------------------------------------------------------------------------- +// Single-command extraction +// --------------------------------------------------------------------------- + +function extractPwshCommandDetail( + commandNode: Node, + source: string, +): ParsedCommandDetail | null { + const text = source + .slice(commandNode.startIndex, commandNode.endIndex) + .trim(); + + // Case 1: simple command_name child (Get-ChildItem, git, etc.) + for (let i = 0; i < commandNode.namedChildCount; i += 1) { + const child = commandNode.namedChild(i); + if (child?.type === 'command_name') { + const name = normalizePwshCommandName(child.text); + if (name !== child.text.trim()) { + return buildPwshStaticInvocationDetail( + name, + child, + commandNode, + source, + text, + ); + } + return { name, text, nameKind: 'static' }; + } + } + + // Case 2: call/dot operator invocation (& "path", . .\script.ps1) + const invocation = findInvocationOperatorTarget(commandNode); + if (invocation) { + return classifyPwshInvocationTarget(invocation, commandNode, source, text); + } + + return null; +} + +/** + * Find the command_name_expr paired with a command_invocation_operator. + */ +function findInvocationOperatorTarget(commandNode: Node): Node | null { + let hasInvocationOperator = false; + let nameExprNode: Node | null = null; + + for (let i = 0; i < commandNode.childCount; i += 1) { + const child = commandNode.child(i); + if (!child) { + continue; + } + if (child.type === 'command_invocation_operator') { + hasInvocationOperator = true; + } else if (child.type === 'command_name_expr') { + nameExprNode = child; + } + } + + return hasInvocationOperator ? nameExprNode : null; +} + +export function getPwshCommandName(commandNode: Node): string | null { + for (let i = 0; i < commandNode.namedChildCount; i += 1) { + const child = commandNode.namedChild(i); + if (child?.type === 'command_name') { + return child.text.toLowerCase(); + } + } + + const invocationTarget = findInvocationOperatorTarget(commandNode); + if (!invocationTarget) { + return null; + } + + for (let i = 0; i < invocationTarget.namedChildCount; i += 1) { + const child = invocationTarget.namedChild(i); + if (child?.type === 'command_name') { + return normalizePwshCommandName(child.text).toLowerCase(); + } + if (child?.type === 'string_literal') { + const literal = extractPwshStaticStringContent(child); + return literal === null + ? null + : normalizePwshCommandName(literal).toLowerCase(); + } + } + + return null; +} + +// --------------------------------------------------------------------------- +// String argument extraction +// --------------------------------------------------------------------------- + +export function extractPwshStaticStringDescendant(node: Node): string | null { + const stack: Node[] = [node]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current) { + continue; + } + + if (current.type === 'string_literal') { + return extractPwshStaticStringContent(current); + } + + // Explicit dynamic boundaries: a bare expandable string (with or without + // interpolation) or any variable/subexpression/array must never yield a + // static fragment through direct traversal. In the current grammar these + // are always children of string_literal (handled above), but marking them + // explicitly makes the contract resilient to grammar changes. + if (DYNAMIC_BOUNDARY_TYPES.has(current.type)) { + return null; + } + + for (let i = current.namedChildCount - 1; i >= 0; i -= 1) { + const child = current.namedChild(i); + if (child) { + stack.push(child); + } + } + } + return null; +} + +function extractPwshStaticStringContent(stringNode: Node): string | null { + for (let i = 0; i < stringNode.namedChildCount; i += 1) { + const child = stringNode.namedChild(i); + if (!child) { + continue; + } + + if ( + child.type === 'verbatim_string_characters' || + child.type === 'verbatim_here_string_characters' + ) { + return extractPwshStringLiteralText(child.text); + } + + if ( + child.type === 'expandable_string_literal' || + child.type === 'expandable_here_string_literal' + ) { + if (child.namedChildCount > 0) { + return null; + } + return extractPwshStringLiteralText(child.text); + } + } + return extractPwshStringLiteralText(stringNode.text); +} + +// --------------------------------------------------------------------------- +// Process launcher (Start-Process) target extraction +// --------------------------------------------------------------------------- + +/** + * Extract the target executable from a Start-Process / saps command node. + * The first positional argument or the -FilePath parameter value is the + * target. Literal string targets and bare tokens are classified as `static`; + * variable/subexpression targets are classified as `dynamic` (Finding 4, #3181). + */ +export function extractPwshLauncherTarget( + commandNode: Node, +): ParsedCommandDetail | null { + const commandElements = findNamedChild(commandNode, 'command_elements'); + if (!commandElements) { + return null; + } + + const children = Array.from( + { length: commandElements.namedChildCount }, + (_, index) => commandElements.namedChild(index), + ).filter((child): child is Node => child !== null); + + const explicit = findExplicitFilePathTarget(children); + return explicit ?? findFirstPositionalLauncherTarget(children); +} + +function findExplicitFilePathTarget( + children: readonly Node[], +): ParsedCommandDetail | null { + for (let index = 0; index < children.length; index += 1) { + const child = children[index]; + if ( + child.type !== 'command_parameter' || + resolveStartProcessParameter(child.text) !== 'filepath' + ) { + continue; + } + const result = findFilePathValue(children, index + 1); + if (result) { + return result; + } + } + return null; +} + +function findFilePathValue( + children: readonly Node[], + startIndex: number, +): ParsedCommandDetail | null { + for ( + let valueIndex = startIndex; + valueIndex < children.length; + valueIndex += 1 + ) { + const value = children[valueIndex]; + if (value.type === 'command_parameter') { + return null; + } + const classified = classifyPwshLauncherArgument(value); + if (classified) { + return classified; + } + } + return null; +} + +function findFirstPositionalLauncherTarget( + children: readonly Node[], +): ParsedCommandDetail | null { + let skipParameterValue = false; + for (const child of children) { + if (child.type === 'command_argument_sep') { + // Separators do not consume the value expected by a preceding parameter. + } else if (child.type === 'command_parameter') { + const parameter = resolveStartProcessParameter(child.text); + skipParameterValue = + parameter === null || !START_PROCESS_SWITCHES.has(parameter); + } else { + const shouldSkip = skipParameterValue; + skipParameterValue = false; + const classified = shouldSkip + ? null + : classifyPwshLauncherArgument(child); + if (classified !== null) { + return classified; + } + } + } + return null; +} + +function classifyPwshLauncherArgument(arg: Node): ParsedCommandDetail | null { + if (arg.type === 'generic_token') { + return { + name: normalizePwshCommandName(arg.text), + text: arg.text, + nameKind: 'static', + }; + } + + if (arg.type === 'unary_expression') { + return classifyUnaryExpressionArg(arg); + } + + if ( + arg.type === 'variable' || + arg.type === 'sub_expression' || + arg.type === 'array_literal' + ) { + return dynamicDetail(arg.text); + } + + return null; +} + +function classifyUnaryExpressionArg(arg: Node): ParsedCommandDetail | null { + for (let i = 0; i < arg.namedChildCount; i += 1) { + const inner = arg.namedChild(i); + if (!inner) { + continue; + } + + if (inner.type === 'string_literal') { + const content = extractPwshStaticStringContent(inner); + if (content !== null) { + return { + name: normalizePwshCommandName(content), + text: content, + nameKind: 'static', + }; + } + return dynamicDetail(arg.text); + } + + if (inner.type === 'variable') { + return dynamicDetail(arg.text); + } + } + // Any other unary_expression content (parenthesized member access, + // element access, string concatenation, nested invocation, etc.) cannot + // resolve to a static command name. Classify it as dynamic so a strict + // allowlist fails closed instead of skipping the target (#3181 review). + return dynamicDetail(arg.text); +} + +// --------------------------------------------------------------------------- +// Invocation target classification (& / . operator) +// --------------------------------------------------------------------------- + +function classifyPwshInvocationTarget( + nameExprNode: Node, + commandNode: Node, + source: string, + text: string, +): ParsedCommandDetail { + for (let i = 0; i < nameExprNode.namedChildCount; i += 1) { + const inner = nameExprNode.namedChild(i); + if (!inner) { + continue; + } + + if (inner.type === 'string_literal' && !hasExpandableChild(inner)) { + const resolvedName = normalizePwshCommandName( + extractPwshStringLiteralText(inner.text), + ); + // An empty target like & '' cannot resolve to a safe static name. + // Classify as dynamic so strict allowlist fails closed (#3181). + if (!resolvedName) { + return dynamicDetail(text); + } + return buildPwshStaticInvocationDetail( + resolvedName, + nameExprNode, + commandNode, + source, + text, + ); + } + + if (inner.type === 'string_literal' && hasExpandableChild(inner)) { + return dynamicDetail(text); + } + + if (inner.type === 'command_name') { + return buildPwshStaticInvocationDetail( + normalizePwshCommandName(inner.text), + nameExprNode, + commandNode, + source, + text, + ); + } + + return dynamicDetail(text); + } + + return dynamicDetail(text); +} + +function hasExpandableChild(node: Node): boolean { + for (let i = 0; i < node.namedChildCount; i += 1) { + const child = node.namedChild(i); + if (child?.type === 'expandable_string_literal') { + return child.namedChildCount > 0; + } + } + return false; +} + +function buildPwshStaticInvocationDetail( + name: string, + nameExprNode: Node, + commandNode: Node, + source: string, + text: string, +): ParsedCommandDetail { + const argsText = source + .slice(nameExprNode.endIndex, commandNode.endIndex) + .trim(); + const canonicalText = argsText ? `${name} ${argsText}` : name; + return { name, text, canonicalText, nameKind: 'static' }; +} + +// --------------------------------------------------------------------------- +// Name normalization +// --------------------------------------------------------------------------- + +function normalizePwshCommandName(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) { + return trimmed; + } + return trimmed.split(/[\\/]/).pop() ?? trimmed; +} + +function extractPwshStringLiteralText(raw: string): string { + if (raw.startsWith("@'") && raw.endsWith("'@")) { + return stripPwshHereStringBoundaryNewlines(raw.slice(2, -2)); + } + if (raw.startsWith('@"') && raw.endsWith('"@')) { + return decodePwshDoubleQuotedContent( + stripPwshHereStringBoundaryNewlines(raw.slice(2, -2)), + ); + } + if (raw.length < 2) { + return raw; + } + + const quote = raw[0]; + if ((quote !== '"' && quote !== "'") || raw[raw.length - 1] !== quote) { + return raw; + } + + const content = raw.slice(1, -1); + return quote === "'" + ? content.replace(/''/g, "'") + : decodePwshDoubleQuotedContent(content); +} + +function stripPwshHereStringBoundaryNewlines(content: string): string { + return content + .replace(/^(?:\r\n|\n|\r)/u, '') + .replace(/(?:\r\n|\n|\r)$/u, ''); +} + +function decodePwshDoubleQuotedContent(content: string): string { + const escapeValues: Readonly> = { + '0': '\0', + a: '\x07', + b: '\b', + e: '\x1b', + f: '\f', + n: '\n', + r: '\r', + t: '\t', + v: '\v', + }; + // Collapse doubled double-quotes FIRST ("" -> "). In PowerShell a literal " + // inside a double-quoted string is escaped by doubling it. This must run + // before backtick processing so a backtick-escaped quote (`") is never + // accidentally merged with an adjacent quote by the collapse. + const collapsed = content.replace(/""/g, '"'); + return collapsed.replace(/`(?:\r\n|[\s\S])/g, (escaped) => { + const value = escaped.slice(1); + if (value === '\r\n' || value === '\n' || value === '\r') { + return ''; + } + return escapeValues[value] ?? value; + }); +} + +// --------------------------------------------------------------------------- +// Substitution detection and error diagnostics +// --------------------------------------------------------------------------- + +/** + * Detect `$()` subexpression nodes. PowerShell backticks are escapes, not + * substitution (unlike Bash backticks). + */ +export function hasPwshCommandSubstitution(root: Node): boolean { + const stack: Node[] = [root]; + + while (stack.length > 0) { + const current = stack.pop(); + if (!current) { + continue; + } + + if (current.type === 'sub_expression') { + return true; + } + + for (let i = current.namedChildCount - 1; i >= 0; i -= 1) { + const child = current.namedChild(i); + if (child) { + stack.push(child); + } + } + } + + return false; +} + +export function findFirstErrorNode(root: Node): Node | null { + if (!root.hasError) { + return null; + } + // tree-sitter represents missing tokens as nodes whose expected type is + // preserved (e.g. 'command_name') with isMissing === true; the 'MISSING' + // pseudo-type is never set on the node.type field. + if (root.type === 'ERROR' || root.isMissing) { + return root; + } + + return findFirstErrorDescendant(root); +} + +function findFirstErrorDescendant(root: Node): Node | null { + for (let i = 0; i < root.childCount; i += 1) { + const child = root.child(i); + if (!child) { + continue; + } + // findFirstErrorNode checks hasError internally and returns null + // immediately for subtrees without errors. + const found = findFirstErrorNode(child); + if (found) { + return found; + } + } + return null; +} diff --git a/packages/core/src/utils/powershell-parse-result.ts b/packages/core/src/utils/powershell-parse-result.ts new file mode 100644 index 0000000000..b6fcab8a0a --- /dev/null +++ b/packages/core/src/utils/powershell-parse-result.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2026 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Tree } from 'web-tree-sitter'; +import type { CommandParseResult } from './shell-parser.js'; +import { + collectPwshCommandDetailsFromTree, + findFirstErrorNode, + type ParsePayloadFn, +} from './powershell-ast.js'; + +export function buildPwshCommandParseResult( + tree: Tree | null, + command: string, + parsePayload: ParsePayloadFn, +): CommandParseResult { + if (tree === null) { + return { + details: [], + hasError: true, + errorReason: + 'PowerShell command rejected because the parser timed out or produced no tree', + }; + } + + if (tree.rootNode.hasError) { + const errorNode = findFirstErrorNode(tree.rootNode); + const position = + errorNode === null + ? '' + : ` at ${errorNode.startPosition.row + 1}:${errorNode.startPosition.column + 1}`; + return { + details: [], + hasError: true, + errorReason: `PowerShell command rejected because tree-sitter-pwsh reported a syntax error${position}`, + }; + } + + return { + details: collectPwshCommandDetailsFromTree(tree, command, parsePayload), + hasError: false, + }; +} diff --git a/packages/core/src/utils/powershell-wrapper-payload.ts b/packages/core/src/utils/powershell-wrapper-payload.ts new file mode 100644 index 0000000000..23a11ec31a --- /dev/null +++ b/packages/core/src/utils/powershell-wrapper-payload.ts @@ -0,0 +1,475 @@ +/** + * @license + * Copyright 2026 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * PowerShell wrapper/encoded-command payload extraction (#3181). + * + * Extracted from powershell-ast.ts to keep that module within line limits. + * This module classifies wrapper/launcher command names, extracts literal + * payloads from -Command/-c/-EncodedCommand invocations, decodes base64 + * (UTF-16LE) payloads, and recursively parses them with the target grammar. + * It relies on powershell-ast.ts for shared tree-walking, name resolution, + * and string-content helpers, and holds no parser lifecycle state. + */ + +import type { Node } from 'web-tree-sitter'; +import { Buffer } from 'node:buffer'; +import type { ParsedCommandDetail, ParserLanguage } from './shell-parser.js'; +import type { ParsePayloadFn } from './powershell-ast.js'; +import { + findNamedChild, + expressionDetail, + extractPwshStaticStringDescendant, + getPwshCommandName, + extractPwshLauncherTarget, +} from './powershell-ast.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Classification of a PowerShell wrapper/launcher command name. + */ +type PwshWrapperCategory = + | 'evaluator' + | 'pwsh' + | 'bash' + | 'cmd' + | 'launcher' + | 'none'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const PWSH_EVALUATORS = new Set(['invoke-expression', 'iex']); +const PWSH_SHELL_WRAPPERS_PWSH = new Set([ + 'powershell', + 'powershell.exe', + 'pwsh', + 'pwsh.exe', +]); +const PWSH_SHELL_WRAPPERS_BASH = new Set(['bash', 'bash.exe', 'sh', 'sh.exe']); +const PWSH_SHELL_WRAPPERS_CMD = new Set(['cmd', 'cmd.exe']); +// Start-Process and its default aliases launch an external process; the target +// executable must be extracted as a static command name for blocklist / +// allowlist validation (Finding 4, #3181). +const PWSH_PROCESS_LAUNCHERS = new Set(['start-process', 'saps', 'start']); + +// --------------------------------------------------------------------------- +// Wrapper-specific string argument extraction +// --------------------------------------------------------------------------- + +function extractPwshStringArgument(commandNode: Node): string | null { + const commandElements = findNamedChild(commandNode, 'command_elements'); + return commandElements + ? extractPwshStaticStringDescendant(commandElements) + : null; +} + +function extractPwshStringArgumentAfterFlag( + commandNode: Node, + flags: ReadonlySet, + category: PwshWrapperCategory = 'none', +): string | null { + const commandElements = findNamedChild(commandNode, 'command_elements'); + if (!commandElements) { + return null; + } + + const flagIndex = findFlagElementIndex(commandElements, flags, category); + return flagIndex >= 0 + ? extractFirstStringAfterFlag(commandElements, flagIndex + 1) + : null; +} + +function extractPwshCommandPayloadAfterFlag( + commandNode: Node, + flags: ReadonlySet, +): string | null { + const commandElements = findNamedChild(commandNode, 'command_elements'); + if (!commandElements) { + return null; + } + + const flagIndex = findFlagElementIndex(commandElements, flags, 'pwsh'); + if (flagIndex < 0) { + return null; + } + + const parts: string[] = []; + for ( + let index = flagIndex + 1; + index < commandElements.namedChildCount; + index += 1 + ) { + const child = commandElements.namedChild(index); + if (!child || child.type === 'command_argument_sep') { + continue; + } + parts.push(extractPwshStaticStringDescendant(child) ?? child.text); + } + + const payload = parts.join(' ').trim(); + return payload || null; +} + +function findFlagElementIndex( + commandElements: Node, + flags: ReadonlySet, + category: PwshWrapperCategory = 'none', +): number { + for (let index = 0; index < commandElements.namedChildCount; index += 1) { + const child = commandElements.namedChild(index); + if (child && isWrapperFlagMatch(child.text, flags, category)) { + return index; + } + } + return -1; +} + +/** + * Match a command_element token against the expected wrapper flag set. + * PowerShell parameter binding accepts unambiguous abbreviations; powershell.exe + * resolves -co through -command (and -c) to -Command, so prefix matching down + * to two characters is required. Bash (-c) and cmd (/c) require exact matching + * (#3181 review). + */ +function isWrapperFlagMatch( + text: string, + flags: ReadonlySet, + category: PwshWrapperCategory, +): boolean { + const lowered = text.toLowerCase(); + if (flags.has(lowered)) { + return true; + } + if (category === 'pwsh') { + if (!lowered.startsWith('-')) { + return false; + } + const param = lowered.slice(1); + return param.length >= 2 && 'command'.startsWith(param); + } + return false; +} + +function extractFirstStringAfterFlag( + commandElements: Node, + startIndex: number, +): string | null { + for ( + let index = startIndex; + index < commandElements.namedChildCount; + index += 1 + ) { + const child = commandElements.namedChild(index); + if (child && child.type !== 'command_argument_sep') { + return extractPwshStaticStringDescendant(child); + } + } + return null; +} + +// --------------------------------------------------------------------------- +// Wrapper / launcher classification +// --------------------------------------------------------------------------- + +function classifyPwshWrapperName(name: string): PwshWrapperCategory { + if (PWSH_EVALUATORS.has(name)) { + return 'evaluator'; + } + if (PWSH_SHELL_WRAPPERS_PWSH.has(name)) { + return 'pwsh'; + } + if (PWSH_SHELL_WRAPPERS_BASH.has(name)) { + return 'bash'; + } + if (PWSH_SHELL_WRAPPERS_CMD.has(name)) { + return 'cmd'; + } + if (PWSH_PROCESS_LAUNCHERS.has(name)) { + return 'launcher'; + } + return 'none'; +} + +function isShellWrapperCategory(category: PwshWrapperCategory): boolean { + return category === 'pwsh' || category === 'bash' || category === 'cmd'; +} + +function resolveBareWrapperFlags( + category: PwshWrapperCategory, +): ReadonlySet { + if (category === 'cmd') { + return new Set(['/c']); + } + if (category === 'bash') { + return new Set(['-c']); + } + return new Set(['-command', '-c']); +} + +function extractPwshBareWrapperPayload( + commandNode: Node, + source: string, + flags: ReadonlySet, + category: PwshWrapperCategory = 'none', +): string | null { + const commandElements = findNamedChild(commandNode, 'command_elements'); + if (!commandElements) { + return null; + } + + for (let index = 0; index < commandElements.namedChildCount; index += 1) { + const child = commandElements.namedChild(index); + if (!child || !isWrapperFlagMatch(child.text, flags, category)) { + continue; + } + const payload = source.slice(child.endIndex, commandNode.endIndex).trim(); + return payload || null; + } + return null; +} + +// --------------------------------------------------------------------------- +// Wrapper payload extraction and recursive parsing +// --------------------------------------------------------------------------- + +/** + * Extract and recursively parse wrapper/evaluator payloads from a + * PowerShell `command` node (Finding 4, #3181). + * + * - Invoke-Expression/iex: parse literal payload with PowerShell grammar; + * dynamic payload -> expression. + * - powershell/pwsh -Command: parse literal payload with PowerShell grammar; + * dynamic payload -> expression. + * - bash/sh -c: parse literal payload with Bash grammar; + * dynamic payload -> expression. + * - cmd/cmd.exe /c: no dedicated parser; literal -> expression (unresolved); + * dynamic -> expression. + */ +export function extractPwshWrapperPayloadDetails( + commandNode: Node, + source: string, + parsePayload: ParsePayloadFn, +): ParsedCommandDetail[] { + const name = getPwshCommandName(commandNode); + const category = name !== null ? classifyPwshWrapperName(name) : 'none'; + if (category === 'none') { + return []; + } + + const fullText = source + .slice(commandNode.startIndex, commandNode.endIndex) + .trim(); + + if (category === 'launcher') { + return extractLauncherPayload(commandNode); + } + + // -EncodedCommand delivers an opaque base64 (UTF-16LE) payload that + // bypasses structural validation. Decode and recurse so nested + // blocklisted commands are caught; invalid/missing payloads fail closed + // (#3181 review). + if (category === 'pwsh') { + const encodedResult = extractPwshEncodedCommandDetails( + commandNode, + fullText, + parsePayload, + ); + if (encodedResult !== null) { + return encodedResult; + } + } + + return extractWrapperPayload( + commandNode, + source, + category, + fullText, + parsePayload, + ); +} + +function extractLauncherPayload(commandNode: Node): ParsedCommandDetail[] { + const target = extractPwshLauncherTarget(commandNode); + if (target === null) { + return []; + } + return [target]; +} + +/** + * Resolve the literal payload from a wrapper/evaluator command. PowerShell + * -Command reconstructs all trailing arguments; bash/sh -c and cmd /c take a + * single string argument; bare evaluators (Invoke-Expression) take a single + * string argument. + */ +function resolveWrapperPayload( + commandNode: Node, + category: PwshWrapperCategory, + wrapperFlags: ReadonlySet | null, +): string | null { + if (wrapperFlags === null) { + return extractPwshStringArgument(commandNode); + } + if (category === 'pwsh') { + return extractPwshCommandPayloadAfterFlag(commandNode, wrapperFlags); + } + return extractPwshStringArgumentAfterFlag( + commandNode, + wrapperFlags, + category, + ); +} + +function extractWrapperPayload( + commandNode: Node, + source: string, + category: PwshWrapperCategory, + fullText: string, + parsePayload: ParsePayloadFn, +): ParsedCommandDetail[] { + const wrapperFlags = isShellWrapperCategory(category) + ? resolveBareWrapperFlags(category) + : null; + let payload = resolveWrapperPayload(commandNode, category, wrapperFlags); + if (payload === null && wrapperFlags) { + payload = extractPwshBareWrapperPayload( + commandNode, + source, + wrapperFlags, + category, + ); + } + + if (payload === null) { + return [expressionDetail(fullText)]; + } + + if (category === 'cmd') { + return [expressionDetail(payload)]; + } + + // Recursive expansion terminates because every payload must be a strict + // substring of its wrapper command. Treat any grammar anomaly that violates + // that invariant as unresolved instead of silently skipping validation. + if (payload.length >= fullText.length) { + return [expressionDetail(fullText)]; + } + + return parseWrapperPayload(payload, category, parsePayload); +} + +function parseWrapperPayload( + payload: string, + category: PwshWrapperCategory, + parsePayload: ParsePayloadFn, +): ParsedCommandDetail[] { + const payloadLanguage: ParserLanguage = + category === 'bash' ? 'bash' : 'powershell'; + const nestedResult = parsePayload(payload, payloadLanguage); + + if (nestedResult?.hasError === false && nestedResult.details.length > 0) { + return nestedResult.details; + } + + return [expressionDetail(payload)]; +} + +// --------------------------------------------------------------------------- +// Encoded-command detection and decoding +// --------------------------------------------------------------------------- + +/** + * Detect -EncodedCommand (and its unambiguous abbreviations) on a powershell + * / pwsh command node. Returns decoded details when the flag is present, or + * null when it is absent (caller should continue with regular extraction). + */ +function extractPwshEncodedCommandDetails( + commandNode: Node, + fullText: string, + parsePayload: ParsePayloadFn, +): ParsedCommandDetail[] | null { + const commandElements = findNamedChild(commandNode, 'command_elements'); + if (!commandElements) { + return null; + } + + for (let index = 0; index < commandElements.namedChildCount; index += 1) { + const child = commandElements.namedChild(index); + if ( + !child || + child.type !== 'command_parameter' || + !isPwshEncodedCommandFlag(child.text) + ) { + continue; + } + + const encoded = extractEncodedPayloadToken(commandElements, index + 1); + if (encoded === null) { + // Flag present but payload is dynamic or absent — fail closed. + return [expressionDetail(fullText)]; + } + const decoded = decodePwshEncodedPayload(encoded); + if (decoded === null || decoded.trim() === '') { + return [expressionDetail(fullText)]; + } + return parseWrapperPayload(decoded, 'pwsh', parsePayload); + } + return null; +} + +function isPwshEncodedCommandFlag(text: string): boolean { + // powershell.exe resolves every prefix of -EncodedCommand down to -e as + // -EncodedCommand, so match any non-empty prefix (#3181 review). + const param = text.toLowerCase().replace(/^-/, ''); + return param.length > 0 && 'encodedcommand'.startsWith(param); +} + +function extractEncodedPayloadToken( + commandElements: Node, + startIndex: number, +): string | null { + for ( + let index = startIndex; + index < commandElements.namedChildCount; + index += 1 + ) { + const child = commandElements.namedChild(index); + if (!child || child.type === 'command_argument_sep') { + continue; + } + if (child.type === 'generic_token') { + return child.text; + } + return extractPwshStaticStringDescendant(child); + } + return null; +} + +function decodePwshEncodedPayload(base64: string): string | null { + const normalized = base64.replace(/\s+/gu, ''); + if ( + normalized.length === 0 || + normalized.length % 4 !== 0 || + !/^[A-Za-z0-9+/]*={0,2}$/u.test(normalized) + ) { + return null; + } + + try { + const bytes = Buffer.from(normalized, 'base64'); + if (bytes.length % 2 !== 0 || bytes.toString('base64') !== normalized) { + return null; + } + return bytes.toString('utf16le'); + } catch { + return null; + } +} diff --git a/packages/core/src/utils/shell-parser-node-smoke.test.ts b/packages/core/src/utils/shell-parser-node-smoke.test.ts new file mode 100644 index 0000000000..3d42ef7b9d --- /dev/null +++ b/packages/core/src/utils/shell-parser-node-smoke.test.ts @@ -0,0 +1,195 @@ +/** + * @license + * Copyright 2026 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { expect, describe, it } from 'bun:test'; +import { build } from 'bun'; +import { execFileSync } from 'node:child_process'; +import { + existsSync, + mkdtempSync, + mkdirSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const repoRoot = resolve(__dirname, '..', '..', '..', '..'); +const shellParserPath = join(__dirname, 'shell-parser.ts'); + +interface NodeExecError extends Error { + status?: number | null; + stderr?: string | Buffer; + stdout?: string | Buffer; +} + +function isNodeExecError(value: unknown): value is NodeExecError { + return value instanceof Error; +} + +function bufferToText(value: string | Buffer | undefined): string { + if (typeof value === 'string') { + return value; + } + if (value instanceof Buffer) { + return value.toString('utf8'); + } + return ''; +} + +/** + * Out-of-process Node smoke of the **production** shell-parser module (#3181). + * + * The test uses `Bun.build` to bundle `shell-parser.ts` (the actual production + * code, not hand-written web-tree-sitter loading) into a temporary ESM module + * targeting Node. `web-tree-sitter` is externalized so Node resolves it from + * `node_modules`; every relative import (DebugLogger, runtime, etc.) is bundled + * inline. + * + * The spawned Node process: + * 1. Imports the bundled production shell-parser. + * 2. Calls `initializeParser()`. + * 3. Asserts Bash is available and PowerShell is unavailable. + * 4. Exits cleanly. + * + * If the production code accidentally loaded the PowerShell grammar under Node + * (e.g., the `isBunRuntime()` guard regressed), the process would crash at + * shutdown with a V8 "Zone" out-of-memory error (observed under Node 24), + * causing `execFileSync` to throw. The explicit `pwshAvailable: false` assertion + * is a belt-and-suspenders check. + */ +describe('shell-parser: Node production smoke', () => { + it('Node loads production shell-parser: Bash available, PowerShell unavailable, clean exit', async () => { + // Temp dir inside the workspace so Node module resolution walks up to + // find node_modules (web-tree-sitter and grammar WASM files). + const tmpParent = join(repoRoot, 'tmp'); + mkdirSync(tmpParent, { recursive: true }); + const tempDir = mkdtempSync(join(tmpParent, 'node-prod-smoke-')); + const wrapperPath = join(tempDir, 'entry.ts'); + const debugLoggerStubPath = join(tempDir, 'DebugLogger.ts'); + const outPath = join(tempDir, 'entry.js'); + + try { + // The parser's logging implementation belongs to another workspace and is + // irrelevant to this smoke. Stub that boundary so isolated CI shards can + // bundle the production parser without requiring prebuilt workspace dist. + writeFileSync( + debugLoggerStubPath, + `export class DebugLogger { + constructor(_namespace: string) {} + log(..._args: unknown[]) {} + warn(..._args: unknown[]) {} + error(..._args: unknown[]) {} +} +`, + ); + + // Wrapper that exercises the REAL production shell-parser module. + writeFileSync( + wrapperPath, + `import { + initializeParser, + isParserAvailable, + resetParser, +} from ${JSON.stringify(shellParserPath)}; + +const ok = await initializeParser(); +const result = { + initOk: ok, + bashAvailable: isParserAvailable('bash'), + pwshAvailable: isParserAvailable('powershell'), +}; +resetParser(); +console.log(JSON.stringify(result)); +`, + ); + + // Bundle the wrapper + production shell-parser + all relative deps. + // Externalize web-tree-sitter so Node resolves it from node_modules; + // bundle runtime.ts and substitute only the unrelated logger boundary. + const buildResult = await build({ + entrypoints: [wrapperPath], + outdir: tempDir, + target: 'node', + format: 'esm', + external: ['web-tree-sitter'], + plugins: [ + { + name: 'debug-logger-workspace-boundary', + setup(builder) { + builder.onResolve( + { + filter: + /^@vybestack\/llxprt-code-telemetry\/debug\/DebugLogger\.js$/, + }, + () => ({ path: debugLoggerStubPath }), + ); + }, + }, + ], + }); + + if (!buildResult.success) { + throw new Error( + 'Bun.build failed: ' + + buildResult.logs.map((l) => String(l)).join('; '), + ); + } + + // Explicit output-file existence check: verify Bun.build actually + // emitted the entry point before spawning Node (#3181 OCR). + if (!existsSync(outPath)) { + throw new Error( + `Bun.build reported success but output file was not created: ${outPath}`, + ); + } + + // Spawn Node to run the bundled production code. Keep only the spawn + // in the try so the catch reports real spawn/exit failures; assertion + // failures below are allowed to surface with their own diagnostics (#3198). + let stdout: string; + try { + stdout = execFileSync('node', [outPath], { + cwd: tempDir, + encoding: 'utf8', + timeout: 30_000, + env: { ...process.env }, + }); + } catch (error: unknown) { + const status = isNodeExecError(error) ? error.status : undefined; + const stderrText = isNodeExecError(error) + ? bufferToText(error.stderr) + : ''; + const stdoutText = isNodeExecError(error) + ? bufferToText(error.stdout) + : ''; + const messageText = isNodeExecError(error) ? error.message : ''; + const parts = [stderrText, stdoutText, messageText]; + const detail = parts.find((p): p is string => !!p && p.length > 0); + throw new Error( + `Node production smoke failed (exit ${status ?? 'unknown'}): ` + + `${detail ?? String(error)}`, + ); + } + + const result = JSON.parse(stdout.trim()) as { + initOk: boolean; + bashAvailable: boolean; + pwshAvailable: boolean; + }; + + // Bash grammar must load and be available under Node. + expect(result.initOk).toBe(true); + expect(result.bashAvailable).toBe(true); + // PowerShell grammar must NOT be loaded under Node (isBunRuntime guard). + expect(result.pwshAvailable).toBe(false); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }, 60_000); +}); diff --git a/packages/core/src/utils/shell-parser-pwsh.test.ts b/packages/core/src/utils/shell-parser-pwsh.test.ts new file mode 100644 index 0000000000..1b2c688097 --- /dev/null +++ b/packages/core/src/utils/shell-parser-pwsh.test.ts @@ -0,0 +1,595 @@ +/** + * @license + * Copyright 2026 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { expect, describe, it, beforeAll, afterAll } from 'bun:test'; +import { execFileSync } from 'node:child_process'; +import { + initializeParser, + resetParser, + isParserAvailable, + parseCommandDetailsForLanguage, + parseShellCommandForLanguage, + extractCommandNamesForLanguage, + hasCommandSubstitutionForLanguage, +} from './shell-parser.js'; + +await initializeParser(); +const pwshAvailable = isParserAvailable('powershell'); +if (!pwshAvailable) { + throw new Error('PowerShell grammar failed to load under Bun'); +} +const describePwsh = describe.skipIf(!pwshAvailable); + +async function restoreParsers(): Promise { + const initialized = await initializeParser(); + if (!initialized || !isParserAvailable('powershell')) { + throw new Error( + 'PowerShell parser restoration failed after lifecycle tests', + ); + } +} + +describePwsh('shell-parser: tree-sitter-pwsh grammar', () => { + beforeAll(() => { + if (!isParserAvailable('powershell')) { + throw new Error('PowerShell grammar failed to load'); + } + }); + + describe('valid PowerShell constructs are not syntax errors', () => { + const validSamples: Array<[string, string]> = [ + [ + 'semicolon-chained if-exit', + 'git status --short --branch; git checkout main; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }', + ], + ['variable assignment + cmdlet', '$result = Get-Content path/to/file'], + ['ForEach-Object script block', 'ForEach-Object { Write-Host $_ }'], + ['array pipeline', '@(1,2,3) | ForEach-Object { $_ * 2 }'], + [ + 'Where-Object pipeline', + 'Get-Process | Where-Object { $_.Name -eq "x" }', + ], + ['call operator literal', '& "C:\\tool.exe" arg1'], + ['dot-source literal', '. .\\script.ps1'], + ['Invoke-Expression', 'Invoke-Expression $cmd'], + ['Start-Process', 'Start-Process notepad.exe'], + ['property access', '$value.Name'], + ['method call', '$value.Trim()'], + ['redirection', 'Get-Process *>&1'], + ['nested command in subexpression', '$(Get-ChildItem)'], + [ + 'multiline if/foreach', + 'foreach ($item in $collection) { Write-Host $item }', + ], + ]; + + for (const [label, cmd] of validSamples) { + it(`accepts ${label}`, () => { + const result = parseCommandDetailsForLanguage(cmd, 'powershell'); + expect(result).not.toBeNull(); + expect(result!.hasError).toBe(false); + }); + } + + it('accepts static .NET method invocation without a syntax error', () => { + const result = parseCommandDetailsForLanguage( + '[System.IO.File]::ReadAllText("test.txt")', + 'powershell', + ); + expect(result).not.toBeNull(); + expect(result!.hasError).toBe(false); + }); + + it('accepts static .NET process start without a syntax error', () => { + const result = parseCommandDetailsForLanguage( + '[System.Diagnostics.Process]::Start("cmd.exe")', + 'powershell', + ); + expect(result).not.toBeNull(); + expect(result!.hasError).toBe(false); + }); + }); + + describe('malformed PowerShell is a syntax error', () => { + it('rejects incomplete pipeline', () => { + const result = parseCommandDetailsForLanguage( + 'Get-ChildItem |', + 'powershell', + ); + expect(result).not.toBeNull(); + expect(result!.hasError).toBe(true); + expect(result!.errorReason).toContain('tree-sitter-pwsh'); + }); + + it('rejects incomplete if', () => { + const result = parseCommandDetailsForLanguage('if (', 'powershell'); + expect(result).not.toBeNull(); + expect(result!.hasError).toBe(true); + expect(result!.errorReason).toContain('tree-sitter-pwsh'); + }); + + it('reports a useful row:column location', () => { + const result = parseCommandDetailsForLanguage( + 'Get-ChildItem |', + 'powershell', + ); + expect(result).not.toBeNull(); + expect(result!.errorReason).toMatch(/\d{1,4}:\d{1,4}/u); + }); + }); + + describe('command extraction', () => { + it('extracts static command names from pipelines', () => { + const tree = parseShellCommandForLanguage( + 'Get-Process | Where-Object { $_.Name -eq "x" }', + 'powershell', + ); + expect(tree).not.toBeNull(); + const names = extractCommandNamesForLanguage(tree!, 'powershell'); + expect(names).toContain('Get-Process'); + expect(names).toContain('Where-Object'); + }); + + it('extracts commands recursively from script blocks', () => { + const tree = parseShellCommandForLanguage( + 'ForEach-Object { Write-Host $_ }', + 'powershell', + ); + expect(tree).not.toBeNull(); + const names = extractCommandNamesForLanguage(tree!, 'powershell'); + expect(names).toContain('ForEach-Object'); + expect(names).toContain('Write-Host'); + }); + + it('extracts commands from subexpressions', () => { + const tree = parseShellCommandForLanguage( + '$(Get-ChildItem)', + 'powershell', + ); + expect(tree).not.toBeNull(); + const names = extractCommandNamesForLanguage(tree!, 'powershell'); + expect(names).toContain('Get-ChildItem'); + }); + + it('extracts literal call-operator targets as static names', () => { + const tree = parseShellCommandForLanguage( + '& "C:\\tools\\my-tool.exe"', + 'powershell', + ); + expect(tree).not.toBeNull(); + const names = extractCommandNamesForLanguage(tree!, 'powershell'); + expect(names).toContain('my-tool.exe'); + }); + + it('does not fabricate command names for .NET expressions', () => { + const tree = parseShellCommandForLanguage( + '[System.IO.File]::ReadAllText("x")', + 'powershell', + ); + expect(tree).not.toBeNull(); + const names = extractCommandNamesForLanguage(tree!, 'powershell'); + expect(names).not.toContain('System'); + }); + }); + + describe('command substitution detection', () => { + it('detects $() subexpressions as substitution', () => { + const tree = parseShellCommandForLanguage( + '$(Get-ChildItem)', + 'powershell', + ); + expect(tree).not.toBeNull(); + expect(hasCommandSubstitutionForLanguage(tree!, 'powershell')).toBe(true); + }); + + it('does NOT treat PowerShell backticks as substitution', () => { + const tree = parseShellCommandForLanguage( + 'Write-Host `n "hello"', + 'powershell', + ); + expect(tree).not.toBeNull(); + expect(hasCommandSubstitutionForLanguage(tree!, 'powershell')).toBe( + false, + ); + }); + }); + + describe('invocation expression classification', () => { + const expressionCases: Array<[string, string]> = [ + [ + 'static type literal Process::Start', + '[System.Diagnostics.Process]::Start("cmd.exe")', + ], + ['instance method Start', '$obj.Start("cmd.exe")'], + ['instance method nested in args', 'Write-Host ($obj.GetName())'], + ['benign instance method Trim', '$value.Trim()'], + [ + 'static method via variable', + '$type = [System.Diagnostics.Process]; $type::Start("cmd.exe")', + ], + ]; + + for (const [label, cmd] of expressionCases) { + it(`classifies ${label} as expression`, () => { + const result = parseCommandDetailsForLanguage(cmd, 'powershell'); + expect(result).not.toBeNull(); + expect(result!.hasError).toBe(false); + const exprDetail = result!.details.find( + (d) => d.nameKind === 'expression', + ); + expect(exprDetail).toBeDefined(); + }); + } + + it('default-allow: benign method still produces valid syntax', () => { + const result = parseCommandDetailsForLanguage( + '$value.Trim()', + 'powershell', + ); + expect(result).not.toBeNull(); + expect(result!.hasError).toBe(false); + }); + }); + + describe('canonical command detail text for invocation targets', () => { + it('normalizes literal & path target to basename with canonicalText', () => { + const result = parseCommandDetailsForLanguage( + "& 'C:\\tools\\my-tool.exe' --safe", + 'powershell', + ); + expect(result).not.toBeNull(); + expect(result!.hasError).toBe(false); + const detail = result!.details.find((d) => d.name === 'my-tool.exe'); + expect(detail).toBeDefined(); + expect(detail!.canonicalText).toContain('my-tool.exe'); + expect(detail!.canonicalText).toContain('--safe'); + }); + + it('does not require ShellTool(&) for literal call targets', () => { + const result = parseCommandDetailsForLanguage( + '& "C:\\tools\\git.exe" status', + 'powershell', + ); + expect(result).not.toBeNull(); + const detail = result!.details.find((d) => d.name === 'git.exe'); + expect(detail).toBeDefined(); + expect(detail!.canonicalText?.startsWith('&')).toBe(false); + }); + + it('classifies expandable string target as dynamic', () => { + const result = parseCommandDetailsForLanguage( + '& "$env:PROGRAMFILES\\tool.exe"', + 'powershell', + ); + expect(result).not.toBeNull(); + const detail = result!.details[0]; + expect(detail.nameKind).toBe('dynamic'); + }); + + it('dot-source literal normalizes to basename', () => { + const result = parseCommandDetailsForLanguage( + '. .\\script.ps1', + 'powershell', + ); + expect(result).not.toBeNull(); + const detail = result!.details.find((d) => d.name === 'script.ps1'); + expect(detail).toBeDefined(); + }); + }); + + describe('PowerShell string literal decoding semantics', () => { + // OCR Finding 9 (#3181): A real Windows PowerShell probe confirmed that + // single-quoted here-strings do NOT collapse doubled single quotes. + // [Console]::WriteLine(@'foo''bar'@) prints foo''bar. This test locks + // that behavior so a future "fix" does not regress it. + it('single-quoted here-string preserves doubled single quotes', () => { + const nl = String.fromCharCode(10); + const result = parseCommandDetailsForLanguage( + "iex @'" + nl + "Write-Host foo''bar" + nl + "'@", + 'powershell', + ); + expect(result).not.toBeNull(); + expect(result!.hasError).toBe(false); + const writeHost = result!.details.find((d) => d.name === 'Write-Host'); + expect(writeHost).toBeDefined(); + expect(writeHost!.text).toContain("foo''bar"); + }); + + // OCR Finding 2 (#3181): Double-quoted strings DO collapse doubled + // double-quotes: "hello ""world""" decodes to hello "world". + it('double-quoted literal collapses doubled double-quotes', () => { + const result = parseCommandDetailsForLanguage( + 'iex "Write-Host hello ""world"""', + 'powershell', + ); + expect(result).not.toBeNull(); + expect(result!.hasError).toBe(false); + const writeHost = result!.details.find((d) => d.name === 'Write-Host'); + expect(writeHost).toBeDefined(); + expect(writeHost!.text).toContain('hello "world"'); + expect(writeHost!.text).not.toContain('""world""'); + }); + }); + + describe('Bash parser remains unchanged', () => { + it('still parses bash commands', () => { + const result = parseCommandDetailsForLanguage('ls -la /tmp', 'bash'); + expect(result).not.toBeNull(); + expect(result!.hasError).toBe(false); + expect(result!.details.some((d) => d.name === 'ls')).toBe(true); + }); + + it('still rejects malformed bash', () => { + const result = parseCommandDetailsForLanguage('ls &&', 'bash'); + expect(result).not.toBeNull(); + expect(result!.hasError).toBe(true); + }); + }); +}); + +describe.skipIf(!pwshAvailable)('shell-parser: pwsh clean lifecycle', () => { + afterAll(restoreParsers); + + it('initializes, resets with disposal, and re-initializes cleanly', async () => { + expect(isParserAvailable('powershell')).toBe(true); + expect(isParserAvailable('bash')).toBe(true); + resetParser(); + // After reset, both parsers must be gone — resources disposed. + expect(isParserAvailable('powershell')).toBe(false); + expect(isParserAvailable('bash')).toBe(false); + // Re-initialize so subsequent tests in the same bun process still + // have a working parser. + const reOk = await initializeParser(); + expect(reOk).toBe(true); + expect(isParserAvailable('powershell')).toBe(true); + expect(isParserAvailable('bash')).toBe(true); + }); + + it('resetParser() during initialization prevents stale publish', async () => { + resetParser(); + // Start initialization but do NOT await — the async body yields at + // `await import('web-tree-sitter')` before any parser is published. + const stalePromise = initializeParser(); + // Immediately reset while initialization is in flight. + resetParser(); + // Both languages must be unavailable right after reset. + expect(isParserAvailable('bash')).toBe(false); + expect(isParserAvailable('powershell')).toBe(false); + // Wait for the stale initialization to settle. A generation-safe + // initializer must NOT publish its allocated parsers over the + // newer reset state. + await stalePromise; + expect(isParserAvailable('bash')).toBe(false); + expect(isParserAvailable('powershell')).toBe(false); + // A subsequent fresh initialize must succeed. + const ok = await initializeParser(); + expect(ok).toBe(true); + expect(isParserAvailable('bash')).toBe(true); + expect(isParserAvailable('powershell')).toBe(true); + }); +}); + +/** + * Saved-corpus construct-family coverage (#3181 Finding 9). + * + * The original LLxprt recording contained 27 rejected run_shell_command tool + * responses. The exact saved recording is not available as a committed test + * fixture. Instead, this corpus reproduces every construct family documented + * in project-plans/issue3181/PLAN.md (variable assignment, .NET invocation, + * ForEach-Object/Where-Object script blocks, foreach/if statements, call + * operator, @() arrays, redirections, Start-Process, property/method access, + * semicolon-chained if-exit, subexpressions, multiline). Each entry asserts + * the tree-sitter-pwsh grammar does NOT produce a syntax error, proving + * valid PowerShell is no longer hard-denied as malformed. + */ +describe.skipIf(!pwshAvailable)('saved-corpus construct families', () => { + const corpus: Array<{ label: string; cmd: string }> = [ + // Family 1: variable assignment + cmdlet + { + label: 'var assign + Get-Content', + cmd: '$result = Get-Content path/to/file', + }, + // Family 2: .NET member invocation + { + label: '.NET static method', + cmd: '[System.IO.File]::ReadAllText("test.txt")', + }, + // Family 3: ForEach-Object script block + { label: 'ForEach-Object block', cmd: 'ForEach-Object { Write-Host $_ }' }, + // Family 4: Where-Object pipeline + { + label: 'Where-Object pipeline', + cmd: 'Get-Process | Where-Object { $_.Name -eq "x" }', + }, + // Family 5: foreach statement + { + label: 'foreach loop', + cmd: 'foreach ($item in $collection) { Write-Host $item }', + }, + // Family 6: if statement + { + label: 'if statement', + cmd: 'if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }', + }, + // Family 7: call operator literal + { label: 'call operator literal', cmd: '& "C:\\tool.exe" arg1' }, + // Family 8: @() array expression + pipeline + { + label: '@() array pipeline', + cmd: '@(1,2,3) | ForEach-Object { $_ * 2 }', + }, + // Family 9: *>&1 redirection + { label: '*>&1 redirection', cmd: 'Get-Process *>&1' }, + // Family 10: Start-Process + { label: 'Start-Process', cmd: 'Start-Process notepad.exe' }, + // Family 11: property access + { label: 'property access', cmd: '$value.Name' }, + // Family 12: method call + { label: 'method call', cmd: '$value.Trim()' }, + // Family 13: semicolon-chained if-exit (exact reproduction) + { + label: 'semicolon if-exit chain', + cmd: 'git status --short --branch; git checkout main; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }', + }, + // Family 14: subexpression + { label: 'subexpression', cmd: '$(Get-ChildItem)' }, + // Family 15: dot-source literal + { label: 'dot-source literal', cmd: '. .\\script.ps1' }, + // Family 16: Invoke-Expression + { label: 'Invoke-Expression', cmd: 'Invoke-Expression $cmd' }, + // Family 17: multiline source + { + label: 'multiline foreach', + cmd: 'foreach ($item in $collection) {\n Write-Host $item\n}', + }, + // Family 18: nested command in pipeline + { + label: 'pipeline nested commands', + cmd: 'Get-ChildItem | Select-Object Name | Sort-Object', + }, + // Family 19: variable assignment + method + { label: 'var + method chain', cmd: '$text = "hello"; $text.ToUpper()' }, + // Family 20: hash table + { label: 'hash table literal', cmd: '$h = @{ Key = "Value"; Num = 42 }' }, + // Family 21: try/catch + { + label: 'try/catch block', + cmd: 'try { Get-Item $path } catch { Write-Host "error" }', + }, + // Family 22: string comparison with -eq operator in an if statement + { + label: 'string comparison', + cmd: 'if ($value -eq "test") { Write-Host "match" }', + }, + // Family 23: array element access via indexer + { label: 'array index', cmd: '$first = $items[0]' }, + // Family 24: here-string + { label: 'here-string', cmd: '$s = @"\nhello\n"@' }, + // Family 25: ternary-style if + { + label: 'if expression in assignment', + cmd: '$x = if ($cond) { 1 } else { 2 }', + }, + // Family 26: pipeline with Where + Select + { + label: 'pipeline Where+Select', + cmd: 'Get-Process | Where-Object { $_.Id -gt 100 } | Select-Object Name, Id', + }, + // Family 27: -join operator + { label: 'array -join', cmd: '$arr = @(1,2,3); $arr -join ","' }, + ]; + + for (const { label, cmd } of corpus) { + it(`corpus: ${label} is not a syntax error`, () => { + const result = parseCommandDetailsForLanguage(cmd, 'powershell'); + expect(result).not.toBeNull(); + expect(result!.hasError).toBe(false); + }); + } + + it('corpus has exactly 27 entries covering all documented families', () => { + expect(corpus.length).toBe(27); + }); +}); + +/** + * Exact .NET root assertions (#3181 Finding 9). + * + * Pure .NET expressions must not produce fabricated Bash-style roots. + * The tree-sitter-pwsh grammar classifies them as expression details, + * which are excluded from command name extraction. + */ +describe.skipIf(!pwshAvailable)('exact .NET roots', () => { + it('static .NET method produces no command names', () => { + const tree = parseShellCommandForLanguage( + '[System.Diagnostics.Process]::Start("cmd.exe")', + 'powershell', + ); + expect(tree).not.toBeNull(); + const names = extractCommandNamesForLanguage(tree!, 'powershell'); + expect(names).toEqual([]); + }); + + it('instance method produces no command names', () => { + const tree = parseShellCommandForLanguage( + '$obj.Start("cmd.exe")', + 'powershell', + ); + expect(tree).not.toBeNull(); + const names = extractCommandNamesForLanguage(tree!, 'powershell'); + expect(names).toEqual([]); + }); + + it('nested .NET in arguments produces only outer command name', () => { + const tree = parseShellCommandForLanguage( + 'Write-Host ([System.IO.File]::ReadAllText("x"))', + 'powershell', + ); + expect(tree).not.toBeNull(); + const names = extractCommandNamesForLanguage(tree!, 'powershell'); + expect(names).toEqual(['Write-Host']); + }); +}); + +/** + * Windows-only bounded Parser.ParseInput conformance (#3181 Finding 9). + * + * On Windows, compare a subset of the corpus against the semantic ground + * truth: [System.Management.Automation.Language.Parser]::ParseInput. The + * command source is passed as DATA via stdin to the helper script — it is + * NEVER interpolated into the script body. ParseInput parses but does not + * execute the input. This test NEVER executes any corpus command. + */ +describe.skipIf(process.platform !== 'win32')( + 'Parser.ParseInput conformance (Windows-only)', + () => { + const conformanceSubset = [ + 'Get-Process | Where-Object { $_.Name -eq "x" }', + 'ForEach-Object { Write-Host $_ }', + 'if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }', + '$(Get-ChildItem)', + '@(1,2,3) | ForEach-Object { $_ * 2 }', + ]; + + for (const cmd of conformanceSubset) { + it(`ParseInput agrees tree-sitter-pwsh accepts: ${cmd.substring(0, 40)}`, () => { + // First verify tree-sitter-pwsh accepts it. + const tsResult = parseCommandDetailsForLanguage(cmd, 'powershell'); + expect(tsResult?.hasError).toBe(false); + + // Then verify PowerShell's Parser.ParseInput also accepts it, + // passing the command source as data via stdin (never interpolated). + const helperScript = + '$inputText = [Console]::In.ReadToEnd(); ' + + '$errors = $null; ' + + '$null = [System.Management.Automation.Language.Parser]::ParseInput(' + + '$inputText, [ref]$null, [ref]$errors); ' + + 'if ($errors.Count -gt 0) { exit 1 } else { exit 0 }'; + + try { + execFileSync( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-Command', helperScript], + { + input: cmd, + encoding: 'utf8', + timeout: 10_000, + }, + ); + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ) { + return; + } + throw error; + } + }, 15_000); + } + }, +); diff --git a/packages/core/src/utils/shell-parser.ts b/packages/core/src/utils/shell-parser.ts index 39227e9fd5..9e98bf8b42 100644 --- a/packages/core/src/utils/shell-parser.ts +++ b/packages/core/src/utils/shell-parser.ts @@ -25,6 +25,13 @@ import type { import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { DebugLogger } from '../debug/DebugLogger.js'; +import { isBunRuntime } from './runtime.js'; +import { + collectPwshCommandDetailsFromTree, + hasPwshCommandSubstitution, + splitPwshCommandsWithTree, +} from './powershell-ast.js'; +import { buildPwshCommandParseResult } from './powershell-parse-result.js'; const require = createRequire(import.meta.url); const debugLogger = new DebugLogger('llxprt:shell-parser'); @@ -140,6 +147,24 @@ async function resolveBashWasmBytes(): Promise { return new Uint8Array(readFileSync(wasmPath)); } +/** + * Resolve the PowerShell grammar WASM bytes from `tree-sitter-pwsh`. + */ +async function resolvePwshWasmBytes(): Promise { + let wasmPath: string; + try { + wasmPath = require.resolve('tree-sitter-pwsh/tree-sitter-powershell.wasm'); + } catch (error) { + throw new Error( + 'Could not resolve the tree-sitter-pwsh grammar WASM ' + + '(tree-sitter-pwsh/tree-sitter-powershell.wasm). Ensure the ' + + '`tree-sitter-pwsh` dependency is installed.', + { cause: error }, + ); + } + return new Uint8Array(readFileSync(wasmPath)); +} + // Type definitions for tree-sitter query results interface QueryCapture { name: string; @@ -151,11 +176,39 @@ interface QueryMatch { captures: QueryCapture[]; } +/** + * Identifies which grammar language to use for parsing. + */ +export type ParserLanguage = 'bash' | 'powershell'; + let parser: ParserType | null = null; let bashLanguage: Language | null = null; +let pwshParser: ParserType | null = null; +let pwshLanguage: Language | null = null; let initializationPromise: Promise | null = null; let initializationError: Error | null = null; +/** + * Monotonic generation counter for race-safe initialization. Each + * `resetParser()` call increments this value. An in-flight initialization + * captures the generation at start and only publishes its results if the + * generation has not changed, preventing a stale promise from overwriting a + * newer reset or re-initialization (#3181). + */ +let generation = 0; + +/** + * Delete a web-tree-sitter Parser object if the runtime exposes `delete()`. + * The `Language` type has no `delete()` method in web-tree-sitter 0.25.x; only + * `Parser` and `Query` expose explicit disposal. Trees remain the caller's + * responsibility. + */ +function safeDeleteParser(p: ParserType | null): void { + if (p !== null && typeof p.delete === 'function') { + p.delete(); + } +} + /** * Get the initialization error, if any. * Useful for debugging why tree-sitter failed to load. @@ -174,11 +227,18 @@ export function initializeParser(): Promise { return initializationPromise; } - initializationPromise = performParserInitialization(); + const initGeneration = generation; + initializationPromise = performParserInitialization(initGeneration); return initializationPromise; } -async function performParserInitialization(): Promise { +async function performParserInitialization( + initGeneration: number, +): Promise { + // Declare local parsers outside the try so the outer catch can dispose + // them if an error occurs after allocation but before publication (#3181). + let localParser: ParserType | null = null; + let localPwshParser: ParserType | null = null; try { const TreeSitter = (await import('web-tree-sitter')) as TreeSitterModule; const parserCandidate = TreeSitter.Parser; @@ -190,7 +250,6 @@ async function performParserInitialization(): Promise { ) as (new () => ParserType) & { init(): Promise }; await Parser.init(); - parser = new Parser(); const LanguageLoader = resolveTreeSitterLanguage( TreeSitter.Language, @@ -202,46 +261,88 @@ async function performParserInitialization(): Promise { ); } - const wasmBytes = await resolveBashWasmBytes(); - bashLanguage = await LanguageLoader.load(wasmBytes); - parser.setLanguage(bashLanguage); + // Load the Bash grammar (required for all paths) into local variables. + const bashWasmBytes = await resolveBashWasmBytes(); + const localBashLanguage = await LanguageLoader.load(bashWasmBytes); + localParser = new Parser(); + localParser.setLanguage(localBashLanguage); + + // PowerShell grammar loads only under Bun: the tree-sitter-pwsh WASM + // causes a V8 Zone OOM crash at shutdown under Node 24. Under Node, + // PowerShell validation fails closed with a truthful diagnostic (#3181). + let localPwshLanguage: Language | null = null; + if (isBunRuntime()) { + try { + const pwshWasmBytes = await resolvePwshWasmBytes(); + localPwshLanguage = await LanguageLoader.load(pwshWasmBytes); + localPwshParser = new Parser(); + localPwshParser.setLanguage(localPwshLanguage); + } catch (pwshError) { + safeDeleteParser(localPwshParser); + localPwshParser = null; + localPwshLanguage = null; + debugLogger.warn( + 'PowerShell grammar initialization failed; PowerShell ' + + 'validation will fail closed:', + pwshError, + ); + } + } + + // If resetParser() was called mid-init, do not publish over newer state. + if (initGeneration !== generation) { + safeDeleteParser(localParser); + safeDeleteParser(localPwshParser); + return false; + } + + // Publish resources to module-level state. + parser = localParser; + bashLanguage = localBashLanguage; + pwshParser = localPwshParser; + pwshLanguage = localPwshLanguage; return true; } catch (error) { - initializationError = - error instanceof Error ? error : new Error(String(error)); - parser = null; - bashLanguage = null; + safeDeleteParser(localParser); + safeDeleteParser(localPwshParser); + if (initGeneration === generation) { + initializationError = + error instanceof Error ? error : new Error(String(error)); + parser = pwshParser = null; + bashLanguage = pwshLanguage = null; + } return false; } } /** - * Check if the tree-sitter parser is available. + * Check if the parser is available for the given language. */ -export function isParserAvailable(): boolean { +export function isParserAvailable(language: ParserLanguage = 'bash'): boolean { + if (language === 'powershell') { + return pwshParser !== null && pwshLanguage !== null; + } return parser !== null && bashLanguage !== null; } /** - * Parse a shell command string and return the syntax tree. - * Returns null if parser is not available, the command is empty, or parsing - * times out (default 1 s). Callers that receive null should either fall back - * to regex parsing or reject the command outright. + * Shared parse-with-timeout logic for Bash and PowerShell parsers. + * A cancelled parse leaves the parser in a resume state; reset so the next + * parse starts fresh (#3181). */ -export function parseShellCommand( +function parseWithTimeout( + activeParser: ParserType, command: string, - timeoutMicros: number = PARSE_TIMEOUT_MICROS, + timeoutMicros: number, + label: string, + logCatchErrors: boolean, ): Tree | null { - if (!parser || !command.trim()) { - return null; - } - const deadline = performance.now() + timeoutMicros / 1000; const parseState = { timedOut: false }; try { - const tree = parser.parse(command, null, { + const tree = activeParser.parse(command, null, { progressCallback: () => { if (performance.now() > deadline) { parseState.timedOut = true; @@ -252,17 +353,97 @@ export function parseShellCommand( }); if (parseState.timedOut) { - debugLogger.error('Bash command parsing timed out for command:', command); - // A cancelled parse leaves the parser in a resume state; reset so the - // next parse starts fresh rather than resuming the cancelled command. - parser.reset(); + debugLogger.error( + `${label} command parsing timed out for command:`, + command, + ); + activeParser.reset(); return null; } - return tree; - } catch { + } catch (error) { + if (logCatchErrors) { + debugLogger.error(`${label} parse threw (command text omitted):`, error); + } + return null; + } +} + +/** + * Parse a shell command string and return the syntax tree. + * Returns null if parser is not available, the command is empty, or parsing + * times out (default 1 s). Callers that receive null should either fall back + * to regex parsing or reject the command outright. + */ +export function parseShellCommand( + command: string, + timeoutMicros: number = PARSE_TIMEOUT_MICROS, +): Tree | null { + if (!parser || !command.trim()) { return null; } + return parseWithTimeout(parser, command, timeoutMicros, 'Bash', false); +} + +/** + * Parse using the grammar for the specified language. + */ +export function parseShellCommandForLanguage( + command: string, + language: ParserLanguage = 'bash', + timeoutMicros: number = PARSE_TIMEOUT_MICROS, +): Tree | null { + if (language === 'powershell') { + return parsePwshCommand(command, timeoutMicros); + } + return parseShellCommand(command, timeoutMicros); +} + +function parsePwshCommand(command: string, timeoutMicros: number): Tree | null { + if (!pwshParser || !command.trim()) { + return null; + } + return parseWithTimeout( + pwshParser, + command, + timeoutMicros, + 'PowerShell', + true, + ); +} + +/** + * Extract command names for the given language. PowerShell excludes + * dynamic/expression targets (no resolvable name). + */ +export function extractCommandNamesForLanguage( + tree: Tree, + language: ParserLanguage = 'bash', +): string[] { + if (language === 'powershell') { + return collectPwshCommandDetailsFromTree( + tree, + tree.rootNode.text, + parseCommandDetailsForLanguage, + ) + .filter((d) => d.nameKind !== 'dynamic' && d.nameKind !== 'expression') + .map((d) => d.name); + } + return extractCommandNames(tree); +} + +/** + * Check for command substitution. PowerShell `$()` is substitution; + * backticks are escapes, not substitution. + */ +export function hasCommandSubstitutionForLanguage( + tree: Tree, + language: ParserLanguage = 'bash', +): boolean { + if (language === 'powershell') { + return hasPwshCommandSubstitution(tree.rootNode); + } + return hasCommandSubstitution(tree); } /** @@ -309,18 +490,32 @@ function collectCommandNamesFromCaptures( /** * Parsed command detail containing the command name and full text. + * `nameKind` distinguishes static (resolvable), dynamic (unresolvable + * target), and expression (.NET invocation) targets. Bash details are + * always `static`. */ export interface ParsedCommandDetail { name: string; text: string; + /** + * Canonical text used for policy matching (blocklist/allowlist). This is the + * command text normalized to the executable basename/root so that policy + * matching does not require broad patterns like `ShellTool(&)`. For example, + * `& 'C:/tools/my-tool.exe' --safe` has canonicalText + * `my-tool.exe --safe`. When absent, callers should fall back to `text`. + */ + canonicalText?: string; + nameKind?: 'static' | 'dynamic' | 'expression'; } /** - * Result of parsing command details. + * Result of parsing command details. `hasError` reflects parser/syntax + * validity only; policy enforcement is the caller's responsibility. */ export interface CommandParseResult { details: ParsedCommandDetail[]; hasError: boolean; + errorReason?: string; } type SourceRange = { @@ -811,13 +1006,21 @@ function extractCommands( } /** - * Reset the parser state (primarily for testing). + * Reset the parser state. Increments the generation counter so any in-flight + * initialization recognizes it is stale and will not publish. Deletes the + * current Bash and PowerShell Parser objects to free WASM resources. Language + * objects have no delete() method in web-tree-sitter 0.25.x. Trees remain the + * caller's responsibility (primarily for testing). */ export function resetParser(): void { + generation += 1; + safeDeleteParser(parser); + safeDeleteParser(pwshParser); parser = null; + pwshParser = null; bashLanguage = null; - initializationPromise = null; - initializationError = null; + pwshLanguage = null; + initializationPromise = initializationError = null; } function isNode(node: Node | null): node is Node { @@ -871,10 +1074,6 @@ export function detectTrailingBackgroundOperator( return { promoted: false, command }; } - if (root.childCount === 0) { - return { promoted: false, command }; - } - const lastChild = root.child(root.childCount - 1); if (lastChild === null || lastChild.type !== '&') { return { promoted: false, command }; @@ -891,3 +1090,53 @@ export function detectTrailingBackgroundOperator( return { promoted: true, command: stripped }; } + +// --------------------------------------------------------------------------- +// PowerShell-specific parsing (#3181) +// --------------------------------------------------------------------------- + +export function parseCommandDetailsForLanguage( + command: string, + language: ParserLanguage = 'bash', +): CommandParseResult | null { + return language === 'powershell' + ? parsePwshCommandDetails(command) + : parseCommandDetails(command); +} + +/** + * Split commands using the grammar for the specified language. + */ +export function splitCommandsWithTreeForLanguage( + tree: Tree, + language: ParserLanguage = 'bash', + options?: SplitCommandsTreeOptions, +): string[] { + if (language === 'powershell') { + return splitPwshCommandsWithTree(tree, options); + } + return splitCommandsWithTree(tree, options); +} + +/** + * Parse PowerShell source and extract all command details with security-aware + * classification of static, dynamic, and expression targets. Mirrors the Bash + * path's try/catch so an unexpected tree-sitter internal error fails closed + * (returns null = parser-unavailable) rather than propagating (#3181 OCR + * Finding 7). + */ +function parsePwshCommandDetails(command: string): CommandParseResult | null { + if (pwshParser === null || pwshLanguage === null) { + return null; + } + try { + return buildPwshCommandParseResult( + parsePwshCommand(command, PARSE_TIMEOUT_MICROS), + command, + parseCommandDetailsForLanguage, + ); + } catch (error) { + debugLogger.error('PowerShell parse threw (command text omitted):', error); + return null; + } +} diff --git a/packages/core/src/utils/shell-utils.detectSubstitution.test.ts b/packages/core/src/utils/shell-utils.detectSubstitution.test.ts index 2899ceaca2..804b457bad 100644 --- a/packages/core/src/utils/shell-utils.detectSubstitution.test.ts +++ b/packages/core/src/utils/shell-utils.detectSubstitution.test.ts @@ -4,105 +4,92 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { expect, describe, it, vi } from 'bun:test'; +import { expect, describe, it, beforeAll, afterAll } from 'bun:test'; /** * Tests for detectCommandSubstitution through the REGEX FALLBACK path. * - * These tests mock shell-parser.js so isParserAvailable() returns false, + * The parser singleton is reset so isParserAvailable() returns false, * forcing detectCommandSubstitution to use detectCommandSubstitutionRegex. + * All cases exercise Bash syntax and pass 'bash' explicitly (#3181). + * + * resetParser/initializeParser are used instead of vi.mock to avoid + * cross-file mock leakage in bun:test. */ -// Hoisted mock: applies to all tests in this file. +import { detectCommandSubstitution } from './shell-utils.js'; +import { resetParser, initializeParser } from './shell-parser.js'; -const realShellParserModule = { ...(await import('./shell-parser.js')) }; -void vi.mock('./shell-parser.js', () => ({ - ...realShellParserModule, - isParserAvailable: () => false, - parseShellCommand: () => null, - extractCommandNames: () => [], - hasCommandSubstitution: () => false, - splitCommandsWithTree: () => [], - parseCommandDetails: () => null, -})); +/** Bash-specific substitution detection (#3181). */ +const detect = (cmd: string): boolean => detectCommandSubstitution(cmd, 'bash'); describe('detectCommandSubstitution regex fallback', () => { - // Extended timeout: the first dynamic import after vi.doMock re-transforms - // the shell-utils module graph, which can exceed the default 5s timeout - // under coverage instrumentation. Subsequent imports hit the module cache. - it('should detect unterminated backtick substitution', async () => { - // BUG CASE: opening backtick without closing backtick - const { detectCommandSubstitution } = await import('./shell-utils.js'); - expect(detectCommandSubstitution('echo `date')).toBe(true); - }, 15000); + beforeAll(() => { + resetParser(); + }); + + afterAll(async () => { + await initializeParser(); + }); + + it('should detect unterminated backtick substitution', () => { + expect(detect('echo `date')).toBe(true); + }); - it('should detect properly paired backtick substitution', async () => { - const { detectCommandSubstitution } = await import('./shell-utils.js'); - expect(detectCommandSubstitution('echo `date`')).toBe(true); + it('should detect properly paired backtick substitution', () => { + expect(detect('echo `date`')).toBe(true); }); - it('should detect backtick substitution inside double quotes', async () => { - const { detectCommandSubstitution } = await import('./shell-utils.js'); - expect(detectCommandSubstitution('echo "`date`"')).toBe(true); + it('should detect backtick substitution inside double quotes', () => { + expect(detect('echo "`date`"')).toBe(true); }); - it('should NOT detect backtick substitution inside single quotes', async () => { - const { detectCommandSubstitution } = await import('./shell-utils.js'); - expect(detectCommandSubstitution("echo '`date`'")).toBe(false); + it('should NOT detect backtick substitution inside single quotes', () => { + expect(detect("echo '`date`'")).toBe(false); }); - it('should NOT detect escaped backticks', async () => { - const { detectCommandSubstitution } = await import('./shell-utils.js'); - expect(detectCommandSubstitution('echo \\`date\\`')).toBe(false); + it('should NOT detect escaped backticks', () => { + expect(detect('echo \\`date\\`')).toBe(false); }); - it('should detect unterminated $() substitution', async () => { - const { detectCommandSubstitution } = await import('./shell-utils.js'); - expect(detectCommandSubstitution('echo $(date')).toBe(true); + it('should detect unterminated $() substitution', () => { + expect(detect('echo $(date')).toBe(true); }); - it('should detect $() substitution', async () => { - const { detectCommandSubstitution } = await import('./shell-utils.js'); - expect(detectCommandSubstitution('echo $(date)')).toBe(true); + it('should detect $() substitution', () => { + expect(detect('echo $(date)')).toBe(true); }); - it('should detect <() process substitution', async () => { - const { detectCommandSubstitution } = await import('./shell-utils.js'); - expect(detectCommandSubstitution('diff <(ls dir1) <(ls dir2)')).toBe(true); + it('should detect <() process substitution', () => { + expect(detect('diff <(ls dir1) <(ls dir2)')).toBe(true); }); - it('should detect >() process substitution', async () => { - const { detectCommandSubstitution } = await import('./shell-utils.js'); - expect(detectCommandSubstitution('tee >(wc -l)')).toBe(true); + it('should detect >() process substitution', () => { + expect(detect('tee >(wc -l)')).toBe(true); }); - it('should NOT detect substitution-like text in single quotes', async () => { - const { detectCommandSubstitution } = await import('./shell-utils.js'); - expect(detectCommandSubstitution("echo '$(date)'")).toBe(false); + it('should NOT detect substitution-like text in single quotes', () => { + expect(detect("echo '$(date)'")).toBe(false); }); - it('should return false for simple commands with no substitution', async () => { - const { detectCommandSubstitution } = await import('./shell-utils.js'); - expect(detectCommandSubstitution('ls -la /tmp')).toBe(false); + it('should return false for simple commands with no substitution', () => { + expect(detect('ls -la /tmp')).toBe(false); }); - it('should detect $() inside double quotes', async () => { - const { detectCommandSubstitution } = await import('./shell-utils.js'); - expect(detectCommandSubstitution('echo "Today is $(date)"')).toBe(true); + it('should detect $() inside double quotes', () => { + expect(detect('echo "Today is $(date)"')).toBe(true); }); - it('should NOT detect <() inside double quotes (process sub is unquoted only)', async () => { - const { detectCommandSubstitution } = await import('./shell-utils.js'); - expect(detectCommandSubstitution('echo "<(cmd)"')).toBe(false); + it('should NOT detect <() inside double quotes (process sub is unquoted only)', () => { + expect(detect('echo "<(cmd)"')).toBe(false); }); - it('should flag $((1+2)) arithmetic expansion via regex (conservative fallback)', async () => { + it('should flag $((1+2)) arithmetic expansion via regex (conservative fallback)', () => { // The regex fallback sees '$(' and flags it as command substitution. // Tree-sitter correctly identifies $((...)) as arithmetic expansion (NOT // command substitution), so the two paths differ. The regex fallback is // intentionally more conservative — false positives are safer than false // negatives in a security-sensitive fallback. - const { detectCommandSubstitution } = await import('./shell-utils.js'); - expect(detectCommandSubstitution('echo $((1+2))')).toBe(true); + expect(detect('echo $((1+2))')).toBe(true); }); }); diff --git a/packages/core/src/utils/shell-utils.multiline.test.ts b/packages/core/src/utils/shell-utils.multiline.test.ts index bf85baf916..475eb9cf87 100644 --- a/packages/core/src/utils/shell-utils.multiline.test.ts +++ b/packages/core/src/utils/shell-utils.multiline.test.ts @@ -59,9 +59,12 @@ function permissionDecision( mode: 'none' | 'allowlist', coreTools: string[], ): { allAllowed: boolean; isHardDenial: boolean } { + // Pass 'bash' explicitly: these tests exercise Bash heredoc/substitution (#3181). const result = checkCommandPermissions( command, createConfig(mode, coreTools), + undefined, + 'bash', ); return { allAllowed: result.allAllowed, @@ -79,26 +82,26 @@ describe.skipIf(!isParserAvailable())( UNQUOTED_DOLLAR_HEREDOC, ...MALFORMED_SUBSTITUTIONS, ])('detects executable substitution syntax in %j', (command) => { - expect(detectCommandSubstitution(command)).toBe(true); + expect(detectCommandSubstitution(command, 'bash')).toBe(true); }); it.each([QUOTED_BACKTICK_HEREDOC, ...QUOTED_DELIMITER_HEREDOCS])( 'treats quoted heredoc contents as literal in %j', (command) => { - expect(detectCommandSubstitution(command)).toBe(false); + expect(detectCommandSubstitution(command, 'bash')).toBe(false); }, ); it('detects paired backticks in an unquoted heredoc body', () => { - expect(detectCommandSubstitution(UNQUOTED_PAIRED_BACKTICK_HEREDOC)).toBe( - true, - ); + expect( + detectCommandSubstitution(UNQUOTED_PAIRED_BACKTICK_HEREDOC, 'bash'), + ).toBe(true); }); it('treats escaped backticks in an unquoted heredoc body as literal', () => { - expect(detectCommandSubstitution(UNQUOTED_ESCAPED_BACKTICK_HEREDOC)).toBe( - false, - ); + expect( + detectCommandSubstitution(UNQUOTED_ESCAPED_BACKTICK_HEREDOC, 'bash'), + ).toBe(false); }); }, ); diff --git a/packages/core/src/utils/shell-utils.parserUnavailable.test.ts b/packages/core/src/utils/shell-utils.parserUnavailable.test.ts index fd3d2f0185..cb3dbdf8f2 100644 --- a/packages/core/src/utils/shell-utils.parserUnavailable.test.ts +++ b/packages/core/src/utils/shell-utils.parserUnavailable.test.ts @@ -4,22 +4,19 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, expect, it, vi } from 'bun:test'; +import { describe, expect, it, beforeAll, afterAll } from 'bun:test'; import type { ShellPermissionConfig } from './shell-utils.js'; -const realShellParserModule = { ...(await import('./shell-parser.js')) }; -void vi.mock('./shell-parser.js', () => ({ - ...realShellParserModule, - isParserAvailable: () => false, - parseShellCommand: () => null, - extractCommandNames: () => [], - hasCommandSubstitution: () => false, - splitCommandsWithTree: () => [], - parseCommandDetails: () => null, - hasPromptCommandTransform: () => false, -})); +/** + * Tests for permission decisions when the structural shell parser is + * unavailable, exercising the regex/split fallback path. + * + * resetParser/initializeParser are used instead of vi.mock to avoid + * cross-file mock leakage in bun:test (#3181). + */ -const { checkCommandPermissions } = await import('./shell-utils.js'); +import { checkCommandPermissions } from './shell-utils.js'; +import { resetParser, initializeParser } from './shell-parser.js'; function createConfig( mode: 'none' | 'allowlist' | 'all', @@ -38,9 +35,12 @@ function permissionDecision( mode: 'none' | 'allowlist' | 'all', coreTools: string[], ): { allAllowed: boolean; isHardDenial: boolean } { + // Pass 'bash' explicitly: these tests exercise Bash fallback behavior (#3181). const result = checkCommandPermissions( command, createConfig(mode, coreTools), + undefined, + 'bash', ); return { allAllowed: result.allAllowed, @@ -51,6 +51,14 @@ function permissionDecision( const HARD_DENIAL = { allAllowed: false, isHardDenial: true }; describe('permissions without the shell parser', () => { + beforeAll(() => { + resetParser(); + }); + + afterAll(async () => { + await initializeParser(); + }); + it.each(['none', 'allowlist'] as const)( 'hard-denies LF multiline input in %s mode', (mode) => { diff --git a/packages/core/src/utils/shell-utils.powershell-wrappers.test.ts b/packages/core/src/utils/shell-utils.powershell-wrappers.test.ts new file mode 100644 index 0000000000..a6541e74c9 --- /dev/null +++ b/packages/core/src/utils/shell-utils.powershell-wrappers.test.ts @@ -0,0 +1,516 @@ +/** + * @license + * Copyright 2026 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + expect, + describe, + it, + beforeAll, + beforeEach, + afterEach, + vi, +} from 'bun:test'; +import { + checkCommandPermissions, + getCommandRoots, + isCommandAllowed, +} from './shell-utils.js'; +import { initializeParser, isParserAvailable } from './shell-parser.js'; +import type { Config } from '../config/config.js'; + +await initializeParser(); +const pwshAvailable = isParserAvailable('powershell'); +if (!pwshAvailable) { + throw new Error('PowerShell grammar failed to load under Bun'); +} + +const mockPlatform = vi.fn(); +void vi.mock('os', () => ({ + default: { + platform: mockPlatform, + homedir: vi.fn(), + }, + platform: mockPlatform, + homedir: vi.fn(), +})); + +let config: Config; +let strictConfig: Config; + +function makeConfig( + overrides: Partial<{ + coreTools: string[]; + excludeTools: string[]; + shellReplacement: string; + }> = {}, +): Config { + return { + getCoreTools: () => overrides.coreTools ?? [], + getExcludeTools: () => overrides.excludeTools ?? [], + getAllowedTools: () => [], + getShellReplacement: () => + (overrides.shellReplacement ?? 'allowlist') as never, + getEphemeralSetting: () => undefined, + } as unknown as Config; +} + +describe.skipIf(!pwshAvailable)( + 'shell-utils: PowerShell wrapper/evaluator bypass prevention', + () => { + beforeAll(() => { + mockPlatform.mockReturnValue('linux'); + }); + + beforeEach(() => { + mockPlatform.mockReturnValue('linux'); + config = makeConfig(); + strictConfig = makeConfig({ + coreTools: ['ShellTool(git)'], + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('wrapper/evaluator bypass prevention', () => { + /** + * Finding 4 (#3181): A specific blocklist or strict allowlist must not + * be bypassed by an allowed outer launcher. Literal payloads must be + * recursively parsed; dynamic payloads fail closed. + */ + const wrapperBlocklist: Config = makeConfig({ + excludeTools: ['ShellTool(rm)'], + }); + + it('blocks blocklisted command inside Invoke-Expression literal payload', () => { + const { allowed } = isCommandAllowed( + 'Invoke-Expression "rm -rf /tmp"', + wrapperBlocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('blocks blocklisted command inside iex alias literal payload', () => { + const { allowed } = isCommandAllowed( + 'iex "rm -rf /tmp"', + wrapperBlocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('blocks blocklisted command inside bash -c literal payload', () => { + const { allowed } = isCommandAllowed( + 'bash -c "rm -rf /tmp"', + wrapperBlocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('blocks blocklisted command inside powershell -Command literal payload', () => { + const { allowed } = isCommandAllowed( + 'powershell -Command "rm -rf /tmp"', + wrapperBlocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('fails closed for dynamic Invoke-Expression payload in strict allowlist', () => { + const result = checkCommandPermissions( + 'Invoke-Expression $cmd', + strictConfig, + undefined, + 'powershell', + ); + expect(result.allAllowed).toBe(false); + expect(result.isHardDenial).toBe(true); + }); + + // --- OCR remediation: expandable-string payloads with interpolation --- + // An expandable double-quoted string ("$cmd") has a variable child and + // must be treated as dynamic — it cannot be statically resolved. + it('fails closed for expandable-string Invoke-Expression payload in strict allowlist', () => { + const result = checkCommandPermissions( + 'Invoke-Expression "$cmd"', + strictConfig, + undefined, + 'powershell', + ); + expect(result.allAllowed).toBe(false); + expect(result.isHardDenial).toBe(true); + }); + + it('fails closed for expandable-string powershell -Command payload in strict allowlist', () => { + const result = checkCommandPermissions( + 'powershell -Command "$payload"', + strictConfig, + undefined, + 'powershell', + ); + expect(result.allAllowed).toBe(false); + expect(result.isHardDenial).toBe(true); + }); + + it('fails closed for cmd /c literal payload in strict allowlist (unresolved)', () => { + const result = checkCommandPermissions( + 'cmd /c "rm -rf /tmp"', + strictConfig, + undefined, + 'powershell', + ); + expect(result.allAllowed).toBe(false); + expect(result.isHardDenial).toBe(true); + }); + + it('default-allow: Invoke-Expression with valid-looking literal still allowed', () => { + const { allowed } = isCommandAllowed( + 'Invoke-Expression "Get-Process"', + config, + 'powershell', + ); + expect(allowed).toBe(true); + }); + + // --- Finding 4: pwsh/sh wrapper coverage --- + it('blocks blocklisted command inside pwsh -Command literal payload', () => { + const { allowed } = isCommandAllowed( + 'pwsh -Command "rm -rf /tmp"', + wrapperBlocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it.each([ + 'powershell -ExecutionPolicy "Bypass" -Command "rm -rf /tmp"', + 'pwsh -WorkingDirectory "C:\\Temp" -Command "rm -rf /tmp"', + '& powershell -ExecutionPolicy "Bypass" -Command "rm -rf /tmp"', + 'bash --init-file "harmless" -c "rm -rf /tmp"', + ])( + 'uses the command-flag payload when earlier options are quoted: %s', + (command) => { + const { allowed } = isCommandAllowed( + command, + wrapperBlocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }, + ); + + it.each([ + '& powershell -Command "rm -rf /tmp"', + '& iex "rm -rf /tmp"', + '& "cmd.exe" /c "rm -rf /tmp"', + '& bash -c "rm -rf /tmp"', + ])('blocks call-operator wrapper payload: %s', (command) => { + const { allowed } = isCommandAllowed( + command, + wrapperBlocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('blocks blocklisted command in a literal here-string evaluator payload', () => { + const { allowed } = isCommandAllowed( + "iex @'\nrm -rf /tmp\n'@", + wrapperBlocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('blocks blocklisted command in an expandable here-string wrapper payload', () => { + const { allowed } = isCommandAllowed( + 'powershell -Command @"\nrm -rf /tmp\n"@', + wrapperBlocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('blocks a bare blocklisted command after pwsh -Command', () => { + const { allowed } = isCommandAllowed( + 'pwsh -Command rm -rf /tmp', + wrapperBlocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('blocks blocklisted command inside sh -c literal payload', () => { + const { allowed } = isCommandAllowed( + 'sh -c "rm -rf /tmp"', + wrapperBlocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + // --- OCR remediation: .exe executable variants of shell wrappers --- + // Windows frequently invokes powershell.exe / pwsh.exe / bash.exe / + // sh.exe. These variants must be recognized as wrappers so a + // blocklisted payload nested behind one cannot bypass validation. + it('blocks blocklisted command inside powershell.exe -Command literal payload', () => { + const { allowed } = isCommandAllowed( + 'powershell.exe -Command "rm -rf /tmp"', + wrapperBlocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('blocks blocklisted command inside pwsh.exe -Command literal payload', () => { + const { allowed } = isCommandAllowed( + 'pwsh.exe -Command "rm -rf /tmp"', + wrapperBlocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('blocks blocklisted command inside bash.exe -c literal payload', () => { + const { allowed } = isCommandAllowed( + 'bash.exe -c "rm -rf /tmp"', + wrapperBlocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('blocks blocklisted command inside sh.exe -c literal payload', () => { + const { allowed } = isCommandAllowed( + 'sh.exe -c "rm -rf /tmp"', + wrapperBlocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + // Call-operator literal variants of the .exe wrappers. + it.each([ + "& 'powershell.exe' -Command 'rm -rf /tmp'", + "& 'pwsh.exe' -Command 'rm -rf /tmp'", + "& 'bash.exe' -c 'rm -rf /tmp'", + "& 'sh.exe' -c 'rm -rf /tmp'", + ])('blocks call-operator .exe wrapper payload: %s', (command) => { + const { allowed } = isCommandAllowed( + command, + wrapperBlocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('blocks blocklisted command inside cmd.exe /c literal payload (expression)', () => { + // cmd.exe /c has no matching grammar; the literal payload is classified + // as an unresolved expression and fails closed in strict allowlist. + // In blocklist mode, the unresolved expression detail contains the + // payload text; blocklist matching checks the full command text. + const { allowed } = isCommandAllowed( + 'cmd.exe /c "rm -rf /tmp"', + wrapperBlocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + // --- Finding 4: Start-Process / saps launcher coverage --- + it('blocks blocklisted target inside Start-Process literal string target', () => { + const { allowed } = isCommandAllowed( + 'Start-Process "rm"', + wrapperBlocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('blocks blocklisted target inside Start-Process bare token target', () => { + const { allowed } = isCommandAllowed( + 'Start-Process rm', + wrapperBlocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('blocks blocklisted target inside saps alias literal string target', () => { + const { allowed } = isCommandAllowed( + 'saps "rm"', + wrapperBlocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('blocks blocklisted target inside start alias literal string target', () => { + const { allowed } = isCommandAllowed( + 'start "rm"', + wrapperBlocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('Start-Process -FilePath literal target extracted for blocklist', () => { + const { allowed } = isCommandAllowed( + 'Start-Process -FilePath "rm"', + wrapperBlocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + // --- OCR remediation: single-quoted Start-Process target variants --- + it('blocks blocklisted target in single-quoted Start-Process positional target', () => { + const { allowed } = isCommandAllowed( + "Start-Process 'rm'", + wrapperBlocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('blocks blocklisted target in single-quoted Start-Process -FilePath target', () => { + const { allowed } = isCommandAllowed( + "Start-Process -FilePath 'rm'", + wrapperBlocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('extracts single-quoted Start-Process target root', () => { + expect( + getCommandRoots("Start-Process 'notepad.exe'", 'powershell'), + ).toContain('notepad.exe'); + }); + + it('ignores preceding named arguments when locating the Start-Process target', () => { + const { allowed } = isCommandAllowed( + 'Start-Process -ArgumentList "harmless" "rm"', + wrapperBlocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('resolves an abbreviated Start-Process -FilePath parameter', () => { + const { allowed } = isCommandAllowed( + 'Start-Process -Fi "rm" -Wait', + wrapperBlocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('does not consume the positional target after a switch parameter', () => { + const { allowed } = isCommandAllowed( + 'Start-Process -Confirm "rm"', + wrapperBlocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('does not consume the positional target after a common switch parameter', () => { + const command = 'Start-Process -Verbose "rm"'; + expect(getCommandRoots(command, 'powershell')).toContain('rm'); + const { allowed } = isCommandAllowed( + command, + wrapperBlocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('conservatively consumes the value of an unknown named parameter', () => { + const { allowed } = isCommandAllowed( + 'Start-Process -Unknown "harmless" "rm"', + wrapperBlocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('fails closed for dynamic Start-Process target in strict allowlist', () => { + const result = checkCommandPermissions( + 'Start-Process $cmd', + strictConfig, + undefined, + 'powershell', + ); + expect(result.allAllowed).toBe(false); + expect(result.isHardDenial).toBe(true); + }); + + it('default-allow: Start-Process with valid target still allowed', () => { + const { allowed } = isCommandAllowed( + 'Start-Process notepad.exe', + config, + 'powershell', + ); + expect(allowed).toBe(true); + }); + + // --- Finding 4: nested blocklist across wrappers --- + it('blocks doubly nested blocklisted command via pwsh -Command iex', () => { + const { allowed } = isCommandAllowed( + 'pwsh -Command \'iex "rm -rf /tmp"\'', + wrapperBlocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('does not truncate blocklist validation for deeply nested literal evaluators', () => { + let command = 'rm -rf /tmp'; + // Exceeds the rejected fixed depth of 16; strict payload shrinkage, + // rather than a shallow budget, guarantees recursion terminates. + for (let nesting = 0; nesting < 17; nesting += 1) { + command = `iex '${command.replace(/'/g, "''")}'`; + } + + const { allowed } = isCommandAllowed( + command, + wrapperBlocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + // --- OCR remediation: doubled-double-quote decoding in wrapper payloads --- + // PowerShell escapes a literal " inside a double-quoted string by doubling + // it: "a""b" decodes to a"b. If decoding does not collapse "" -> " the + // nested payload is mis-parsed and a blocklisted command hidden behind + // the doubled quotes can escape detection. + it('finds blocklisted command behind doubled-double-quote in evaluator payload', () => { + // iex "& ""rm"" -rf /tmp" decodes to: & "rm" -rf /tmp + const { allowed } = isCommandAllowed( + 'iex "& ""rm"" -rf /tmp"', + wrapperBlocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('finds blocklisted command behind doubled-double-quote in pwsh -Command payload', () => { + // powershell -Command "rm ""-rf"" /tmp" decodes to: rm "-rf" /tmp + const { allowed } = isCommandAllowed( + 'powershell -Command "rm ""-rf"" /tmp"', + wrapperBlocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + }); + }, +); diff --git a/packages/core/src/utils/shell-utils.powershell.test.ts b/packages/core/src/utils/shell-utils.powershell.test.ts new file mode 100644 index 0000000000..31a4cef272 --- /dev/null +++ b/packages/core/src/utils/shell-utils.powershell.test.ts @@ -0,0 +1,711 @@ +/** + * @license + * Copyright 2026 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + expect, + describe, + it, + beforeAll, + beforeEach, + afterEach, + vi, +} from 'bun:test'; +import { + checkCommandPermissions, + getCommandRoots, + isCommandAllowed, + detectCommandSubstitution, + shellTypeToParserLanguage, +} from './shell-utils.js'; +import { initializeParser, isParserAvailable } from './shell-parser.js'; +import type { Config } from '../config/config.js'; + +await initializeParser(); +const pwshAvailable = isParserAvailable('powershell'); +if (!pwshAvailable) { + throw new Error('PowerShell grammar failed to load under Bun'); +} +const describePwsh = describe.skipIf(!pwshAvailable); + +const mockPlatform = vi.fn(); +void vi.mock('os', () => ({ + default: { + platform: mockPlatform, + homedir: vi.fn(), + }, + platform: mockPlatform, + homedir: vi.fn(), +})); + +let config: Config; +let strictConfig: Config; +let blocklistConfig: Config; + +function makeConfig( + overrides: Partial<{ + coreTools: string[]; + excludeTools: string[]; + shellReplacement: string; + }> = {}, +): Config { + return { + getCoreTools: () => overrides.coreTools ?? [], + getExcludeTools: () => overrides.excludeTools ?? [], + getAllowedTools: () => [], + getShellReplacement: () => + (overrides.shellReplacement ?? 'allowlist') as never, + getEphemeralSetting: () => undefined, + } as unknown as Config; +} + +describePwsh('shell-utils: PowerShell permission path', () => { + beforeAll(() => { + mockPlatform.mockReturnValue('linux'); + }); + + beforeEach(() => { + mockPlatform.mockReturnValue('linux'); + config = makeConfig(); + strictConfig = makeConfig({ + coreTools: ['ShellTool(git)'], + }); + blocklistConfig = makeConfig({ + excludeTools: ['ShellTool(rm)'], + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('shellTypeToParserLanguage', () => { + it('maps powershell to powershell', () => { + expect(shellTypeToParserLanguage('powershell')).toBe('powershell'); + }); + + it('maps bash to bash', () => { + expect(shellTypeToParserLanguage('bash')).toBe('bash'); + }); + + it('does NOT map cmd to powershell', () => { + expect(shellTypeToParserLanguage('cmd')).not.toBe('powershell'); + }); + + it('defaults undefined to bash', () => { + expect(shellTypeToParserLanguage(undefined)).toBe('bash'); + }); + }); + + describe('valid PowerShell is accepted in default-allow mode', () => { + const validSamples: Array<[string, string]> = [ + ['if-exit chain', 'if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }'], + ['assignment + cmdlet', '$result = Get-Content path/to/file'], + ['ForEach-Object', 'ForEach-Object { Write-Host $_ }'], + ['array pipeline', '@(1,2,3) | ForEach-Object { $_ * 2 }'], + ['Where-Object', 'Get-Process | Where-Object { $_.Name -eq "x" }'], + ['call operator', '& "C:\\tool.exe"'], + ['property access', '$value.Name'], + ['method call', '$value.Trim()'], + ]; + + for (const [label, cmd] of validSamples) { + it(`allows ${label}`, () => { + const { allowed } = isCommandAllowed(cmd, config, 'powershell'); + expect(allowed).toBe(true); + }); + } + + it('allows static .NET method invocation in default-allow mode', () => { + const { allowed } = isCommandAllowed( + '[System.IO.File]::ReadAllText("test.txt")', + config, + 'powershell', + ); + expect(allowed).toBe(true); + }); + + it('allows static .NET Process::Start in default-allow mode', () => { + const { allowed } = isCommandAllowed( + '[System.Diagnostics.Process]::Start("notepad.exe")', + config, + 'powershell', + ); + expect(allowed).toBe(true); + }); + + it('allows dynamic call target in default-allow mode', () => { + const { allowed } = isCommandAllowed('& $command', config, 'powershell'); + expect(allowed).toBe(true); + }); + }); + + describe('malformed PowerShell fails closed', () => { + it('rejects incomplete pipeline with PowerShell-specific diagnostic', () => { + const { allowed, reason } = isCommandAllowed( + 'Get-ChildItem |', + config, + 'powershell', + ); + expect(allowed).toBe(false); + expect(reason).toContain('tree-sitter-pwsh'); + }); + + it('does not use the generic Bash parse-safely message', () => { + const { allowed, reason } = isCommandAllowed( + 'if (', + config, + 'powershell', + ); + expect(allowed).toBe(false); + expect(reason).not.toBe( + 'Command rejected because it could not be parsed safely', + ); + }); + }); + + describe('successful parse with zero command details', () => { + it('does not produce a parser-unavailable diagnostic for a pure expression', () => { + // A valid PowerShell expression that parses without errors but yields + // zero command details must NOT fall through to the "structural parser + // is unavailable" diagnostic. The parser was available and parsed + // successfully; the command should be validated as-is. + const result = checkCommandPermissions( + '42', + config, + undefined, + 'powershell', + ); + expect(result.blockReason ?? '').not.toContain('unavailable'); + expect(result.allAllowed).toBe(true); + }); + }); + + describe('strict allowlist: dynamic and expression targets fail closed', () => { + it('fails closed for .NET Process::Start in strict allowlist', () => { + const result = checkCommandPermissions( + '[System.Diagnostics.Process]::Start("cmd.exe")', + strictConfig, + undefined, + 'powershell', + ); + expect(result.allAllowed).toBe(false); + expect(result.isHardDenial).toBe(true); + expect(result.blockReason).not.toContain('syntax error'); + }); + + it('fails closed for dynamic call target in strict allowlist', () => { + const result = checkCommandPermissions( + '& $command', + strictConfig, + undefined, + 'powershell', + ); + expect(result.allAllowed).toBe(false); + expect(result.isHardDenial).toBe(true); + }); + + it('still allows allowed commands in strict allowlist', () => { + const result = checkCommandPermissions( + 'git status', + strictConfig, + undefined, + 'powershell', + ); + expect(result.allAllowed).toBe(true); + }); + }); + + describe('blocklist still applies to PowerShell', () => { + it('blocks a blocklisted command', () => { + const { allowed } = isCommandAllowed( + 'rm -rf /tmp', + blocklistConfig, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('blocks a blocklisted command nested in script block', () => { + const { allowed } = isCommandAllowed( + 'ForEach-Object { rm -rf /tmp }', + blocklistConfig, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + // Finding 3 (#3181): PowerShell is case-insensitive for command names. + // A blocklist entry ShellTool(rm) must catch RM, Rm, or rm. + it('blocks uppercase RM matching lowercase blocklist entry', () => { + const { allowed } = isCommandAllowed( + 'RM -rf /tmp', + blocklistConfig, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('blocks mixed-case blocklisted command in nested script block', () => { + const { allowed } = isCommandAllowed( + 'ForEach-Object { Rm -rf /tmp }', + blocklistConfig, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('blocklist matching does NOT lowercase Bash (case-sensitive)', () => { + const bashBlocklist: Config = makeConfig({ + excludeTools: ['ShellTool(rm)'], + }); + // Bash IS case-sensitive: RM != rm. + const { allowed } = isCommandAllowed( + 'RM -rf /tmp', + bashBlocklist, + 'bash', + ); + expect(allowed).toBe(true); + }); + }); + + describe('case-insensitive PowerShell allowlist matching', () => { + it('uppercase PowerShell command matches lowercase allowlist entry', () => { + const psAllowlist: Config = makeConfig({ + coreTools: ['ShellTool(get-process)'], + }); + const { allowed } = isCommandAllowed( + 'GET-PROCESS', + psAllowlist, + 'powershell', + ); + expect(allowed).toBe(true); + }); + + it('lowercase PowerShell command matches mixed-case allowlist entry', () => { + const psAllowlist: Config = makeConfig({ + coreTools: ['ShellTool(Get-Process)'], + }); + const { allowed } = isCommandAllowed( + 'get-process', + psAllowlist, + 'powershell', + ); + expect(allowed).toBe(true); + }); + + it('session allowlist matching is case-insensitive for PowerShell', () => { + const sessionAllowlist = new Set(['Get-Process']); + const result = checkCommandPermissions( + 'GET-PROCESS', + makeConfig({ coreTools: [] }), + sessionAllowlist, + 'powershell', + ); + expect(result.allAllowed).toBe(true); + }); + }); + + describe('getCommandRoots with PowerShell', () => { + it('extracts command roots for PowerShell pipeline', () => { + const roots = getCommandRoots( + 'Get-Process | Where-Object { $_.Name -eq "x" }', + 'powershell', + ); + expect(roots).toContain('Get-Process'); + expect(roots).toContain('Where-Object'); + }); + + it('extracts literal call-operator root', () => { + const roots = getCommandRoots('& "C:\\tools\\my-tool.exe"', 'powershell'); + expect(roots).toContain('my-tool.exe'); + }); + + it('does not fabricate roots for pure .NET expressions', () => { + const roots = getCommandRoots( + '[System.IO.File]::ReadAllText("x")', + 'powershell', + ); + // A pure expression has no command root; it must not fabricate one + // from the regex fallback when the parser is available. + expect(roots).toEqual([]); + }); + }); + + describe('detectCommandSubstitution with PowerShell', () => { + it('detects $() as substitution', () => { + expect(detectCommandSubstitution('$(Get-Date)', 'powershell')).toBe(true); + }); + + it('does NOT treat backticks as substitution', () => { + expect( + detectCommandSubstitution('Write-Host `n "hi"', 'powershell'), + ).toBe(false); + }); + + it('does NOT treat $variable as substitution', () => { + expect(detectCommandSubstitution('Write-Host $HOME', 'powershell')).toBe( + false, + ); + }); + + it('fails closed when the PowerShell tree has a parse error', () => { + // A malformed PowerShell command may have $() that the parser's error + // recovery dropped or misclassified as a non-sub_expression node. + // Return true (fail closed) rather than trusting AST detection on a + // broken tree. Get-ChildItem | has no $() but produces hasError, + // so detection must still return true. + expect(detectCommandSubstitution('Get-ChildItem |', 'powershell')).toBe( + true, + ); + }); + + it('still does NOT treat valid backtick usage as substitution with parse error absent', () => { + // Confirms the fix does not widen to valid backtick commands. + expect( + detectCommandSubstitution('Write-Host `n "hello"', 'powershell'), + ).toBe(false); + }); + }); + + describe('PowerShell parser available', () => { + it('accepts valid multiline PowerShell', () => { + const result = checkCommandPermissions( + 'Get-Process\nWrite-Host done', + config, + undefined, + 'powershell', + ); + expect(result.allAllowed).toBe(true); + expect(result.blockReason ?? '').not.toContain( + 'could not be parsed safely', + ); + }); + }); + + describe('Bash tests remain unaffected', () => { + it('still allows basic bash commands', () => { + const { allowed } = isCommandAllowed('ls -la /tmp', config, 'bash'); + expect(allowed).toBe(true); + }); + + it('still rejects malformed bash', () => { + const { allowed } = isCommandAllowed('ls &&', config, 'bash'); + expect(allowed).toBe(false); + }); + + it('still detects bash backtick substitution', () => { + expect(detectCommandSubstitution('echo `date`', 'bash')).toBe(true); + }); + }); + + describe('blocklist recursion across shell-replacement modes', () => { + it('all mode catches blocklisted command nested in script block', () => { + const allConfig = makeConfig({ + excludeTools: ['ShellTool(rm)'], + shellReplacement: 'all', + }); + const { allowed } = isCommandAllowed( + 'ForEach-Object { rm -rf /tmp }', + allConfig, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('none mode catches blocklisted command nested in script block', () => { + const noneConfig = makeConfig({ + excludeTools: ['ShellTool(rm)'], + shellReplacement: 'none', + }); + const { allowed } = isCommandAllowed( + 'ForEach-Object { rm -rf /tmp }', + noneConfig, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('all mode does NOT disable excludeTools', () => { + const allConfig = makeConfig({ + excludeTools: ['ShellTool(rm)'], + shellReplacement: 'all', + }); + const { allowed } = isCommandAllowed( + 'rm -rf /tmp', + allConfig, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('all mode allows valid commands without blocklist hits', () => { + const allConfig = makeConfig({ + shellReplacement: 'all', + }); + const { allowed } = isCommandAllowed( + 'Get-Process | Select-Object Name', + allConfig, + 'powershell', + ); + expect(allowed).toBe(true); + }); + + it('none mode blocks $() substitution for PowerShell', () => { + const noneConfig = makeConfig({ + shellReplacement: 'none', + }); + const { allowed } = isCommandAllowed( + '$(Get-Date)', + noneConfig, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('none mode does NOT block PowerShell backtick line continuation', () => { + const noneConfig = makeConfig({ + shellReplacement: 'none', + }); + const { allowed } = isCommandAllowed( + 'Write-Host `n "hello"', + noneConfig, + 'powershell', + ); + expect(allowed).toBe(true); + }); + }); + + // Finding 6 (#3181): PowerShell construct-specific substitution and + // blocklist behavior across none/allowlist/all modes. + describe('PowerShell construct substitution and blocklist semantics', () => { + const blocklistConfig6 = makeConfig({ + excludeTools: ['ShellTool(rm)'], + }); + + it('@() array expression is NOT substitution in none mode', () => { + const noneConfig = makeConfig({ shellReplacement: 'none' }); + const { allowed } = isCommandAllowed( + '@(1, 2, 3) | ForEach-Object { Write-Host $_ }', + noneConfig, + 'powershell', + ); + expect(allowed).toBe(true); + }); + + it('$() subexpression inside none mode is blocked', () => { + const noneConfig = makeConfig({ shellReplacement: 'none' }); + const { allowed } = isCommandAllowed( + 'Write-Host $(Get-Date)', + noneConfig, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('backtick line continuation is NOT substitution in none mode', () => { + const noneConfig = makeConfig({ shellReplacement: 'none' }); + const cmd = + 'Get-Process `' + String.fromCharCode(10) + ' | Select-Object Name'; + const { allowed } = isCommandAllowed(cmd, noneConfig, 'powershell'); + expect(allowed).toBe(true); + }); + + it('& {} script block: nested blocklisted command caught in allowlist mode', () => { + const { allowed } = isCommandAllowed( + '& { rm -rf /tmp }', + blocklistConfig6, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('& {} script block: nested blocklisted command caught in none mode', () => { + const noneConfig = makeConfig({ + excludeTools: ['ShellTool(rm)'], + shellReplacement: 'none', + }); + const { allowed } = isCommandAllowed( + '& { rm -rf /tmp }', + noneConfig, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('& {} script block: nested blocklisted command caught in all mode', () => { + const allConfig = makeConfig({ + excludeTools: ['ShellTool(rm)'], + shellReplacement: 'all', + }); + const { allowed } = isCommandAllowed( + '& { rm -rf /tmp }', + allConfig, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('dynamic & call target fails closed in strict allowlist', () => { + const result = checkCommandPermissions( + '& $cmd', + strictConfig, + undefined, + 'powershell', + ); + expect(result.allAllowed).toBe(false); + expect(result.isHardDenial).toBe(true); + }); + + it('dot-source literal extracts script name for blocklist matching', () => { + const dotBlocklist = makeConfig({ + excludeTools: ['ShellTool(evil.ps1)'], + }); + const { allowed } = isCommandAllowed( + '. .\\evil.ps1', + dotBlocklist, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('all mode allows $() subexpression (substitution restriction relaxed)', () => { + const allConfig = makeConfig({ shellReplacement: 'all' }); + const { allowed } = isCommandAllowed( + '$(Get-Date)', + allConfig, + 'powershell', + ); + expect(allowed).toBe(true); + }); + + it('all mode still blocks blocklisted command nested in $()', () => { + const allConfig = makeConfig({ + excludeTools: ['ShellTool(rm)'], + shellReplacement: 'all', + }); + const { allowed } = isCommandAllowed( + '$(rm -rf /tmp)', + allConfig, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('allowlist mode blocks blocklisted command nested in pipeline', () => { + const { allowed } = isCommandAllowed( + 'Get-Process | rm', + blocklistConfig6, + 'powershell', + ); + expect(allowed).toBe(false); + }); + + it('none mode blocks $() nested in ForEach-Object script block', () => { + const noneConfig = makeConfig({ shellReplacement: 'none' }); + const { allowed } = isCommandAllowed( + 'ForEach-Object { Write-Host $(Get-Date) }', + noneConfig, + 'powershell', + ); + expect(allowed).toBe(false); + }); + }); + describe('session allowlist hard-denies expression/dynamic targets (#3181 Finding 2)', () => { + const sessionAllowlist = new Set(['git']); + + it('hard-denies static Process::Start under session allowlist', () => { + const result = checkCommandPermissions( + '[System.Diagnostics.Process]::Start("cmd.exe")', + makeConfig({ coreTools: [] }), + sessionAllowlist, + 'powershell', + ); + expect(result.allAllowed).toBe(false); + expect(result.isHardDenial).toBe(true); + expect(result.blockReason).toContain('dynamic or expression'); + }); + + it('hard-denies instance method call under session allowlist', () => { + const result = checkCommandPermissions( + '$obj.Start("cmd.exe")', + makeConfig({ coreTools: [] }), + sessionAllowlist, + 'powershell', + ); + expect(result.allAllowed).toBe(false); + expect(result.isHardDenial).toBe(true); + }); + + it('hard-denies dynamic call target under session allowlist', () => { + const result = checkCommandPermissions( + '& $cmd', + makeConfig({ coreTools: [] }), + sessionAllowlist, + 'powershell', + ); + expect(result.allAllowed).toBe(false); + expect(result.isHardDenial).toBe(true); + }); + + it('hard-denies nested .NET invocation in arguments under session allowlist', () => { + const result = checkCommandPermissions( + 'Write-Host ([System.Diagnostics.Process]::Start("cmd.exe"))', + makeConfig({ coreTools: [] }), + sessionAllowlist, + 'powershell', + ); + expect(result.allAllowed).toBe(false); + expect(result.isHardDenial).toBe(true); + }); + + it('allows allowed command under session allowlist', () => { + const result = checkCommandPermissions( + 'git status', + makeConfig({ coreTools: [] }), + sessionAllowlist, + 'powershell', + ); + expect(result.allAllowed).toBe(true); + }); + }); + + describe('no duplicate or fabricated roots (#3181 Finding 2)', () => { + it('pipeline produces distinct roots with no duplicates', () => { + const roots = getCommandRoots( + 'Get-Process | Where-Object { $_.Name -eq "x" }', + 'powershell', + ); + expect(new Set(roots).size).toBe(roots.length); + }); + + it('wrapper payload does not duplicate the wrapper root', () => { + const roots = getCommandRoots( + 'Invoke-Expression "Get-Process"', + 'powershell', + ); + expect(new Set(roots).size).toBe(roots.length); + }); + + it('subexpression does not fabricate a root from $()', () => { + const roots = getCommandRoots('$(Get-ChildItem)', 'powershell'); + expect(roots.every((r) => r !== '$')).toBe(true); + expect(roots.every((r) => r !== '')).toBe(true); + }); + + it('expression detail does not produce an empty-name static root in allowlist matching', () => { + // A .NET expression should fail closed in strict allowlist, not pass + // by matching an empty root. + const result = checkCommandPermissions( + '[System.Diagnostics.Process]::Start("cmd.exe")', + strictConfig, + undefined, + 'powershell', + ); + expect(result.allAllowed).toBe(false); + expect(result.isHardDenial).toBe(true); + }); + }); +}); diff --git a/packages/core/src/utils/shell-utils.pwshUnavailable.test.ts b/packages/core/src/utils/shell-utils.pwshUnavailable.test.ts new file mode 100644 index 0000000000..354dbb730d --- /dev/null +++ b/packages/core/src/utils/shell-utils.pwshUnavailable.test.ts @@ -0,0 +1,175 @@ +/** + * @license + * Copyright 2026 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, beforeAll, afterAll } from 'bun:test'; +import type { ShellPermissionConfig } from './shell-utils.js'; + +/** + * Finding 8 (#3181): PowerShell permission behavior when the structural + * parser is unavailable. + * + * Under Node (or if the PowerShell WASM fails to load), `isParserAvailable + * ('powershell')` returns false. These tests reset the parser to simulate + * that state and verify: + * - allowlist mode hard-denies with a truthful PowerShell diagnostic; + * - none mode fails closed for substitution; + * - multiline input is hard-denied with a PowerShell-specific reason; + * - all mode does not hard-deny solely due to parser absence; + * - a concurrent reset/init lifecycle does not corrupt parser state. + * + * resetParser/initializeParser are used instead of vi.mock to avoid + * cross-file mock leakage in bun:test. + */ + +import { checkCommandPermissions } from './shell-utils.js'; +import { + resetParser, + initializeParser, + isParserAvailable, +} from './shell-parser.js'; + +await initializeParser(); +const pwshAvailable = isParserAvailable('powershell'); +if (!pwshAvailable) { + throw new Error('PowerShell grammar failed to load under Bun'); +} + +async function restoreParsers(context: string): Promise { + const initialized = await initializeParser(); + if (!initialized || !isParserAvailable('powershell')) { + throw new Error( + `PowerShell parser re-initialization failed after ${context}`, + ); + } +} + +function createConfig( + mode: 'none' | 'allowlist' | 'all', + coreTools: string[], + excludeTools: string[] = [], +): ShellPermissionConfig { + return { + getEphemeralSetting: () => mode, + getShellReplacement: () => mode, + getExcludeTools: () => excludeTools, + getCoreTools: () => coreTools, + }; +} + +function decide( + command: string, + mode: 'none' | 'allowlist' | 'all', + coreTools: string[] = [], + excludeTools: string[] = [], +): { allAllowed: boolean; isHardDenial: boolean; blockReason?: string } { + const result = checkCommandPermissions( + command, + createConfig(mode, coreTools, excludeTools), + undefined, + 'powershell', + ); + return { + allAllowed: result.allAllowed, + isHardDenial: result.isHardDenial === true, + blockReason: result.blockReason, + }; +} + +const HARD_DENIAL = { allAllowed: false, isHardDenial: true }; + +describe('PowerShell permission behavior when parser unavailable', () => { + beforeAll(() => { + resetParser(); + }); + + afterAll(() => restoreParsers('unavailable-state tests')); + + it('allowlist mode hard-denies one-line PowerShell with truthful diagnostic', () => { + const result = decide('Get-Process', 'allowlist', [ + 'run_shell_command(Get-Process)', + ]); + expect(result).toMatchObject(HARD_DENIAL); + expect(result.blockReason).toContain('PowerShell'); + expect(result.blockReason).toContain('structural parser'); + expect(result.blockReason).not.toContain('could not be parsed safely'); + }); + + it('allowlist mode hard-denies multiline PowerShell with parser-required diagnostic', () => { + const result = decide('Get-Process\nWrite-Host done', 'allowlist'); + expect(result).toMatchObject(HARD_DENIAL); + expect(result.blockReason).toContain('PowerShell'); + expect(result.blockReason).toContain('parser'); + }); + + it('none mode hard-denies one-line PowerShell with $() substitution', () => { + const result = decide('$(Get-Date)', 'none'); + expect(result).toMatchObject(HARD_DENIAL); + expect(result.blockReason).toContain('substitution'); + }); + + it('none mode hard-denies one-line PowerShell even without substitution (parser unavailable fail-closed)', () => { + // Without the parser, detectCommandSubstitution returns true for PowerShell + // (fail closed). So even a plain command is denied in none mode. + const result = decide('Get-Process', 'none'); + expect(result).toMatchObject(HARD_DENIAL); + }); + + it('all mode does NOT hard-deny solely because the parser is unavailable', () => { + const result = decide('Get-Process', 'all'); + expect(result.isHardDenial).toBe(false); + }); + + it('all mode still enforces blocklist when parser unavailable', () => { + const result = decide('rm -rf /tmp', 'all', [], ['ShellTool(rm)']); + expect(result).toMatchObject(HARD_DENIAL); + }); + + it('none mode blocks multiline input with PowerShell-specific reason', () => { + const result = decide('Get-Process\nWrite-Host', 'none'); + expect(result).toMatchObject(HARD_DENIAL); + expect(result.blockReason).toContain('PowerShell'); + }); +}); + +/** + * Concurrent reset/init lifecycle: verify that resetParser during or after + * initialization produces a consistent state, and that concurrent + * initializeParser calls are de-duplicated. + */ +describe.skipIf(!pwshAvailable)( + 'parser reset/init lifecycle consistency', + () => { + afterAll(() => restoreParsers('lifecycle tests')); + + it('concurrent initializeParser calls return the same promise', async () => { + resetParser(); + const p1 = initializeParser(); + const p2 = initializeParser(); + expect(p1).toBe(p2); + const result = await p1; + expect(result).toBe(true); + expect(isParserAvailable('powershell')).toBe(true); + }); + + it('resetParser after init clears both parsers', async () => { + await initializeParser(); + expect(isParserAvailable('powershell')).toBe(true); + expect(isParserAvailable('bash')).toBe(true); + resetParser(); + expect(isParserAvailable('powershell')).toBe(false); + expect(isParserAvailable('bash')).toBe(false); + }); + + it('re-initialize after reset restores parser availability', async () => { + resetParser(); + expect(isParserAvailable('powershell')).toBe(false); + const ok = await initializeParser(); + expect(ok).toBe(true); + expect(isParserAvailable('powershell')).toBe(true); + expect(isParserAvailable('bash')).toBe(true); + }); + }, +); diff --git a/packages/core/src/utils/shell-utils.shellReplacement.test.ts b/packages/core/src/utils/shell-utils.shellReplacement.test.ts index 6af6e84fd2..7852f06717 100644 --- a/packages/core/src/utils/shell-utils.shellReplacement.test.ts +++ b/packages/core/src/utils/shell-utils.shellReplacement.test.ts @@ -6,8 +6,8 @@ import { describe, it, expect, beforeEach, beforeAll } from 'bun:test'; import { - detectCommandSubstitution, - checkCommandPermissions, + detectCommandSubstitution as detectCommandSubstitutionImpl, + checkCommandPermissions as checkCommandPermissionsImpl, } from './shell-utils.js'; import { Config } from '../config/config.js'; import { SettingsService } from '@vybestack/llxprt-code-settings'; @@ -19,6 +19,16 @@ import { await initializeShellParsers(); +// All cases in this file exercise Bash substitution syntax; pass 'bash' +// explicitly so tests are platform-independent (#3181). +const detectCommandSubstitution = (cmd: string): boolean => + detectCommandSubstitutionImpl(cmd, 'bash'); +const checkCommandPermissions = ( + cmd: string, + cfg: Config, +): ReturnType => + checkCommandPermissionsImpl(cmd, cfg, undefined, 'bash'); + describe('Shell replacement settings', () => { let config: Config; let settingsService: SettingsService; diff --git a/packages/core/src/utils/shell-utils.test.ts b/packages/core/src/utils/shell-utils.test.ts index a8e763e354..861f5a1977 100644 --- a/packages/core/src/utils/shell-utils.test.ts +++ b/packages/core/src/utils/shell-utils.test.ts @@ -20,7 +20,10 @@ import { stripShellWrapper, } from './shell-utils.js'; import { isShellInvocationAllowlisted } from './tool-utils.js'; -import { initializeParser as initializeShellParsers } from './shell-parser.js'; +import { + initializeParser as initializeShellParsers, + isParserAvailable, +} from './shell-parser.js'; import type { Config } from '../config/config.js'; import type { AnyToolInvocation } from '../index.js'; @@ -41,8 +44,9 @@ void vi.mock('shell-quote', () => ({ })); let config: Config; -const isWindowsRuntime = process.platform === 'win32'; const parserInitialized = await initializeShellParsers(); +const pwshAvailable = parserInitialized && isParserAvailable('powershell'); +const describePwsh = describe.skipIf(!pwshAvailable); describe('shell-utils', () => { beforeAll(async () => { @@ -68,21 +72,33 @@ describe('shell-utils', () => { vi.clearAllMocks(); }); + // All tests in this file exercise Bash syntax. These wrappers pass 'bash' + // explicitly to avoid platform-dependent parser selection (#3181). + function bashAllowed(cmd: string): { allowed: boolean; reason?: string } { + return isCommandAllowed(cmd, config, 'bash'); + } + function bashCheck( + cmd: string, + allowlist?: Set, + ): ReturnType { + return checkCommandPermissions(cmd, config, allowlist, 'bash'); + } + describe('isCommandAllowed', () => { it('should allow a command if no restrictions are provided', () => { - const result = isCommandAllowed('goodCommand --safe', config); + const result = bashAllowed('goodCommand --safe'); expect(result.allowed).toBe(true); }); it('should allow a command if it is in the global allowlist', () => { config.getCoreTools = () => ['ShellTool(goodCommand)']; - const result = isCommandAllowed('goodCommand --safe', config); + const result = bashAllowed('goodCommand --safe'); expect(result.allowed).toBe(true); }); it('should block a command if it is not in a strict global allowlist', () => { config.getCoreTools = () => ['ShellTool(goodCommand --safe)']; - const result = isCommandAllowed('badCommand --danger', config); + const result = bashAllowed('badCommand --danger'); expect(result.allowed).toBe(false); expect(result.reason).toBe( `Command(s) not in the allowed commands list. Disallowed commands: "badCommand --danger"`, @@ -91,7 +107,7 @@ describe('shell-utils', () => { it('should block a command if it is in the blocked list', () => { config.getExcludeTools = () => ['ShellTool(badCommand --danger)']; - const result = isCommandAllowed('badCommand --danger', config); + const result = bashAllowed('badCommand --danger'); expect(result.allowed).toBe(false); expect(result.reason).toBe( `Command 'badCommand --danger' is blocked by configuration`, @@ -101,7 +117,7 @@ describe('shell-utils', () => { it('should prioritize the blocklist over the allowlist', () => { config.getCoreTools = () => ['ShellTool(badCommand --danger)']; config.getExcludeTools = () => ['ShellTool(badCommand --danger)']; - const result = isCommandAllowed('badCommand --danger', config); + const result = bashAllowed('badCommand --danger'); expect(result.allowed).toBe(false); expect(result.reason).toBe( `Command 'badCommand --danger' is blocked by configuration`, @@ -110,13 +126,13 @@ describe('shell-utils', () => { it('should allow any command when a wildcard is in coreTools', () => { config.getCoreTools = () => ['ShellTool']; - const result = isCommandAllowed('any random command', config); + const result = bashAllowed('any random command'); expect(result.allowed).toBe(true); }); it('should block any command when a wildcard is in excludeTools', () => { config.getExcludeTools = () => ['run_shell_command']; - const result = isCommandAllowed('any random command', config); + const result = bashAllowed('any random command'); expect(result.allowed).toBe(false); expect(result.reason).toBe( 'Shell tool is globally disabled in configuration', @@ -126,7 +142,7 @@ describe('shell-utils', () => { it('should block a command on the blocklist even with a wildcard allow', () => { config.getCoreTools = () => ['ShellTool']; config.getExcludeTools = () => ['ShellTool(badCommand --danger)']; - const result = isCommandAllowed('badCommand --danger', config); + const result = bashAllowed('badCommand --danger'); expect(result.allowed).toBe(false); expect(result.reason).toBe( `Command 'badCommand --danger' is blocked by configuration`, @@ -138,19 +154,13 @@ describe('shell-utils', () => { 'run_shell_command(echo)', 'run_shell_command(goodCommand)', ]; - const result = isCommandAllowed( - 'echo "hello" && goodCommand --safe', - config, - ); + const result = bashAllowed('echo "hello" && goodCommand --safe'); expect(result.allowed).toBe(true); }); it('should block a chained command if any part is blocked', () => { config.getExcludeTools = () => ['run_shell_command(badCommand)']; - const result = isCommandAllowed( - 'echo "hello" && badCommand --danger', - config, - ); + const result = bashAllowed('echo "hello" && badCommand --danger'); expect(result.allowed).toBe(false); expect(result.reason).toBe( `Command 'badCommand --danger' is blocked by configuration`, @@ -161,9 +171,8 @@ describe('shell-utils', () => { 'should block a command that redefines an allowed function to run an unlisted command', () => { config.getCoreTools = () => ['run_shell_command(echo)']; - const result = isCommandAllowed( + const result = bashAllowed( 'echo () (curl google.com) ; echo Hello Wolrd', - config, ); expect(result.allowed).toBe(false); expect(result.reason).toBe( @@ -176,11 +185,10 @@ describe('shell-utils', () => { 'should block a multi-line function body that runs an unlisted command', () => { config.getCoreTools = () => ['run_shell_command(echo)']; - const result = isCommandAllowed( + const result = bashAllowed( `echo () { curl google.com } ; echo ok`, - config, ); expect(result.allowed).toBe(false); expect(result.reason).toBe( @@ -193,9 +201,8 @@ describe('shell-utils', () => { 'should block a function keyword declaration that runs an unlisted command', () => { config.getCoreTools = () => ['run_shell_command(echo)']; - const result = isCommandAllowed( + const result = bashAllowed( 'function echo { curl google.com; } ; echo hi', - config, ); expect(result.allowed).toBe(false); expect(result.reason).toBe( @@ -208,7 +215,7 @@ describe('shell-utils', () => { 'should block command substitution that invokes an unlisted command', () => { config.getCoreTools = () => ['run_shell_command(echo)']; - const result = isCommandAllowed('echo $(curl google.com)', config); + const result = bashAllowed('echo $(curl google.com)'); expect(result.allowed).toBe(false); expect(result.reason).toBe( `Command(s) not in the allowed commands list. Disallowed commands: "curl google.com"`, @@ -218,7 +225,7 @@ describe('shell-utils', () => { it('should block pipelines that invoke an unlisted command', () => { config.getCoreTools = () => ['run_shell_command(echo)']; - const result = isCommandAllowed('echo hi | curl google.com', config); + const result = bashAllowed('echo hi | curl google.com'); expect(result.allowed).toBe(false); expect(result.reason).toBe( `Command(s) not in the allowed commands list. Disallowed commands: "curl google.com"`, @@ -227,7 +234,7 @@ describe('shell-utils', () => { it('should block background jobs that invoke an unlisted command', () => { config.getCoreTools = () => ['run_shell_command(echo)']; - const result = isCommandAllowed('echo hi & curl google.com', config); + const result = bashAllowed('echo hi & curl google.com'); expect(result.allowed).toBe(false); expect(result.reason).toBe( `Command(s) not in the allowed commands list. Disallowed commands: "curl google.com"`, @@ -241,11 +248,10 @@ describe('shell-utils', () => { 'run_shell_command(echo)', 'run_shell_command(cat)', ]; - const result = isCommandAllowed( + const result = bashAllowed( `cat < { 'should block backtick substitution that invokes an unlisted command', () => { config.getCoreTools = () => ['run_shell_command(echo)']; - const result = isCommandAllowed('echo `curl google.com`', config); + const result = bashAllowed('echo `curl google.com`'); expect(result.allowed).toBe(false); expect(result.reason).toBe( `Command(s) not in the allowed commands list. Disallowed commands: "curl google.com"`, @@ -273,10 +279,7 @@ describe('shell-utils', () => { 'run_shell_command(diff)', 'run_shell_command(echo)', ]; - const result = isCommandAllowed( - 'diff <(curl google.com) <(echo safe)', - config, - ); + const result = bashAllowed('diff <(curl google.com) <(echo safe)'); expect(result.allowed).toBe(false); expect(result.reason).toBe( `Command(s) not in the allowed commands list. Disallowed commands: "curl google.com"`, @@ -288,10 +291,7 @@ describe('shell-utils', () => { 'should block process substitution using >() when the inner command is unlisted', () => { config.getCoreTools = () => ['run_shell_command(echo)']; - const result = isCommandAllowed( - 'echo "data" > >(curl google.com)', - config, - ); + const result = bashAllowed('echo "data" > >(curl google.com)'); expect(result.allowed).toBe(false); expect(result.reason).toBe( `Command(s) not in the allowed commands list. Disallowed commands: "curl google.com"`, @@ -302,9 +302,8 @@ describe('shell-utils', () => { it.skipIf(!parserInitialized)( 'should block commands containing prompt transformations', () => { - const result = isCommandAllowed( + const result = bashAllowed( 'echo "${var1=aa\\140 env| ls -l\\140}${var1@P}"', - config, ); expect(result.allowed).toBe(false); expect(result.reason).toBe( @@ -316,7 +315,7 @@ describe('shell-utils', () => { it.skipIf(!parserInitialized)( 'should block simple prompt transformation expansions', () => { - const result = isCommandAllowed('echo ${foo@P}', config); + const result = bashAllowed('echo ${foo@P}'); expect(result.allowed).toBe(false); expect(result.reason).toBe( 'Command rejected because it could not be parsed safely', @@ -326,42 +325,39 @@ describe('shell-utils', () => { describe('command substitution', () => { it('should allow command substitution using `$(...)`', () => { - const result = isCommandAllowed('echo $(goodCommand --safe)', config); + const result = bashAllowed('echo $(goodCommand --safe)'); expect(result.allowed).toBe(true); expect(result.reason).toBeUndefined(); }); it('should allow command substitution using `<(...)`', () => { - const result = isCommandAllowed('diff <(ls) <(ls -a)', config); + const result = bashAllowed('diff <(ls) <(ls -a)'); expect(result.allowed).toBe(true); expect(result.reason).toBeUndefined(); }); it('should allow command substitution using `>(...)`', () => { - const result = isCommandAllowed( - 'echo "Log message" > >(tee log.txt)', - config, - ); + const result = bashAllowed('echo "Log message" > >(tee log.txt)'); expect(result.allowed).toBe(true); expect(result.reason).toBeUndefined(); }); it('should allow command substitution using backticks', () => { - const result = isCommandAllowed('echo `goodCommand --safe`', config); + const result = bashAllowed('echo `goodCommand --safe`'); expect(result.allowed).toBe(true); expect(result.reason).toBeUndefined(); }); it('should allow substitution-like patterns inside single quotes', () => { config.getCoreTools = () => ['ShellTool(echo)']; - const result = isCommandAllowed("echo '$(pwd)'", config); + const result = bashAllowed("echo '$(pwd)'"); expect(result.allowed).toBe(true); }); it.skipIf(!parserInitialized)( 'should block a command when parsing fails', () => { - const result = isCommandAllowed('ls &&', config); + const result = bashAllowed('ls &&'); expect(result.allowed).toBe(false); expect(result.reason).toBe( 'Command rejected because it could not be parsed safely', @@ -374,7 +370,7 @@ describe('shell-utils', () => { describe('checkCommandPermissions', () => { describe('in "Default Allow" mode (no sessionAllowlist)', () => { it('should return a detailed success object for an allowed command', () => { - const result = checkCommandPermissions('goodCommand --safe', config); + const result = bashCheck('goodCommand --safe'); expect(result).toStrictEqual({ allAllowed: true, disallowedCommands: [], @@ -384,7 +380,7 @@ describe('shell-utils', () => { it.skipIf(!parserInitialized)( 'should block commands that cannot be parsed safely', () => { - const result = checkCommandPermissions('ls &&', config); + const result = bashCheck('ls &&'); expect(result).toStrictEqual({ allAllowed: false, disallowedCommands: ['ls &&'], @@ -397,7 +393,7 @@ describe('shell-utils', () => { it('should return a detailed failure object for a blocked command', () => { config.getExcludeTools = () => ['ShellTool(badCommand)']; - const result = checkCommandPermissions('badCommand --danger', config); + const result = bashCheck('badCommand --danger'); expect(result).toStrictEqual({ allAllowed: false, disallowedCommands: ['badCommand --danger'], @@ -408,10 +404,7 @@ describe('shell-utils', () => { it('should return a detailed failure object for a command not on a strict allowlist', () => { config.getCoreTools = () => ['ShellTool(goodCommand)']; - const result = checkCommandPermissions( - 'git status && goodCommand', - config, - ); + const result = bashCheck('git status && goodCommand'); expect(result).toStrictEqual({ allAllowed: false, disallowedCommands: ['git status'], @@ -423,18 +416,16 @@ describe('shell-utils', () => { describe('in "Default Deny" mode (with sessionAllowlist)', () => { it('should allow a command on the sessionAllowlist', () => { - const result = checkCommandPermissions( + const result = bashCheck( 'goodCommand --safe', - config, new Set(['goodCommand --safe']), ); expect(result.allAllowed).toBe(true); }); it('should block a command not on the sessionAllowlist or global allowlist', () => { - const result = checkCommandPermissions( + const result = bashCheck( 'badCommand --danger', - config, new Set(['goodCommand --safe']), ); expect(result.allAllowed).toBe(false); @@ -448,19 +439,14 @@ describe('shell-utils', () => { it('should allow a command on the global allowlist even if not on the session allowlist', () => { config.getCoreTools = () => ['ShellTool(git status)']; - const result = checkCommandPermissions( - 'git status', - config, - new Set(['goodCommand --safe']), - ); + const result = bashCheck('git status', new Set(['goodCommand --safe'])); expect(result.allAllowed).toBe(true); }); it('should allow a chained command if parts are on different allowlists', () => { config.getCoreTools = () => ['ShellTool(git status)']; - const result = checkCommandPermissions( + const result = bashCheck( 'git status && git commit', - config, new Set(['git commit']), ); expect(result.allAllowed).toBe(true); @@ -468,9 +454,8 @@ describe('shell-utils', () => { it('should block a command on the sessionAllowlist if it is also globally blocked', () => { config.getExcludeTools = () => ['run_shell_command(badCommand)']; - const result = checkCommandPermissions( + const result = bashCheck( 'badCommand --danger', - config, new Set(['badCommand --danger']), ); expect(result.allAllowed).toBe(false); @@ -479,9 +464,8 @@ describe('shell-utils', () => { it('should block a chained command if one part is not on any allowlist', () => { config.getCoreTools = () => ['run_shell_command(echo)']; - const result = checkCommandPermissions( + const result = bashCheck( 'echo "hello" && badCommand --danger', - config, new Set(['echo']), ); expect(result.allAllowed).toBe(false); @@ -560,36 +544,28 @@ describe('shell-utils', () => { ); }); - describe.skipIf(!isWindowsRuntime)('PowerShell integration', () => { - const originalComSpec = process.env['ComSpec']; - beforeEach(() => { - mockPlatform.mockReturnValue('win32'); - const systemRoot = process.env['SystemRoot'] ?? 'C:\\\\Windows'; - process.env['ComSpec'] = - `${systemRoot}\\\\System32\\\\WindowsPowerShell\\\\v1.0\\\\powershell.exe`; - }); - afterEach(() => { - if (originalComSpec === undefined) { - delete process.env['ComSpec']; - } else { - process.env['ComSpec'] = originalComSpec; - } - }); - it('should return command roots using PowerShell AST output', () => { - const roots = getCommandRoots('Get-ChildItem | Select-Object Name'); + describe.skipIf(!pwshAvailable)('PowerShell parser integration', () => { + // These tests exercise the real tree-sitter-pwsh grammar by passing the + // shell type explicitly. Full PowerShell behavior coverage lives in + // shell-utils.powershell.test.ts and shell-parser-pwsh.test.ts. + it('should return command roots using the PowerShell grammar', () => { + const roots = getCommandRoots( + 'Get-ChildItem | Select-Object Name', + 'powershell', + ); expect(roots.length).toBeGreaterThan(0); expect(roots).toContain('Get-ChildItem'); + expect(roots).toContain('Select-Object'); + }); + it('should block commands when the PowerShell parser reports errors', () => { + const { allowed, reason } = isCommandAllowed( + 'Get-ChildItem |', + config, + 'powershell', + ); + expect(allowed).toBe(false); + expect(reason).toContain('tree-sitter-pwsh'); }); - it.skipIf(!parserInitialized)( - 'should block commands when PowerShell parser reports errors', - () => { - const { allowed, reason } = isCommandAllowed('Get-ChildItem |', config); - expect(allowed).toBe(false); - expect(reason).toBe( - 'Command rejected because it could not be parsed safely', - ); - }, - ); }); describe('stripShellWrapper', () => { @@ -629,11 +605,11 @@ describe('shell-utils', () => { }); }); - describe('isShellInvocationAllowlisted', () => { - function createInvocation(command: string): AnyToolInvocation { - return { params: { command } } as unknown as AnyToolInvocation; - } + function createInvocation(command: string): AnyToolInvocation { + return { params: { command } } as unknown as AnyToolInvocation; + } + describe('isShellInvocationAllowlisted', () => { it('should return false when any chained command segment is not allowlisted', () => { const invocation = createInvocation( 'git status && rm -rf /tmp/should-not-run', @@ -677,4 +653,85 @@ describe('shell-utils', () => { ).toBe(true); }); }); + + describePwsh('isShellInvocationAllowlisted: PowerShell shell-aware', () => { + it('should require all pipeline stages to be allowlisted for PowerShell', () => { + const invocation = createInvocation( + 'Get-Process | Where-Object { $_.Name -eq "x" }', + ); + expect( + isShellInvocationAllowlisted( + invocation, + ['run_shell_command(Get-Process)'], + 'powershell', + ), + ).toBe(false); + expect( + isShellInvocationAllowlisted( + invocation, + ['run_shell_command(Get-Process)', 'run_shell_command(Where-Object)'], + 'powershell', + ), + ).toBe(true); + }); + + it('should find nested commands inside script blocks for PowerShell', () => { + const invocation = createInvocation('ForEach-Object { Write-Host $_ }'); + // Only ForEach-Object is allowlisted — Write-Host is nested inside + expect( + isShellInvocationAllowlisted( + invocation, + ['run_shell_command(ForEach-Object)'], + 'powershell', + ), + ).toBe(false); + // Both must be allowlisted + expect( + isShellInvocationAllowlisted( + invocation, + [ + 'run_shell_command(ForEach-Object)', + 'run_shell_command(Write-Host)', + ], + 'powershell', + ), + ).toBe(true); + }); + + it('should fail closed for dynamic call targets in PowerShell', () => { + const invocation = createInvocation('& $command'); + expect( + isShellInvocationAllowlisted( + invocation, + ['run_shell_command(git)'], + 'powershell', + ), + ).toBe(false); + }); + + it('should fail closed for .NET invocation expressions in PowerShell', () => { + const invocation = createInvocation( + '[System.Diagnostics.Process]::Start("cmd.exe")', + ); + expect( + isShellInvocationAllowlisted( + invocation, + ['run_shell_command(git)'], + 'powershell', + ), + ).toBe(false); + }); + + it('should catch blocklisted command nested in Invoke-Expression payload', () => { + const invocation = createInvocation('Invoke-Expression "rm -rf /tmp"'); + // rm is not in the allowed list, so the whole invocation is not allowed + expect( + isShellInvocationAllowlisted( + invocation, + ['run_shell_command(Invoke-Expression)'], + 'powershell', + ), + ).toBe(false); + }); + }); }); diff --git a/packages/core/src/utils/shell-utils.ts b/packages/core/src/utils/shell-utils.ts index bc86f2cc94..f92a7e4ff4 100644 --- a/packages/core/src/utils/shell-utils.ts +++ b/packages/core/src/utils/shell-utils.ts @@ -39,13 +39,14 @@ import { isWindows } from './runtime.js'; import { doesToolInvocationMatch } from './tool-utils.js'; import { isParserAvailable, - parseShellCommand, - extractCommandNames, - hasCommandSubstitution as treeSitterHasCommandSubstitution, - splitCommandsWithTree, - parseCommandDetails, + parseShellCommandForLanguage, + extractCommandNamesForLanguage, + hasCommandSubstitutionForLanguage, + splitCommandsWithTreeForLanguage, + parseCommandDetailsForLanguage, hasPromptCommandTransform, } from './shell-parser.js'; +import type { ParserLanguage, ParsedCommandDetail } from './shell-parser.js'; import { debugLogger } from './debugLogger.js'; export const SHELL_TOOL_NAMES = ['run_shell_command', 'ShellTool']; @@ -55,6 +56,30 @@ export const SHELL_TOOL_NAMES = ['run_shell_command', 'ShellTool']; */ export type ShellType = 'cmd' | 'powershell' | 'bash'; +/** + * Map an execution {@link ShellType} to the corresponding parser grammar + * language. Only `powershell` maps to the PowerShell grammar; `cmd` maps + * to `bash` (the default) because cmd.exe syntax is not PowerShell and no + * dedicated cmd grammar exists — using Bash is the same as the pre-#3181 + * behavior and does not make a false claim about the language. + */ +export function shellTypeToParserLanguage( + shellType?: ShellType, +): ParserLanguage { + if (shellType === 'powershell') { + return 'powershell'; + } + return 'bash'; +} + +/** + * Resolve the execution shell type, using the platform shell configuration + * when no override is provided. + */ +function resolveShellType(shellType?: ShellType): ShellType { + return shellType ?? getShellConfiguration().shell; +} + /** * Defines the configuration required to execute a command string within a specific shell. */ @@ -155,19 +180,24 @@ export interface SplitCommandsOptions { * Uses tree-sitter for accurate parsing when available. * @param command The shell command string to parse * @param options Optional settings for split behavior + * @param shellType Optional shell type override; defaults to bash grammar * @returns An array of individual command strings */ export function splitCommands( command: string, options?: SplitCommandsOptions, + shellType?: ShellType, ): string[] { const splitOnPipes = options?.splitOnPipes ?? true; + const language = shellTypeToParserLanguage(shellType); // Try tree-sitter first for accurate parsing - if (isParserAvailable()) { - const tree = parseShellCommand(command); + if (isParserAvailable(language)) { + const tree = parseShellCommandForLanguage(command, language); if (tree) { - const result = splitCommandsWithTree(tree, { splitOnPipes }); + const result = splitCommandsWithTreeForLanguage(tree, language, { + splitOnPipes, + }); if (result.length > 0) { return result; } @@ -343,29 +373,42 @@ export function getCommandRoot(command: string): string | undefined { return undefined; } -export function getCommandRoots(command: string): string[] { +export function getCommandRoots( + command: string, + shellType?: ShellType, +): string[] { if (!command) { return []; } + const language = shellTypeToParserLanguage(shellType); + // Try tree-sitter first for accurate parsing - if (isParserAvailable()) { - const tree = parseShellCommand(command); + if (isParserAvailable(language)) { + const tree = parseShellCommandForLanguage(command, language); if (tree) { // Prompt transformations (${var@P}) can execute arbitrary commands, so // the command is treated as unsafe and no roots are returned. - if (hasPromptCommandTransform(tree.rootNode)) { + // This is a Bash-specific check; skip for PowerShell. + if (language === 'bash' && hasPromptCommandTransform(tree.rootNode)) { return []; } - const result = extractCommandNames(tree); + const result = extractCommandNamesForLanguage(tree, language); if (result.length > 0) { return result; } + // When the PowerShell parser is available and found no static command + // names (e.g., a pure .NET expression), do not fall back to regex — + // a pure expression has no command root and the regex fallback would + // fabricate one (#3181 OCR Finding 11). + if (language === 'powershell') { + return []; + } } } // Fall back to regex-based parsing - return splitCommands(command) + return splitCommands(command, undefined, shellType) .map((c) => getCommandRoot(c)) .filter((c): c is string => !!c); } @@ -459,27 +502,41 @@ function matchShellWrapperPrefix(cmd: string): number { } /** - * Detects command substitution patterns in a shell command, following bash quoting rules: - * - Single quotes ('): Everything literal, no substitution possible - * - Double quotes ("): Command substitution with $() and backticks unless escaped with \ - * - No quotes: Command substitution with $(), <(), and backticks - * Uses tree-sitter for accurate parsing when available, falls back to regex. - * @param command The shell command string to check - * @returns true if command substitution would be executed by bash + * Detects command substitution patterns in a shell command. + * + * **Bash** (default): `$()`, backticks, and `<()`/`>()` process substitution. + * + * **PowerShell**: `$()` subexpressions only. Backticks are escapes, not + * substitution. When the PowerShell parser is unavailable, fail closed + * (return true) because structural substitution detection cannot be trusted. */ -export function detectCommandSubstitution(command: string): boolean { - // Try tree-sitter first for accurate parsing - if (isParserAvailable()) { - const tree = parseShellCommand(command); +export function detectCommandSubstitution( + command: string, + shellType?: ShellType, +): boolean { + const language = shellTypeToParserLanguage(shellType); + + if (isParserAvailable(language)) { + const tree = parseShellCommandForLanguage(command, language); if (tree) { - const detected = treeSitterHasCommandSubstitution(tree); + const detected = hasCommandSubstitutionForLanguage(tree, language); + if (language === 'powershell') { + // Fail closed on parse errors: a malformed tree may have missed + // $() subexpressions during error recovery. PowerShell backticks + // are escapes, not substitution — only valid trees are trusted. + return detected || tree.rootNode.hasError; + } if (detected || !tree.rootNode.hasError) { return detected; } } } - // Parser errors require conservative detection of malformed substitution starts. + if (language === 'powershell') { + // PowerShell parser unavailable: fail closed. + return true; + } + return detectCommandSubstitutionRegex(command); } @@ -645,17 +702,20 @@ function hasHeredocOperator(command: string): boolean { function checkParserUnavailableBlock( command: string, shellReplacementMode: 'allowlist' | 'all' | 'none', + language: ParserLanguage, ): PermissionCheckResult | null { if ( shellReplacementMode !== 'all' && - !isParserAvailable() && + !isParserAvailable(language) && (/\r|\n/u.test(command) || hasHeredocOperator(command)) ) { return { allAllowed: false, disallowedCommands: [command], blockReason: - 'Command rejected because multiline and heredoc syntax requires the shell parser', + language === 'powershell' + ? 'Command rejected because multiline syntax requires the PowerShell shell parser' + : 'Command rejected because multiline and heredoc syntax requires the shell parser', isHardDenial: true, }; } @@ -665,52 +725,150 @@ function checkParserUnavailableBlock( function checkShellReplacementBlock( command: string, shellReplacementMode: 'allowlist' | 'all' | 'none', + language: ParserLanguage, ): PermissionCheckResult | null { - if (shellReplacementMode === 'none' && detectCommandSubstitution(command)) { + if ( + shellReplacementMode === 'none' && + detectCommandSubstitution( + command, + language === 'powershell' ? 'powershell' : 'bash', + ) + ) { return { allAllowed: false, disallowedCommands: [command], blockReason: - 'Command substitution using $(), `` ` ``, <(), or >() is not allowed for security reasons', + language === 'powershell' + ? 'PowerShell command substitution using $() is not allowed for security reasons' + : 'Command substitution using $(), `` ` ``, <(), or >() is not allowed for security reasons', isHardDenial: true, }; } return null; } +function getStrictAllowlistDenial( + details: readonly ParsedCommandDetail[], + hasStrictAllowlist: boolean, + command: string, +): PermissionCheckResult | null { + if (!hasStrictAllowlist) { + return null; + } + const unresolvable = details.find( + (d) => d.nameKind === 'dynamic' || d.nameKind === 'expression', + ); + if (!unresolvable) { + return null; + } + return { + allAllowed: false, + disallowedCommands: [command], + blockReason: + 'Command rejected because it contains a dynamic or ' + + 'expression invocation target that cannot be validated ' + + 'against the allowlist', + isHardDenial: true, + }; +} + function extractCommandsToValidate( command: string, shellReplacementMode: 'allowlist' | 'all' | 'none', + language: ParserLanguage, + hasStrictAllowlist: boolean, ): string[] | PermissionCheckResult { const normalize = (cmd: string): string => cmd.trim().replace(/\s+/g, ' '); if (shellReplacementMode === 'allowlist') { - const parseResult = parseCommandDetails(command); - if ( - parseResult && - parseResult.hasError !== true && - parseResult.details.length > 0 - ) { - return parseResult.details - .map((detail) => normalize(detail.text)) + const parseResult = parseCommandDetailsForLanguage(command, language); + + if (parseResult?.hasError === true) { + return { + allAllowed: false, + disallowedCommands: [command], + blockReason: + parseResult.errorReason ?? + 'Command rejected because it could not be parsed safely', + isHardDenial: true, + }; + } + + if (parseResult) { + const strictDenial = getStrictAllowlistDenial( + parseResult.details, + hasStrictAllowlist, + command, + ); + if (strictDenial) { + return strictDenial; + } + + const commands = parseResult.details + .map((detail) => normalize(detail.canonicalText ?? detail.text)) .filter(Boolean); + if (commands.length > 0) { + return commands; + } + // All details filtered to empty (e.g., empty targets). Fall through + // to the normalized command so allowlist checking is not bypassed. + + const normalized = normalize(command); + if (normalized) { + return [normalized]; + } + + // Successful parse produced zero details and the command normalizes + // to empty. Return an empty array rather than falling through to the + // parser-unavailable diagnostic — the parser WAS available (#3181 + // OCR Finding 6). + return []; } - if (parseResult?.hasError === true) { + + // Parser unavailable. + if (language === 'powershell') { return { allAllowed: false, disallowedCommands: [command], - blockReason: 'Command rejected because it could not be parsed safely', + blockReason: + 'PowerShell command rejected because the structural parser ' + + 'is unavailable', isHardDenial: true, }; } - return splitCommands(command).map(normalize); + + // Bash: fall back to regex splitting. + return splitCommands(command, undefined, language).map(normalize); + } + + // For 'all' and 'none' modes: use recursive structured details when the + // parser is available so that blocklisted commands nested inside script + // blocks, pipelines, and wrapper payloads are caught (Finding 6, #3181). + // Substitution was already handled by checkShellReplacementBlock for 'none'. + if (isParserAvailable(language)) { + const parseResult = parseCommandDetailsForLanguage(command, language); + if (parseResult?.hasError === false && parseResult.details.length > 0) { + const commands = parseResult.details + .map((detail) => normalize(detail.canonicalText ?? detail.text)) + .filter(Boolean); + if (commands.length > 0) { + return commands; + } + // All details filtered to empty; fall through to shallow splitter so + // blocklist checking is not bypassed (#3181 review). + } } - return splitCommands(command).map(normalize); + + // Parser unavailable or parse error: fall back to shallow splitting for + // best-effort blocklist checking. For PowerShell without parser, the + // shallow regex splitter is the only option (substitution already checked). + return splitCommands(command, undefined, language).map(normalize); } function checkBlocklist( commandsToValidate: string[], config: ShellPermissionConfig, + language: ParserLanguage, ): PermissionCheckResult | null { const excludeTools = config.getExcludeTools() ?? []; const isWildcardBlocked = SHELL_TOOL_NAMES.some((name) => @@ -726,6 +884,9 @@ function checkBlocklist( }; } + // PowerShell command resolution is case-insensitive; Bash is case-sensitive. + const caseInsensitive = language === 'powershell'; + const invocation: AnyToolInvocation & { params: { command: string } } = { params: { command: '' }, } as AnyToolInvocation & { params: { command: string } }; @@ -733,7 +894,12 @@ function checkBlocklist( for (const cmd of commandsToValidate) { invocation.params['command'] = cmd; if ( - doesToolInvocationMatch('run_shell_command', invocation, excludeTools) + doesToolInvocationMatch( + 'run_shell_command', + invocation, + excludeTools, + caseInsensitive, + ) ) { return { allAllowed: false, @@ -750,6 +916,7 @@ function checkSessionAllowlistMode( commandsToValidate: string[], sessionAllowlist: Set, coreTools: string[], + language: ParserLanguage, ): PermissionCheckResult | null { const invocation: AnyToolInvocation & { params: { command: string } } = { params: { command: '' }, @@ -761,6 +928,9 @@ function checkSessionAllowlistMode( ), ); + // PowerShell command resolution is case-insensitive; Bash is case-sensitive. + const caseInsensitive = language === 'powershell'; + const disallowedCommands: string[] = []; for (const cmd of commandsToValidate) { @@ -769,10 +939,16 @@ function checkSessionAllowlistMode( 'run_shell_command', invocation, [...normalizedSessionAllowlist], + caseInsensitive, ); const isGloballyAllowed = isSessionAllowed ? true - : doesToolInvocationMatch('run_shell_command', invocation, coreTools); + : doesToolInvocationMatch( + 'run_shell_command', + invocation, + coreTools, + caseInsensitive, + ); if (isSessionAllowed || isGloballyAllowed) { continue; } @@ -796,6 +972,7 @@ function checkSessionAllowlistMode( function checkDefaultAllowMode( commandsToValidate: string[], coreTools: string[], + language: ParserLanguage, ): PermissionCheckResult | null { const hasSpecificAllowedCommands = coreTools.filter((tool) => @@ -804,6 +981,9 @@ function checkDefaultAllowMode( if (!hasSpecificAllowedCommands) return null; + // PowerShell command resolution is case-insensitive; Bash is case-sensitive. + const caseInsensitive = language === 'powershell'; + const invocation: AnyToolInvocation & { params: { command: string } } = { params: { command: '' }, } as AnyToolInvocation & { params: { command: string } }; @@ -815,6 +995,7 @@ function checkDefaultAllowMode( 'run_shell_command', invocation, coreTools, + caseInsensitive, ); if (!isGloballyAllowed) { disallowedCommands.push(cmd); @@ -855,14 +1036,18 @@ function checkDefaultAllowMode( * @param config The application configuration. * @param sessionAllowlist A session-level list of approved commands. Its * presence activates "Default Deny" mode. + * @param shellType Optional override for the shell type; defaults to the + * platform's execution shell so validation always matches execution (#3181). * @returns An object detailing which commands are not allowed. */ export function checkCommandPermissions( command: string, config: ShellPermissionConfig, sessionAllowlist?: Set, + shellType?: ShellType, ): PermissionCheckResult { const shellReplacementMode = resolveShellReplacementMode(config); + const language = shellTypeToParserLanguage(resolveShellType(shellType)); // Debug logging when VERBOSE is set if (process.env.VERBOSE === 'true') { @@ -872,6 +1057,7 @@ export function checkCommandPermissions( ephemeralValue, configValue, shellReplacementMode, + language, command: command.substring(0, 50) + (command.length > 50 ? '...' : ''), }); } @@ -879,29 +1065,39 @@ export function checkCommandPermissions( const parserUnavailableBlock = checkParserUnavailableBlock( command, shellReplacementMode, + language, ); if (parserUnavailableBlock) return parserUnavailableBlock; const replacementBlock = checkShellReplacementBlock( command, shellReplacementMode, + language, ); if (replacementBlock) return replacementBlock; + const coreTools = config.getCoreTools() ?? []; + const isWildcardAllowed = SHELL_TOOL_NAMES.some((name) => + coreTools.includes(name), + ); + const hasSpecificAllowedCommands = coreTools.some((tool) => + SHELL_TOOL_NAMES.some((name) => tool.startsWith(`${name}(`)), + ); + const hasStrictAllowlist = + !isWildcardAllowed && (!!sessionAllowlist || hasSpecificAllowedCommands); + const commandsOrError = extractCommandsToValidate( command, shellReplacementMode, + language, + hasStrictAllowlist, ); if (!Array.isArray(commandsOrError)) return commandsOrError; const commandsToValidate = commandsOrError; - const blocklistResult = checkBlocklist(commandsToValidate, config); + const blocklistResult = checkBlocklist(commandsToValidate, config, language); if (blocklistResult) return blocklistResult; - const coreTools = config.getCoreTools() ?? []; - const isWildcardAllowed = SHELL_TOOL_NAMES.some((name) => - coreTools.includes(name), - ); if (isWildcardAllowed) { return { allAllowed: true, disallowedCommands: [] }; } @@ -911,10 +1107,15 @@ export function checkCommandPermissions( commandsToValidate, sessionAllowlist, coreTools, + language, ); if (sessionResult) return sessionResult; } else { - const defaultResult = checkDefaultAllowMode(commandsToValidate, coreTools); + const defaultResult = checkDefaultAllowMode( + commandsToValidate, + coreTools, + language, + ); if (defaultResult) return defaultResult; } @@ -930,14 +1131,22 @@ export function checkCommandPermissions( * * @param command The shell command string to validate. * @param config The application configuration. + * @param shellType Optional override for the shell type; defaults to the + * platform's execution shell. * @returns An object with 'allowed' boolean and optional 'reason' string if not allowed. */ export function isCommandAllowed( command: string, config: ShellPermissionConfig, + shellType?: ShellType, ): { allowed: boolean; reason?: string } { // By not providing a sessionAllowlist, we invoke "default allow" behavior. - const { allAllowed, blockReason } = checkCommandPermissions(command, config); + const { allAllowed, blockReason } = checkCommandPermissions( + command, + config, + undefined, + shellType, + ); if (allAllowed) { return { allowed: true }; } diff --git a/packages/core/src/utils/tool-utils.ts b/packages/core/src/utils/tool-utils.ts index 78a47ae3cb..7b93c7605e 100644 --- a/packages/core/src/utils/tool-utils.ts +++ b/packages/core/src/utils/tool-utils.ts @@ -7,7 +7,17 @@ import levenshtein from 'fast-levenshtein'; import type { AnyDeclarativeTool, AnyToolInvocation } from '../index.js'; import { isTool } from '../index.js'; -import { SHELL_TOOL_NAMES, splitCommands } from './shell-utils.js'; +import { + SHELL_TOOL_NAMES, + splitCommands, + shellTypeToParserLanguage, + type ShellType, +} from './shell-utils.js'; +import { + parseCommandDetailsForLanguage, + isParserAvailable, +} from './shell-parser.js'; +import type { ParserLanguage } from './shell-parser.js'; /** * Checks if a tool invocation matches any of a list of patterns. @@ -25,6 +35,7 @@ export function doesToolInvocationMatch( toolOrToolName: AnyDeclarativeTool | string, invocation: AnyToolInvocation | string, patterns: string[], + caseInsensitive = false, ): boolean { let toolNames: string[]; if (isTool(toolOrToolName)) { @@ -38,7 +49,7 @@ export function doesToolInvocationMatch( } for (const pattern of patterns) { - if (matchesToolPattern(pattern, toolNames, invocation)) { + if (matchesToolPattern(pattern, toolNames, invocation, caseInsensitive)) { return true; } } @@ -50,6 +61,7 @@ function matchesToolPattern( pattern: string, toolNames: string[], invocation: AnyToolInvocation | string, + caseInsensitive = false, ): boolean { const openParen = pattern.indexOf('('); @@ -74,9 +86,17 @@ function matchesToolPattern( command = String((invocation.params as { command: string }).command); } + // PowerShell command resolution is case-insensitive; Bash is case-sensitive. + // Normalize both sides for comparison without mutating the original values. + const compareCommand = caseInsensitive ? command.toLowerCase() : command; + const compareArgPattern = caseInsensitive + ? argPattern.toLowerCase() + : argPattern; + return ( toolNames.some((name) => SHELL_TOOL_NAMES.includes(name)) && - (command === argPattern || command.startsWith(argPattern + ' ')) + (compareCommand === compareArgPattern || + compareCommand.startsWith(compareArgPattern + ' ')) ); } @@ -85,13 +105,19 @@ function matchesToolPattern( * This function handles chained commands (e.g., "echo foo && ls -l") by ensuring * ALL segments of the chained command are allowlisted. * + * When `shellType` is provided, uses shell-aware recursive structured detail + * parsing so that nested commands inside script blocks, pipelines, subshells, + * and wrapper payloads are all validated (Finding 5, #3181). + * * @param invocation The tool invocation containing the command to check. * @param allowedPatterns A list of patterns that represent allowed tools/commands. + * @param shellType The execution shell type; defaults to Bash when omitted. * @returns True if the invocation is allowlisted, false otherwise. */ export function isShellInvocationAllowlisted( invocation: AnyToolInvocation, allowedPatterns: string[], + shellType?: ShellType, ): boolean { if (allowedPatterns.length === 0) { return false; @@ -124,25 +150,74 @@ export function isShellInvocationAllowlisted( } const command = commandValue.trim(); - const normalize = (cmd: string): string => cmd.trim().replace(/\s+/g, ' '); - const commandsToValidate = splitCommands(command) - .map(normalize) - .filter(Boolean); + const language: ParserLanguage = shellTypeToParserLanguage(shellType); + + const commandsToValidate = resolveAllowlistCommands( + command, + language, + normalize, + ); if (commandsToValidate.length === 0) { return false; } + // PowerShell command resolution is case-insensitive; Bash is case-sensitive. + const caseInsensitive = language === 'powershell'; + return commandsToValidate.every((commandSegment) => doesToolInvocationMatch( SHELL_TOOL_NAMES[0], { params: { command: commandSegment } } as AnyToolInvocation, allowedPatterns, + caseInsensitive, ), ); } +/** + * Resolve the list of command texts to validate against the allowlist. + * + * For shells with a matching parser (Bash, PowerShell under Bun), use the + * recursive structured detail extraction so nested commands, script blocks, + * pipelines, and wrapper payloads are all enumerated. Each detail's + * canonicalText (or text fallback) is normalized for pattern matching. + * + * For Bash without a parser, fall back to `splitCommands`. + * For PowerShell without a parser, fail closed (return an empty array, + * which matches no specific allowlist pattern). + */ +function resolveAllowlistCommands( + command: string, + language: ParserLanguage, + normalize: (cmd: string) => string, +): string[] { + if (isParserAvailable(language)) { + const parseResult = parseCommandDetailsForLanguage(command, language); + + if (parseResult?.hasError === false && parseResult.details.length > 0) { + return parseResult.details + .map((detail) => normalize(detail.canonicalText ?? detail.text)) + .filter(Boolean); + } + + // Parse error: fail closed by returning an empty array, which will not + // match any specific allowlist pattern. + if (parseResult?.hasError === true) { + return []; + } + } + + // PowerShell parser unavailable: fail closed. + if (language === 'powershell') { + return []; + } + + // Bash fallback: split using regex. + return splitCommands(command).map(normalize).filter(Boolean); +} + /** * Build a friendly suggestion message when a tool can't be found. * Uses Levenshtein distance to find similar tool names. diff --git a/project-plans/issue3181/PLAN.md b/project-plans/issue3181/PLAN.md new file mode 100644 index 0000000000..475bad5dd2 --- /dev/null +++ b/project-plans/issue3181/PLAN.md @@ -0,0 +1,473 @@ +# Issue #3181 — Shell-aware command validation on Windows + +Plan ID: `PLAN-20260808-SHELL-PARSER-WINDOWS` + +## Status + +Implementation and issue-focused verification are complete. The focused issue +#3181 suite passes 447 tests with 2 pre-existing fallback-path skips and no +failures. Multiple RED/GREEN security-remediation loops, the initial +OpenCodeReview pass, and a final independent security review are complete; the +final independent review returned `READY` with no findings. The required final +OpenCodeReview rerun also completed. Its two valid behavioral findings were +remediated with tests: malformed PowerShell parse trees now fail closed during +substitution detection, and successful empty parse results no longer produce a +false parser-unavailable diagnostic. Remaining findings were test-maintenance +suggestions, intentional compatibility differences, or false positives and did +not justify weakening the security model. + +The PowerShell (`tree-sitter-pwsh`) grammar is integrated for the Bun CLI and +validation now selects behavior from the execution shell. Bash behavior is +unchanged. + +### Verified guarantees + +- **Bun CLI**: PowerShell grammar loads and structural validation works. +- **Node** (core/A2A/library): PowerShell grammar is NOT loaded (`isBunRuntime` + guard). PowerShell validation fails closed truthfully. Bash grammar still + loads under Node. +- **cmd.exe**: maps to Bash grammar (no dedicated cmd grammar exists). +- **Case-insensitive**: PowerShell blocklist/allowlist matching is + case-insensitive; Bash remains case-sensitive. +- **Wrapper/evaluator bypass prevention**: `Invoke-Expression`/`iex`, + `powershell`/`pwsh -Command`, `bash`/`sh -c`, `cmd /c`, and + `Start-Process`/`saps`/`start` are recursively validated. Statically + resolvable payloads are parsed; dynamic payloads fail closed under strict + allowlists. +- **Blocklist recursion**: `excludeTools` recurses into script blocks, + subexpressions, pipelines, and wrapper payloads in all modes (`none`, + `allowlist`, `all`). `all` relaxes only substitution restrictions. +- **Canonical matching**: literal call targets and dot-source paths normalize + to basename. Expandable string targets are dynamic. +- **27-command construct corpus**: all documented construct families pass. +- **Parser.ParseInput conformance**: Windows-only bounded test compares a + subset against the semantic ground truth via stdin (data, never executed). + +## Problem statement + +LLxprt executes `run_shell_command` through PowerShell on Windows, but validates +the command as Bash. Valid PowerShell syntax therefore becomes a Bash parse +error and is hard-denied before execution with: + +```text +Command rejected because it could not be parsed safely +``` + +This is a platform-specific parser-selection defect, not an expected rejection +and not the parser-initialization defect fixed by issue #2950 / PR #2961. + +## Reproduction + +The defect reproduced immediately on Windows with a command shaped like: + +```powershell +git status --short --branch; git checkout main; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +``` + +The command is valid PowerShell. The tool rejected it before spawning +PowerShell. Splitting the same operation into commands that are also valid Bash +allowed execution. + +A saved LLxprt recording from the stranded repository contains repeated +rejections of valid commands using these PowerShell constructs: + +- variable assignment and .NET member invocation; +- `ForEach-Object { ... }` and `Where-Object { ... }`; +- `foreach` and `if` statements; +- the `&` invocation operator; +- `@(...)` array expressions; +- `*>&1` and PowerShell redirection; +- `Start-Process` with PowerShell expressions; +- property and method access such as `$value.Name` and `$value.Trim()`. + +The recording contained 27 actual `run_shell_command` tool responses with this +parse rejection. These are direct behavioral examples, not synthetic guesses. + +## Root cause + +### Execution is shell-aware + +`packages/core/src/utils/shell-utils.ts:80-107` selects PowerShell on Windows: + +```typescript +{ + executable: 'powershell.exe', + argsPrefix: ['-NoProfile', '-Command'], + shell: 'powershell', +} +``` + +`ShellExecutionService` and the managed shell-job path consume that +configuration when they spawn the command. + +### Validation is Bash-only + +The permission path is: + +1. `packages/tools/src/tools/shell.ts:794-813` validates the tool parameters. +2. `CoreShellToolHostAdapter.isCommandAllowed()` delegates to + `shell-utils.isCommandAllowed()`. +3. `checkCommandPermissions()` resolves the default `shell-replacement` mode to + `allowlist`. +4. `extractCommandsToValidate()` calls `parseCommandDetails(command)`. +5. `packages/core/src/utils/shell-parser.ts` parses through one singleton + `web-tree-sitter` parser loaded only with `tree-sitter-bash`. +6. `parseCommandDetails()` marks any Bash `ERROR`/`MISSING` node as an error. +7. `shell-utils.ts:698-704` converts that result into a hard denial. + +No shell type reaches `parseCommandDetails`, `getCommandRoots`, +`splitCommands`, or `detectCommandSubstitution`. The validator therefore asks +whether PowerShell source is valid Bash instead of whether it is valid in the +shell that will execute it. + +### Misleading current tests + +The Windows block in `packages/core/src/utils/shell-utils.test.ts:563-593` does +not exercise a PowerShell parser: + +- “PowerShell AST output” is output from the Bash grammar. The test input happens + to be valid in both languages. +- “PowerShell parser reports errors” is also the Bash grammar rejecting an + incomplete pipeline that is invalid in both languages. + +Those tests should be replaced, not merely supplemented, because their names +currently claim behavior that does not exist. + +## Related issue distinction + +Issue #2950 covered an uninitialized/unavailable Bash WASM parser. PR #2961 +correctly initialized that parser before tool registration. Issue #3181 occurs +when initialization succeeds: the available parser is the wrong language for +the Windows execution shell. + +## Parser-option investigation + +### Real PowerShell `Parser.ParseInput` + +`[System.Management.Automation.Language.Parser]::ParseInput` is the semantic +ground truth for Windows PowerShell. It parsed every command in the saved +rejection corpus without an error, and `CommandAst.GetCommandName()` produced +the expected nested command names. + +It is safe only if untrusted command text is passed as data (for example over +stdin), never interpolated into the helper script. `ParseInput` itself parses and +does not execute the supplied command. + +It is not a good per-call implementation behind the current synchronous tool +validation API. Local no-profile PowerShell startup measurements ranged from +roughly 430 ms to 750 ms. Starting one process and parsing the full corpus took +about 1.1 seconds. Spawning a new process for every shell tool validation would +be a visible regression. A persistent helper would require asynchronous +lifecycle/IPC changes while the current parameter validation contract is +synchronous. + +Use `Parser.ParseInput` as a Windows conformance oracle in integration tests, +not as the default hot-path parser. + +### `tree-sitter-powershell@0.26.4` + +This in-process grammar parsed 22 of the 27 valid saved commands. It still +reported false syntax errors for valid argument lists, comma-separated paths, +string concatenation, and compound expressions. It is not sufficient for this +fix. + +### `tree-sitter-pwsh@0.38.1` + +The maintained `wharflab/tree-sitter-powershell` fork is published as +`tree-sitter-pwsh` and includes a WASM grammar. In the local Bun 1.3.14 spike it: + +- parsed every command in the saved rejection corpus without an error; +- rejected malformed inputs such as `Get-ChildItem |` and `if (`; +- exposed nested `command` nodes under script blocks, subexpressions, + pipelines, `foreach`, and `if` conditions; +- exposed static and dynamic invocation expressions distinctly; +- extracted the same command-name sequence as PowerShell `CommandAst` for the + corpus; +- parsed the corpus in-process in approximately 53 ms total. + +The same ad hoc WASM spike under Node 24 on Windows printed the correct result +but crashed during process shutdown with a V8 “Zone” out-of-memory failure. +The shipped CLI now uses Bun, and the Bun spike exited cleanly, but this remains +a preflight compatibility gate: implementation must prove the selected grammar +and runtime combination is stable in every supported invocation/test path. + +## Recommended design + +Use a shell-aware parser facade and an in-process PowerShell grammar, subject to +the runtime-stability preflight. Keep the existing Bash parser behavior intact. + +### Shared contract + +Define a shell-neutral parse result consumed by permission checking: + +```typescript +interface ParsedShellCommand { + text: string; + name: string | null; + nameKind: 'static' | 'dynamic' | 'expression'; +} + +interface ShellCommandParseResult { + commands: ParsedShellCommand[]; + hasError: boolean; + error?: { + parser: 'bash-tree-sitter' | 'powershell-tree-sitter'; + message: string; + row?: number; + column?: number; + }; +} +``` + +The exact type names may follow neighboring conventions, but the behavior must +preserve the distinction between a statically resolvable command name and an +expression that cannot be safely allowlisted. + +### Parser selection + +- Bash execution → existing `tree-sitter-bash` implementation. +- PowerShell execution → maintained PowerShell tree-sitter implementation. +- Selection must use the same `ShellConfiguration` that execution uses; do not + independently infer the platform in the permission layer. +- Parser availability must be tracked per shell/language, not through the + current global Bash-only `isParserAvailable()` boolean. + +Avoid a circular dependency between `shell-utils.ts` and `shell-parser.ts` by +moving shell configuration/types to a lower-level module or by injecting the +resolved shell into the parser facade from the existing host adapter. + +### PowerShell extraction rules + +In `allowlist` mode: + +1. Reject trees with syntax errors. +2. Traverse all PowerShell `command` nodes recursively, including commands in + script blocks and `$()` subexpressions. +3. Treat a literal call-operator target such as `& 'tool.exe'` as a static + command and normalize its root like other paths. +4. Fail closed for dynamic call targets such as `& $command` or `. $script`; + they cannot be compared honestly with an allowlist. +5. Do not silently discard executable invocation expressions. Static .NET + method invocations and other effectful expressions must either become + explicit permission details or receive a documented fail-closed policy in + restricted allowlist mode. A command such as + `[System.Diagnostics.Process]::Start(...)` must not piggyback unnoticed on an + unrelated allowed command. +6. Preserve command text/extents for blocklist and confirmation messages. + +`Invoke-Expression`, nested shell wrappers (`powershell -Command`, `cmd /c`, +`bash -c`), and dynamically created script blocks deserve explicit security +cases. A shell-aware parser must not accidentally make those less restrictive +than the current fail-closed behavior. + +### Substitution modes + +PowerShell semantics must replace Bash semantics on the PowerShell path: + +- PowerShell backticks are escapes/line continuations, not Bash-style command + substitution. +- `$()` is a PowerShell subexpression and its nested commands must be found. +- Script-block command nodes must be validated recursively in `allowlist` mode. +- `none` mode must block the PowerShell execution/substitution forms that the + setting promises to block without rejecting ordinary PowerShell escapes. + +Do not route PowerShell through the current Bash regex fallback. If the +PowerShell grammar is unavailable in a mode that requires structural parsing, +fail closed with an accurate PowerShell-parser diagnostic. + +### Diagnostics + +Replace the ambiguous error with a shell-aware reason while keeping command +text out of telemetry: + +```text +PowerShell command rejected because powershell-tree-sitter reported a syntax error at 1:42 +``` + +Debug logging may include parser captures under the existing debug-logging +privacy rules. User-facing output should identify the selected shell/parser and +the first useful error location. + +## Acceptance criteria + +### REQ-3181-001 — Validation matches execution shell + +**GIVEN** the execution configuration selects PowerShell +**WHEN** a command is permission-checked +**THEN** it is parsed with the PowerShell parser, not the Bash parser. + +Bash execution continues to use the existing Bash grammar unchanged. + +### REQ-3181-002 — Valid PowerShell is accepted + +Representative valid PowerShell constructs from the saved failures do not +produce a parse hard-denial: assignments, methods, script blocks, control flow, +call operators, arrays, redirects, and multiline source. + +This means “not rejected as malformed”; ordinary allowlist, blocklist, +confirmation, and workspace policies still apply. + +### REQ-3181-003 — Invalid PowerShell fails closed + +Malformed PowerShell and parser failures remain hard denials. The message names +the PowerShell parser and reports a useful location/reason when available. + +### REQ-3181-004 — Nested commands remain enforceable + +Every statically resolvable command under PowerShell script blocks, +subexpressions, pipelines, and control flow is checked. A blocked command hidden +inside `ForEach-Object { ... }`, `$()`, or `& { ... }` is still blocked. + +Dynamic command targets do not pass an allowlist as if they were known static +commands. + +### REQ-3181-005 — Command roots are shell-correct + +PowerShell pipelines and literal invocation targets yield normalized roots for +permission prompts. Pure expressions do not acquire fabricated Bash roots. + +### REQ-3181-006 — Substitution policy is shell-correct + +PowerShell backticks are not treated as Bash command substitution. PowerShell +subexpressions and nested executable forms follow the configured +`allowlist`/`all`/`none` behavior. + +### REQ-3181-007 — Runtime compatibility is proven + +The PowerShell grammar initializes and shuts down cleanly under the supported +Bun CLI runtime on Windows and in the repository's cross-platform Bun tests. +Any supported Node path that loads the grammar must also exit cleanly; otherwise +that path must be shown not to load core parsing code. + +## Test-first implementation sequence + +### P01 — Preflight and dependency gate + +Before production edits: + +1. Add a temporary or test-owned corpus covering the real rejected construct + families. +2. Verify the selected package's license, WASM artifact resolution, Bun + compatibility, package publishing integrity, and installed-package layout. +3. Reproduce and resolve or bound the observed Node shutdown crash. +4. Verify that both Bash and PowerShell WASM assets load from source, + development, built, and npm-installed layouts. +5. Record the accepted dependency/version decision in this plan. + +Do not proceed with a grammar that cannot parse the real corpus or that makes a +supported runtime crash. + +### P02 — RED: shell-selection integration tests + +Write failing behavioral tests through the public permission path showing that: + +- a valid PowerShell `if ($LASTEXITCODE ...)` command is not a parse denial; +- the same PowerShell-only syntax is not sent to the Bash parser; +- Bash behavior remains unchanged when Bash is selected; +- a PowerShell parser initialization failure is a hard denial with an accurate + reason. + +Use the real grammar. Do not mock `parseCommandDetails` to return the desired +answer. + +### P03 — RED: PowerShell grammar behavior + +Write failing Bun tests for: + +- each real rejected construct family; +- malformed PowerShell; +- recursive command extraction from pipelines, `if`, `foreach`, script blocks, + `$()`, and `& { ... }`; +- literal call targets versus dynamic targets; +- static invocation expressions and the restricted-mode policy; +- PowerShell backticks and multiline source; +- syntax-error diagnostics. + +On Windows, add a bounded conformance test that compares the checked-in corpus +against `Parser.ParseInput` without executing any corpus command. + +### P04 — GREEN: parser facade and initialization + +Implement the minimum shell-aware parser contract and initialize both grammar +assets. Keep Bash behavior and its current security checks unchanged. Wire +parser selection to the execution shell configuration. + +### P05 — GREEN: permission and root extraction integration + +Route `checkCommandPermissions`, command-root extraction, and substitution +checks through shell-aware results. Preserve blocklist/allowlist behavior and +fail closed for unresolved dynamic execution. + +Update the misleading Windows tests so they exercise the actual PowerShell +parser. + +### P06 — RED/GREEN: end-to-end shell tool behavior + +Add an integration test through `ShellTool.validateToolParamValues` and the real +core host adapter. Prove a valid PowerShell-only command reaches the normal +permission/confirmation path, malformed input does not, and a blocked nested +command remains blocked. + +Where Windows CI is available, add a non-destructive execution test using a +PowerShell-only construct. Cross-platform tests must still exercise the WASM +parser directly rather than skipping all useful coverage off Windows. + +### P07 — Documentation and verification + +Update shell-replacement documentation to describe per-shell parsing and +PowerShell semantics. Run focused tests after each RED/GREEN step, then the full +required verification cycle: + +```text +npm run test +npm run lint +npm run typecheck +npm run format +npm run build +bun scripts/start.ts --profile-load stepfun-37 "write me a haiku and nothing else" +``` + +Finally, exercise a representative subset of the formerly rejected PowerShell +commands through the built CLI on Windows. + +### Verification results + +- Issue-focused behavioral suites: 447 passed, 2 pre-existing fallback-path + tests skipped, 0 failed. +- Dependency trust and strict-layout resolution suites: 21 passed, 0 failed. +- `npm run format`, repository-wide `npm run typecheck`, repository-wide + `npm run build`, explicit changed-file ESLint, integration-test ESLint, and + `bun scripts/bun-native-modules-smoke.ts` completed successfully. +- Final OpenCodeReview completed successfully. The malformed-tree substitution + and empty-success findings were remediated and the focused suite was rerun; + incompatible and false-positive suggestions were rejected to preserve + truthful parser-unavailable diagnostics and intentional Bash compatibility. +- A complete `npm run test` run progressed without a failure until it stalled in + the unchanged Windows-only + `shellJobManagerCancelRace.test.ts` cancellation-ownership suite and was + terminated after exceeding that test's own timeout by several minutes. The + issue-focused suites, including real Windows shell-adapter behavior, remain + green. +- Root `npm run lint` and `npm run lint:changed` exit 1 without diagnostics on + this Windows machine; direct ESLint over every changed or new TypeScript file + passes. A default-heap root ESLint attempt separately exhausted V8 memory. +- The prescribed StepFun smoke could not start because the local `stepfun-37` + profile is not installed. No credentials or user configuration were changed. +- The standalone unchanged bundle-runtime-assets suite is limited on this + Windows environment: required-asset staging passes, while three spawned-bundle + launch assertions produce no child output and one directory-shaped-asset case + fails during Windows symlink cleanup with `EFAULT`. +- Generated lockfile churn was removed; final lock changes contain only direct + dependency entries and the `tree-sitter-pwsh` package records. + +## Out of scope + +- Replacing the configured Windows execution shell. +- Weakening validation by accepting Bash parse errors on Windows. +- A regex-only PowerShell parser. +- Executing untrusted command text inside a parser helper. +- Broad redesign of shell approval policy unrelated to the parser mismatch. + +Known wrapper/evaluator risks discovered while implementing shell-aware nested +extraction should be fixed if necessary to avoid a security regression; larger +policy redesigns should receive separate issues. diff --git a/scripts/bun-native-modules-smoke.ts b/scripts/bun-native-modules-smoke.ts index 6cf2da8e03..1d341c445e 100644 --- a/scripts/bun-native-modules-smoke.ts +++ b/scripts/bun-native-modules-smoke.ts @@ -165,6 +165,40 @@ async function checkTreeSitter(): Promise { } } +// --------------------------------------------------------------------------- +// 3b. web-tree-sitter + tree-sitter-pwsh WASM (#3181) +// --------------------------------------------------------------------------- +async function checkTreeSitterPowerShell(): Promise { + try { + const { Parser, Language } = await import('web-tree-sitter'); + await Parser.init(); + const parser = new Parser(); + const wasmPath = require.resolve( + 'tree-sitter-pwsh/tree-sitter-powershell.wasm', + ); + const wasmBytes = readFileSync(wasmPath); + const pwshLanguage = await Language.load(wasmBytes); + parser.setLanguage(pwshLanguage); + const tree = parser.parse('Get-Process | Where-Object { $_.Name -eq "x" }'); + if (tree === null) { + throw new Error('parser returned null tree'); + } + if (tree.rootNode.type !== 'program') { + throw new Error( + `expected root node type "program", got "${tree.rootNode.type}"`, + ); + } + if (tree.rootNode.hasError) { + throw new Error(`PowerShell parse produced a syntax error tree`); + } + pass( + 'web-tree-sitter + tree-sitter-pwsh WASM: parse PowerShell command (#3181)', + ); + } catch (e) { + fail('web-tree-sitter + tree-sitter-pwsh WASM (#3181)', e); + } +} + // --------------------------------------------------------------------------- // 4. @lydell/node-pty (Windows ConPTY path; Windows-only) // --------------------------------------------------------------------------- @@ -320,6 +354,7 @@ async function checkBunPty(): Promise { await checkAstGrep(); await checkKeyring(); await checkTreeSitter(); +await checkTreeSitterPowerShell(); await checkNodePty(); await checkBunPty(); diff --git a/scripts/tests/bun-workspaces.test.ts b/scripts/tests/bun-workspaces.test.ts index 6d0460e275..bb4a52f193 100644 --- a/scripts/tests/bun-workspaces.test.ts +++ b/scripts/tests/bun-workspaces.test.ts @@ -103,6 +103,9 @@ const REVIEWED_UNTRUSTED_INSTALL_SCRIPTS: readonly string[] = [ // separate prebuilt platform packages, or the script is dev/build-only). 'esbuild', // platform binary delivered by @esbuild/, no script needed 'msw', // dev/test-only mock service worker; postinstall not runtime-required + // Only the published PowerShell WASM is loaded; the native Node binding built + // by this lifecycle script is intentionally unused (and unstable on Node 24). + 'tree-sitter-pwsh', 'node-pty', // legacy fallback; runtime prefers prebuilt @lydell/node-pty // Transitive deps that ship a lifecycle script we deliberately do not run. '@vscode/vsce-sign', // release/VSCE signing tooling, not a CLI runtime need diff --git a/scripts/tests/issue-3181-pwsh-resolution.bun.test.ts b/scripts/tests/issue-3181-pwsh-resolution.bun.test.ts new file mode 100644 index 0000000000..621bbe1cb1 --- /dev/null +++ b/scripts/tests/issue-3181-pwsh-resolution.bun.test.ts @@ -0,0 +1,176 @@ +/** + * @license + * Copyright 2026 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Issue #3181 — the published CLI bundle must be able to resolve + * tree-sitter-pwsh/tree-sitter-powershell.wasm from its own direct dependency, + * without relying on root-level hoisting. + * + * The bundled CLI lives in `packages/cli/bundle/`. At runtime, `shell-parser.ts` + * resolves the WASM via `createRequire(import.meta.url)` + + * `require.resolve('tree-sitter-pwsh/tree-sitter-powershell.wasm')`. In a + * strict (non-hoisted) npm layout, this resolution succeeds only if + * tree-sitter-pwsh is a direct dependency of the package that owns the bundle + * (packages/cli), because Node's module resolution walks up from the bundle's + * directory to the nearest `node_modules`. + * + * These tests construct a real isolated layout (tree-sitter-pwsh placed only in + * the package's own `node_modules`, never hoisted to a parent) and prove + * resolution works — and fails when the local copy is absent. + */ + +import { describe, expect, it } from 'bun:test'; +import { execFileSync } from 'node:child_process'; +import { + copyFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const repoRoot = resolve(__filename, '..', '..', '..'); +const cliPackageJsonPath = join(repoRoot, 'packages', 'cli', 'package.json'); + +const localRequire = createRequire(import.meta.url); + +/** + * Path to the real tree-sitter-powershell.wasm installed in the workspace. + */ +const realWasmPath = localRequire.resolve( + 'tree-sitter-pwsh/tree-sitter-powershell.wasm', +); + +/** + * Read the actual installed tree-sitter-pwsh version so the synthetic + * package.json mirrors the real dependency without hardcoding a version + * that could drift (#3181 OCR). + */ +const realPwshPackageJsonPath = localRequire.resolve( + 'tree-sitter-pwsh/package.json', +); +const realPwshPackage = JSON.parse( + readFileSync(realPwshPackageJsonPath, 'utf8'), +) as { name: string; version: string }; + +describe('issue #3181: CLI resolves tree-sitter-pwsh WASM from its direct dependency', () => { + it('packages/cli declares tree-sitter-pwsh as a direct runtime dependency', () => { + const pkg = JSON.parse(readFileSync(cliPackageJsonPath, 'utf8')) as { + dependencies?: Record; + }; + expect(pkg.dependencies).toBeDefined(); + expect(pkg.dependencies!['tree-sitter-pwsh']).toBeDefined(); + }); + + it('resolves tree-sitter-powershell.wasm in a strict (non-hoisted) layout', () => { + // Build an isolated layout that mimics a strict npm install: + // / + // cli/ + // node_modules/ + // tree-sitter-pwsh/ + // package.json + // tree-sitter-powershell.wasm ← real WASM + // bundle/ + // resolve-test.mjs ← simulated bundle location + // + // tree-sitter-pwsh exists ONLY in cli/node_modules — no parent hoisting. + const tempRoot = mkdtempSync(join(tmpdir(), 'issue3181-pwsh-strict-')); + try { + const cliDir = join(tempRoot, 'cli'); + const pwshDir = join(cliDir, 'node_modules', 'tree-sitter-pwsh'); + const bundleDir = join(cliDir, 'bundle'); + mkdirSync(pwshDir, { recursive: true }); + mkdirSync(bundleDir, { recursive: true }); + + // Minimal package.json so Node recognizes the directory as a module. + writeFileSync( + join(pwshDir, 'package.json'), + JSON.stringify({ + name: realPwshPackage.name, + version: realPwshPackage.version, + }), + ); + + // Copy the real WASM so resolution points at a real file. + copyFileSync(realWasmPath, join(pwshDir, 'tree-sitter-powershell.wasm')); + + // Node ESM script that uses the same resolution mechanism as + // shell-parser.ts: createRequire(import.meta.url) + require.resolve. + const scriptPath = join(bundleDir, 'resolve-test.mjs'); + writeFileSync( + scriptPath, + [ + "import { createRequire } from 'node:module';", + "import { existsSync } from 'node:fs';", + 'const require = createRequire(import.meta.url);', + 'try {', + " const p = require.resolve('tree-sitter-pwsh/tree-sitter-powershell.wasm');", + ' process.stdout.write(JSON.stringify({ resolved: true, exists: existsSync(p) }));', + '} catch (e) {', + ' process.stdout.write(JSON.stringify({ resolved: false, error: String(e) }));', + '}', + ].join('\n'), + ); + + const stdout = execFileSync('node', [scriptPath], { + encoding: 'utf8', + timeout: 10_000, + }); + + const result = JSON.parse(stdout) as { + resolved: boolean; + exists?: boolean; + error?: string; + }; + expect(result.resolved).toBe(true); + expect(result.exists).toBe(true); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('resolution fails when tree-sitter-pwsh is absent from local node_modules (test has teeth)', () => { + // Same strict layout but WITHOUT tree-sitter-pwsh in any node_modules. + // Resolution MUST fail, proving the positive test is not vacuously true. + const tempRoot = mkdtempSync(join(tmpdir(), 'issue3181-pwsh-absent-')); + try { + const cliDir = join(tempRoot, 'cli'); + const bundleDir = join(cliDir, 'bundle'); + mkdirSync(bundleDir, { recursive: true }); + + const scriptPath = join(bundleDir, 'resolve-test.mjs'); + writeFileSync( + scriptPath, + [ + "import { createRequire } from 'node:module';", + 'const require = createRequire(import.meta.url);', + 'try {', + " require.resolve('tree-sitter-pwsh/tree-sitter-powershell.wasm');", + ' process.stdout.write(JSON.stringify({ resolved: true }));', + '} catch {', + ' process.stdout.write(JSON.stringify({ resolved: false }));', + '}', + ].join('\n'), + ); + + const stdout = execFileSync('node', [scriptPath], { + encoding: 'utf8', + timeout: 10_000, + }); + + const result = JSON.parse(stdout) as { resolved: boolean }; + expect(result.resolved).toBe(false); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); +});