diff --git a/docs/cli/commands.md b/docs/cli/commands.md index 01eafb77b8..b5b25a682a 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -292,9 +292,8 @@ Gemini CLI. - **`!`** - **Description:** Execute the given `` using `bash` on - Linux/macOS or `powershell.exe -NoProfile -Command` on Windows (unless you - override `ComSpec`). Any output or errors from the command are displayed in - the terminal. + Linux/macOS or `cmd.exe` on Windows. Any output or errors from the command + are displayed in the terminal. - **Examples:** - `!ls -la` (executes `ls -la` and returns to Gemini CLI) - `!git status` (executes `git status` and returns to Gemini CLI) diff --git a/docs/tools/shell.md b/docs/tools/shell.md index 3802c10d3f..f5ef32ba41 100644 --- a/docs/tools/shell.md +++ b/docs/tools/shell.md @@ -10,9 +10,8 @@ command, including interactive commands that require user input (e.g., `vim`, `git rebase -i`) if the `tools.shell.enableInteractiveShell` setting is set to `true`. -On Windows, commands are executed with `powershell.exe -NoProfile -Command` -(unless you explicitly point `ComSpec` at another shell). On other platforms, -they are executed with `bash -c`. +On Windows, commands are executed with `cmd.exe /c`. On other platforms, they +are executed with `bash -c`. ### Arguments diff --git a/package-lock.json b/package-lock.json index 40de0c7df8..371d621009 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12432,17 +12432,6 @@ "webidl-conversions": "^3.0.0" } }, - "node_modules/node-gyp-build": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", - "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "license": "MIT", - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, "node_modules/node-pty": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz", @@ -16156,34 +16145,6 @@ "tslib": "2" } }, - "node_modules/tree-sitter-bash": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/tree-sitter-bash/-/tree-sitter-bash-0.25.0.tgz", - "integrity": "sha512-gZtlj9+qFS81qKxpLfD6H0UssQ3QBc/F0nKkPsiFDyfQF2YBqYvglFJUzchrPpVhZe9kLZTrJ9n2J6lmka69Vg==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "node-addon-api": "^8.2.1", - "node-gyp-build": "^4.8.2" - }, - "peerDependencies": { - "tree-sitter": "^0.25.0" - }, - "peerDependenciesMeta": { - "tree-sitter": { - "optional": true - } - } - }, - "node_modules/tree-sitter-bash/node_modules/node-addon-api": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", - "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", - "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", @@ -16975,20 +16936,6 @@ "node": ">=18" } }, - "node_modules/web-tree-sitter": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.25.10.tgz", - "integrity": "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA==", - "license": "MIT", - "peerDependencies": { - "@types/emscripten": "^1.40.0" - }, - "peerDependenciesMeta": { - "@types/emscripten": { - "optional": true - } - } - }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -18084,9 +18031,7 @@ "shell-quote": "^1.8.3", "simple-git": "^3.28.0", "strip-ansi": "^7.1.0", - "tree-sitter-bash": "^0.25.0", "undici": "^7.10.0", - "web-tree-sitter": "^0.25.10", "ws": "^8.18.0" }, "devDependencies": { diff --git a/packages/cli/src/services/prompt-processors/shellProcessor.test.ts b/packages/cli/src/services/prompt-processors/shellProcessor.test.ts index 2c93ecf8c0..e4021b54db 100644 --- a/packages/cli/src/services/prompt-processors/shellProcessor.test.ts +++ b/packages/cli/src/services/prompt-processors/shellProcessor.test.ts @@ -9,7 +9,8 @@ import { ConfirmationRequiredError, ShellProcessor } from './shellProcessor.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import type { CommandContext } from '../../ui/commands/types.js'; import type { Config } from '@google/gemini-cli-core'; -import { ApprovalMode, getShellConfiguration } from '@google/gemini-cli-core'; +import { ApprovalMode } from '@google/gemini-cli-core'; +import os from 'node:os'; import { quote } from 'shell-quote'; import { createPartFromText } from '@google/genai'; import type { PromptPipelineContent } from './types.js'; @@ -17,16 +18,18 @@ import type { PromptPipelineContent } from './types.js'; // Helper function to determine the expected escaped string based on the current OS, // mirroring the logic in the actual `escapeShellArg` implementation. function getExpectedEscapedArgForPlatform(arg: string): string { - const { shell } = getShellConfiguration(); + if (os.platform() === 'win32') { + const comSpec = (process.env['ComSpec'] || 'cmd.exe').toLowerCase(); + const isPowerShell = + comSpec.endsWith('powershell.exe') || comSpec.endsWith('pwsh.exe'); - switch (shell) { - case 'powershell': + if (isPowerShell) { return `'${arg.replace(/'/g, "''")}'`; - case 'cmd': + } else { return `"${arg.replace(/"/g, '""')}"`; - case 'bash': - default: - return quote([arg]); + } + } else { + return quote([arg]); } } diff --git a/packages/core/package.json b/packages/core/package.json index 5a4116acb7..43dc8e459d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -20,10 +20,10 @@ "dist" ], "dependencies": { - "@google-cloud/logging": "^11.2.1", + "@google/genai": "1.16.0", "@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0", "@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0", - "@google/genai": "1.16.0", + "@google-cloud/logging": "^11.2.1", "@joshua.litt/get-ripgrep": "^0.0.2", "@modelcontextprotocol/sdk": "^1.11.0", "@opentelemetry/api": "^1.9.0", @@ -61,9 +61,7 @@ "shell-quote": "^1.8.3", "simple-git": "^3.28.0", "strip-ansi": "^7.1.0", - "tree-sitter-bash": "^0.25.0", "undici": "^7.10.0", - "web-tree-sitter": "^0.25.10", "ws": "^8.18.0" }, "optionalDependencies": { diff --git a/packages/core/src/services/shellExecutionService.test.ts b/packages/core/src/services/shellExecutionService.test.ts index 45aa9fce97..b8457e40f8 100644 --- a/packages/core/src/services/shellExecutionService.test.ts +++ b/packages/core/src/services/shellExecutionService.test.ts @@ -24,14 +24,9 @@ const mockSerializeTerminalToObject = vi.hoisted(() => vi.fn()); vi.mock('@lydell/node-pty', () => ({ spawn: mockPtySpawn, })); -vi.mock('node:child_process', async (importOriginal) => { - const actual = - (await importOriginal()) as typeof import('node:child_process'); - return { - ...actual, - spawn: mockCpSpawn, - }; -}); +vi.mock('child_process', () => ({ + spawn: mockCpSpawn, +})); vi.mock('../utils/textUtils.js', () => ({ isBinary: mockIsBinary, })); @@ -470,15 +465,15 @@ describe('ShellExecutionService', () => { }); describe('Platform-Specific Behavior', () => { - it('should use powershell.exe on Windows', async () => { + it('should use cmd.exe on Windows', async () => { mockPlatform.mockReturnValue('win32'); await simulateExecution('dir "foo bar"', (pty) => pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }), ); expect(mockPtySpawn).toHaveBeenCalledWith( - 'powershell.exe', - ['-NoProfile', '-Command', 'dir "foo bar"'], + 'cmd.exe', + '/c dir "foo bar"', expect.any(Object), ); }); @@ -642,9 +637,9 @@ describe('ShellExecutionService child_process fallback', () => { }); expect(mockCpSpawn).toHaveBeenCalledWith( - 'bash', - ['-c', 'ls -l'], - expect.objectContaining({ shell: false, detached: true }), + 'ls -l', + [], + expect.objectContaining({ shell: 'bash' }), ); expect(result.exitCode).toBe(0); expect(result.signal).toBeNull(); @@ -910,19 +905,18 @@ describe('ShellExecutionService child_process fallback', () => { }); describe('Platform-Specific Behavior', () => { - it('should use powershell.exe on Windows', async () => { + it('should use cmd.exe on Windows', async () => { mockPlatform.mockReturnValue('win32'); await simulateExecution('dir "foo bar"', (cp) => cp.emit('exit', 0, null), ); expect(mockCpSpawn).toHaveBeenCalledWith( - 'powershell.exe', - ['-NoProfile', '-Command', 'dir "foo bar"'], + 'dir "foo bar"', + [], expect.objectContaining({ - shell: false, + shell: true, detached: false, - windowsVerbatimArguments: false, }), ); }); @@ -932,10 +926,10 @@ describe('ShellExecutionService child_process fallback', () => { await simulateExecution('ls "foo bar"', (cp) => cp.emit('exit', 0, null)); expect(mockCpSpawn).toHaveBeenCalledWith( - 'bash', - ['-c', 'ls "foo bar"'], + 'ls "foo bar"', + [], expect.objectContaining({ - shell: false, + shell: 'bash', detached: true, }), ); diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index f3bdddbc55..d76fc56ee2 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -12,7 +12,6 @@ import { TextDecoder } from 'node:util'; import os from 'node:os'; import type { IPty } from '@lydell/node-pty'; import { getCachedEncodingForBuffer } from '../utils/systemEncoding.js'; -import { getShellConfiguration } from '../utils/shell-utils.js'; import { isBinary } from '../utils/textUtils.js'; import pkg from '@xterm/headless'; import { @@ -190,14 +189,12 @@ export class ShellExecutionService { ): ShellExecutionHandle { try { const isWindows = os.platform() === 'win32'; - const { executable, argsPrefix } = getShellConfiguration(); - const spawnArgs = [...argsPrefix, commandToExecute]; - const child = cpSpawn(executable, spawnArgs, { + const child = cpSpawn(commandToExecute, [], { cwd, stdio: ['ignore', 'pipe', 'pipe'], - windowsVerbatimArguments: isWindows ? false : undefined, - shell: false, + windowsVerbatimArguments: true, + shell: isWindows ? true : 'bash', detached: !isWindows, env: { ...process.env, @@ -403,10 +400,13 @@ export class ShellExecutionService { try { const cols = shellExecutionConfig.terminalWidth ?? 80; const rows = shellExecutionConfig.terminalHeight ?? 30; - const { executable, argsPrefix } = getShellConfiguration(); - const args = [...argsPrefix, commandToExecute]; + const isWindows = os.platform() === 'win32'; + const shell = isWindows ? 'cmd.exe' : 'bash'; + const args = isWindows + ? `/c ${commandToExecute}` + : ['-c', commandToExecute]; - const ptyProcess = ptyInfo.module.spawn(executable, args, { + const ptyProcess = ptyInfo.module.spawn(shell, args, { cwd, name: 'xterm', cols, diff --git a/packages/core/src/tools/__snapshots__/shell.test.ts.snap b/packages/core/src/tools/__snapshots__/shell.test.ts.snap index 76a5ded3ef..1579cb9716 100644 --- a/packages/core/src/tools/__snapshots__/shell.test.ts.snap +++ b/packages/core/src/tools/__snapshots__/shell.test.ts.snap @@ -17,7 +17,7 @@ exports[`ShellTool > getDescription > should return the non-windows description `; exports[`ShellTool > getDescription > should return the windows description when on windows 1`] = ` -"This tool executes a given shell command as \`powershell.exe -NoProfile -Command \`. Command can start background processes using PowerShell constructs such as \`Start-Process -NoNewWindow\` or \`Start-Job\`. +"This tool executes a given shell command as \`cmd.exe /c \`. Command can start background processes using \`start /b\`. The following information is returned: diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index 90998a6ae4..d5854df49f 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -9,7 +9,6 @@ import { describe, it, expect, - beforeAll, beforeEach, afterEach, type Mock, @@ -24,10 +23,7 @@ vi.mock('os'); vi.mock('crypto'); vi.mock('../utils/summarizer.js'); -import { - initializeShellParsers, - isCommandAllowed, -} from '../utils/shell-utils.js'; +import { isCommandAllowed } from '../utils/shell-utils.js'; import { ShellTool } from './shell.js'; import { type Config } from '../config/config.js'; import { @@ -45,9 +41,6 @@ import { ToolConfirmationOutcome } from './tools.js'; import { OUTPUT_UPDATE_INTERVAL_MS } from './shell.js'; import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js'; -const originalComSpec = process.env['ComSpec']; -const itWindowsOnly = process.platform === 'win32' ? it : it.skip; - describe('ShellTool', () => { let shellTool: ShellTool; let mockConfig: Config; @@ -78,8 +71,6 @@ describe('ShellTool', () => { (vi.mocked(crypto.randomBytes) as Mock).mockReturnValue( Buffer.from('abcdef', 'hex'), ); - process.env['ComSpec'] = - 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'; // Capture the output callback to simulate streaming events from the service mockShellExecutionService.mockImplementation((_cmd, _cwd, callback) => { @@ -93,36 +84,23 @@ describe('ShellTool', () => { }); }); - afterEach(() => { - if (originalComSpec === undefined) { - delete process.env['ComSpec']; - } else { - process.env['ComSpec'] = originalComSpec; - } - }); - describe('isCommandAllowed', () => { it('should allow a command if no restrictions are provided', () => { (mockConfig.getCoreTools as Mock).mockReturnValue(undefined); (mockConfig.getExcludeTools as Mock).mockReturnValue(undefined); - expect(isCommandAllowed('goodCommand --safe', mockConfig).allowed).toBe( - true, - ); + expect(isCommandAllowed('ls -l', mockConfig).allowed).toBe(true); }); - it('should allow a command with command substitution using $()', () => { - const evaluation = isCommandAllowed( - 'echo $(goodCommand --safe)', - mockConfig, + it('should block a command with command substitution using $()', () => { + expect(isCommandAllowed('echo $(rm -rf /)', mockConfig).allowed).toBe( + false, ); - expect(evaluation.allowed).toBe(true); - expect(evaluation.reason).toBeUndefined(); }); }); describe('build', () => { it('should return an invocation for a valid command', () => { - const invocation = shellTool.build({ command: 'goodCommand --safe' }); + const invocation = shellTool.build({ command: 'ls -l' }); expect(invocation).toBeDefined(); }); @@ -229,7 +207,7 @@ describe('ShellTool', () => { ); }); - itWindowsOnly('should not wrap command on windows', async () => { + it('should not wrap command on windows', async () => { vi.mocked(os.platform).mockReturnValue('win32'); const invocation = shellTool.build({ command: 'dir' }); const promise = invocation.execute(mockAbortSignal); @@ -448,6 +426,3 @@ describe('ShellTool', () => { }); }); }); -beforeAll(async () => { - await initializeShellParsers(); -}); diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 923813eb4c..f9d017b626 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -34,7 +34,6 @@ import { formatMemoryUsage } from '../utils/formatters.js'; import type { AnsiOutput } from '../utils/terminalSerializer.js'; import { getCommandRoots, - initializeShellParsers, isCommandAllowed, SHELL_TOOL_NAMES, stripShellWrapper, @@ -389,17 +388,25 @@ function getShellToolDescription(): string { Process Group PGID: Process group started or \`(none)\``; if (os.platform() === 'win32') { - return `This tool executes a given shell command as \`powershell.exe -NoProfile -Command \`. Command can start background processes using PowerShell constructs such as \`Start-Process -NoNewWindow\` or \`Start-Job\`.${returnedInfo}`; + return `This tool executes a given shell command as \`cmd.exe /c \`. Command can start background processes using \`start /b\`.${returnedInfo}`; } else { return `This tool executes a given shell command as \`bash -c \`. Command can start background processes using \`&\`. Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`.${returnedInfo}`; } } function getCommandDescription(): string { + const cmd_substitution_warning = + '\n*** WARNING: Command substitution using $(), `` ` ``, <(), or >() is not allowed for security reasons.'; if (os.platform() === 'win32') { - return 'Exact command to execute as `powershell.exe -NoProfile -Command `'; + return ( + 'Exact command to execute as `cmd.exe /c `' + + cmd_substitution_warning + ); } else { - return 'Exact bash command to execute as `bash -c `'; + return ( + 'Exact bash command to execute as `bash -c `' + + cmd_substitution_warning + ); } } @@ -411,7 +418,6 @@ export class ShellTool extends BaseDeclarativeTool< private allowlist: Set = new Set(); constructor(private readonly config: Config) { - void initializeShellParsers(); super( ShellTool.Name, 'Shell', @@ -445,10 +451,6 @@ export class ShellTool extends BaseDeclarativeTool< protected override validateToolParamValues( params: ShellToolParams, ): string | null { - if (!params.command.trim()) { - return 'Command cannot be empty.'; - } - const commandCheck = isCommandAllowed(params.command, this.config); if (!commandCheck.allowed) { if (!commandCheck.reason) { @@ -459,6 +461,9 @@ export class ShellTool extends BaseDeclarativeTool< } return commandCheck.reason; } + if (!params.command.trim()) { + return 'Command cannot be empty.'; + } if (getCommandRoots(params.command).length === 0) { return 'Could not identify command root to obtain permission from user.'; } diff --git a/packages/core/src/utils/shell-utils.test.ts b/packages/core/src/utils/shell-utils.test.ts index 14fa1c0c37..9ac6b207ad 100644 --- a/packages/core/src/utils/shell-utils.test.ts +++ b/packages/core/src/utils/shell-utils.test.ts @@ -4,22 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - expect, - describe, - it, - beforeEach, - beforeAll, - vi, - afterEach, -} from 'vitest'; +import { expect, describe, it, beforeEach, vi, afterEach } from 'vitest'; import { checkCommandPermissions, escapeShellArg, getCommandRoots, getShellConfiguration, isCommandAllowed, - initializeShellParsers, stripShellWrapper, } from './shell-utils.js'; import type { Config } from '../config/config.js'; @@ -41,13 +32,6 @@ vi.mock('shell-quote', () => ({ })); let config: Config; -const isWindowsRuntime = process.platform === 'win32'; -const describeWindowsOnly = isWindowsRuntime ? describe : describe.skip; - -beforeAll(async () => { - mockPlatform.mockReturnValue('linux'); - await initializeShellParsers(); -}); beforeEach(() => { mockPlatform.mockReturnValue('linux'); @@ -67,41 +51,41 @@ afterEach(() => { describe('isCommandAllowed', () => { it('should allow a command if no restrictions are provided', () => { - const result = isCommandAllowed('goodCommand --safe', config); + const result = isCommandAllowed('ls -l', config); 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); + config.getCoreTools = () => ['ShellTool(ls)']; + const result = isCommandAllowed('ls -l', config); 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); + config.getCoreTools = () => ['ShellTool(ls -l)']; + const result = isCommandAllowed('rm -rf /', config); expect(result.allowed).toBe(false); expect(result.reason).toBe( - `Command(s) not in the allowed commands list. Disallowed commands: "badCommand --danger"`, + `Command(s) not in the allowed commands list. Disallowed commands: "rm -rf /"`, ); }); it('should block a command if it is in the blocked list', () => { - config.getExcludeTools = () => ['ShellTool(badCommand --danger)']; - const result = isCommandAllowed('badCommand --danger', config); + config.getExcludeTools = () => ['ShellTool(rm -rf /)']; + const result = isCommandAllowed('rm -rf /', config); expect(result.allowed).toBe(false); expect(result.reason).toBe( - `Command 'badCommand --danger' is blocked by configuration`, + `Command 'rm -rf /' is blocked by configuration`, ); }); it('should prioritize the blocklist over the allowlist', () => { - config.getCoreTools = () => ['ShellTool(badCommand --danger)']; - config.getExcludeTools = () => ['ShellTool(badCommand --danger)']; - const result = isCommandAllowed('badCommand --danger', config); + config.getCoreTools = () => ['ShellTool(rm -rf /)']; + config.getExcludeTools = () => ['ShellTool(rm -rf /)']; + const result = isCommandAllowed('rm -rf /', config); expect(result.allowed).toBe(false); expect(result.reason).toBe( - `Command 'badCommand --danger' is blocked by configuration`, + `Command 'rm -rf /' is blocked by configuration`, ); }); @@ -122,64 +106,58 @@ describe('isCommandAllowed', () => { 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); + config.getExcludeTools = () => ['ShellTool(rm -rf /)']; + const result = isCommandAllowed('rm -rf /', config); expect(result.allowed).toBe(false); expect(result.reason).toBe( - `Command 'badCommand --danger' is blocked by configuration`, + `Command 'rm -rf /' is blocked by configuration`, ); }); it('should allow a chained command if all parts are on the global allowlist', () => { config.getCoreTools = () => [ 'run_shell_command(echo)', - 'run_shell_command(goodCommand)', + 'run_shell_command(ls)', ]; - const result = isCommandAllowed( - 'echo "hello" && goodCommand --safe', - config, - ); + const result = isCommandAllowed('echo "hello" && ls -l', config); 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, - ); + config.getExcludeTools = () => ['run_shell_command(rm)']; + const result = isCommandAllowed('echo "hello" && rm -rf /', config); expect(result.allowed).toBe(false); expect(result.reason).toBe( - `Command 'badCommand --danger' is blocked by configuration`, + `Command 'rm -rf /' is blocked by configuration`, ); }); describe('command substitution', () => { - it('should allow command substitution using `$(...)`', () => { - const result = isCommandAllowed('echo $(goodCommand --safe)', config); - expect(result.allowed).toBe(true); - expect(result.reason).toBeUndefined(); + it('should block command substitution using `$(...)`', () => { + const result = isCommandAllowed('echo $(rm -rf /)', config); + expect(result.allowed).toBe(false); + expect(result.reason).toContain('Command substitution'); }); - it('should allow command substitution using `<(...)`', () => { + it('should block command substitution using `<(...)`', () => { const result = isCommandAllowed('diff <(ls) <(ls -a)', config); - expect(result.allowed).toBe(true); - expect(result.reason).toBeUndefined(); + expect(result.allowed).toBe(false); + expect(result.reason).toContain('Command substitution'); }); - it('should allow command substitution using `>(...)`', () => { + it('should block command substitution using `>(...)`', () => { const result = isCommandAllowed( 'echo "Log message" > >(tee log.txt)', config, ); - expect(result.allowed).toBe(true); - expect(result.reason).toBeUndefined(); + expect(result.allowed).toBe(false); + expect(result.reason).toContain('Command substitution'); }); - it('should allow command substitution using backticks', () => { - const result = isCommandAllowed('echo `goodCommand --safe`', config); - expect(result.allowed).toBe(true); - expect(result.reason).toBeUndefined(); + it('should block command substitution using backticks', () => { + const result = isCommandAllowed('echo `rm -rf /`', config); + expect(result.allowed).toBe(false); + expect(result.reason).toContain('Command substitution'); }); it('should allow substitution-like patterns inside single quotes', () => { @@ -187,54 +165,33 @@ describe('isCommandAllowed', () => { const result = isCommandAllowed("echo '$(pwd)'", config); expect(result.allowed).toBe(true); }); - - it('should block a command when parsing fails', () => { - const result = isCommandAllowed('ls &&', config); - expect(result.allowed).toBe(false); - expect(result.reason).toBe( - 'Command rejected because it could not be parsed safely', - ); - }); }); }); 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 = checkCommandPermissions('ls -l', config); expect(result).toEqual({ allAllowed: true, disallowedCommands: [], }); }); - it('should block commands that cannot be parsed safely', () => { - const result = checkCommandPermissions('ls &&', config); - expect(result).toEqual({ - allAllowed: false, - disallowedCommands: ['ls &&'], - blockReason: 'Command rejected because it could not be parsed safely', - isHardDenial: true, - }); - }); - it('should return a detailed failure object for a blocked command', () => { - config.getExcludeTools = () => ['ShellTool(badCommand)']; - const result = checkCommandPermissions('badCommand --danger', config); + config.getExcludeTools = () => ['ShellTool(rm)']; + const result = checkCommandPermissions('rm -rf /', config); expect(result).toEqual({ allAllowed: false, - disallowedCommands: ['badCommand --danger'], - blockReason: `Command 'badCommand --danger' is blocked by configuration`, + disallowedCommands: ['rm -rf /'], + blockReason: `Command 'rm -rf /' is blocked by configuration`, isHardDenial: true, }); }); 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, - ); + config.getCoreTools = () => ['ShellTool(ls)']; + const result = checkCommandPermissions('git status && ls', config); expect(result).toEqual({ allAllowed: false, disallowedCommands: ['git status'], @@ -247,24 +204,24 @@ describe('checkCommandPermissions', () => { describe('in "Default Deny" mode (with sessionAllowlist)', () => { it('should allow a command on the sessionAllowlist', () => { const result = checkCommandPermissions( - 'goodCommand --safe', + 'ls -l', config, - new Set(['goodCommand --safe']), + new Set(['ls -l']), ); expect(result.allAllowed).toBe(true); }); it('should block a command not on the sessionAllowlist or global allowlist', () => { const result = checkCommandPermissions( - 'badCommand --danger', + 'rm -rf /', config, - new Set(['goodCommand --safe']), + new Set(['ls -l']), ); expect(result.allAllowed).toBe(false); expect(result.blockReason).toContain( 'not on the global or session allowlist', ); - expect(result.disallowedCommands).toEqual(['badCommand --danger']); + expect(result.disallowedCommands).toEqual(['rm -rf /']); }); it('should allow a command on the global allowlist even if not on the session allowlist', () => { @@ -272,7 +229,7 @@ describe('checkCommandPermissions', () => { const result = checkCommandPermissions( 'git status', config, - new Set(['goodCommand --safe']), + new Set(['ls -l']), ); expect(result.allAllowed).toBe(true); }); @@ -288,11 +245,11 @@ describe('checkCommandPermissions', () => { }); it('should block a command on the sessionAllowlist if it is also globally blocked', () => { - config.getExcludeTools = () => ['run_shell_command(badCommand)']; + config.getExcludeTools = () => ['run_shell_command(rm)']; const result = checkCommandPermissions( - 'badCommand --danger', + 'rm -rf /', config, - new Set(['badCommand --danger']), + new Set(['rm -rf /']), ); expect(result.allAllowed).toBe(false); expect(result.blockReason).toContain('is blocked by configuration'); @@ -301,12 +258,12 @@ describe('checkCommandPermissions', () => { it('should block a chained command if one part is not on any allowlist', () => { config.getCoreTools = () => ['run_shell_command(echo)']; const result = checkCommandPermissions( - 'echo "hello" && badCommand --danger', + 'echo "hello" && rm -rf /', config, new Set(['echo']), ); expect(result.allAllowed).toBe(false); - expect(result.disallowedCommands).toEqual(['badCommand --danger']); + expect(result.disallowedCommands).toEqual(['rm -rf /']); }); }); }); @@ -333,54 +290,6 @@ describe('getCommandRoots', () => { const result = getCommandRoots('echo "hello" && git commit -m "feat"'); expect(result).toEqual(['echo', 'git']); }); - - it('should include nested command substitutions', () => { - const result = getCommandRoots('echo $(badCommand --danger)'); - expect(result).toEqual(['echo', 'badCommand']); - }); - - it('should include process substitutions', () => { - const result = getCommandRoots('diff <(ls) <(ls -a)'); - expect(result).toEqual(['diff', 'ls', 'ls']); - }); - - it('should include backtick substitutions', () => { - const result = getCommandRoots('echo `badCommand --danger`'); - expect(result).toEqual(['echo', 'badCommand']); - }); -}); - -describeWindowsOnly('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'); - expect(roots.length).toBeGreaterThan(0); - expect(roots).toContain('Get-ChildItem'); - }); - - it('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', () => { @@ -400,21 +309,6 @@ describe('stripShellWrapper', () => { expect(stripShellWrapper('cmd.exe /c "dir"')).toEqual('dir'); }); - it('should strip powershell.exe -Command with optional -NoProfile', () => { - expect( - stripShellWrapper('powershell.exe -NoProfile -Command "Get-ChildItem"'), - ).toEqual('Get-ChildItem'); - expect( - stripShellWrapper('powershell.exe -Command "Get-ChildItem"'), - ).toEqual('Get-ChildItem'); - }); - - it('should strip pwsh -Command wrapper', () => { - expect( - stripShellWrapper('pwsh -NoProfile -Command "Get-ChildItem"'), - ).toEqual('Get-ChildItem'); - }); - it('should not strip anything if no wrapper is present', () => { expect(stripShellWrapper('ls -l')).toEqual('ls -l'); }); @@ -506,21 +400,21 @@ describe('getShellConfiguration', () => { mockPlatform.mockReturnValue('win32'); }); - it('should return PowerShell configuration by default', () => { + it('should return cmd.exe configuration by default', () => { delete process.env['ComSpec']; const config = getShellConfiguration(); - expect(config.executable).toBe('powershell.exe'); - expect(config.argsPrefix).toEqual(['-NoProfile', '-Command']); - expect(config.shell).toBe('powershell'); + expect(config.executable).toBe('cmd.exe'); + expect(config.argsPrefix).toEqual(['/d', '/s', '/c']); + expect(config.shell).toBe('cmd'); }); - it('should ignore ComSpec when pointing to cmd.exe', () => { + it('should respect ComSpec for cmd.exe', () => { const cmdPath = 'C:\\WINDOWS\\system32\\cmd.exe'; process.env['ComSpec'] = cmdPath; const config = getShellConfiguration(); - expect(config.executable).toBe('powershell.exe'); - expect(config.argsPrefix).toEqual(['-NoProfile', '-Command']); - expect(config.shell).toBe('powershell'); + expect(config.executable).toBe(cmdPath); + expect(config.argsPrefix).toEqual(['/d', '/s', '/c']); + expect(config.shell).toBe('cmd'); }); it('should return PowerShell configuration if ComSpec points to powershell.exe', () => { diff --git a/packages/core/src/utils/shell-utils.ts b/packages/core/src/utils/shell-utils.ts index 3d608ffa61..e038e7cf2d 100644 --- a/packages/core/src/utils/shell-utils.ts +++ b/packages/core/src/utils/shell-utils.ts @@ -4,19 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { createRequire as createModuleRequire } from 'node:module'; import type { AnyToolInvocation } from '../index.js'; import type { Config } from '../config/config.js'; import os from 'node:os'; import { quote } from 'shell-quote'; import { doesToolInvocationMatch } from './tool-utils.js'; -import { - spawn, - spawnSync, - type SpawnOptionsWithoutStdio, -} from 'node:child_process'; -import type { Node } from 'web-tree-sitter'; -import { Language, Parser } from 'web-tree-sitter'; +import { spawn, type SpawnOptionsWithoutStdio } from 'node:child_process'; export const SHELL_TOOL_NAMES = ['run_shell_command', 'ShellTool']; @@ -29,7 +22,7 @@ export type ShellType = 'cmd' | 'powershell' | 'bash'; * Defines the configuration required to execute a command string within a specific shell. */ export interface ShellConfiguration { - /** The path or name of the shell executable (e.g., 'bash', 'powershell.exe'). */ + /** The path or name of the shell executable (e.g., 'bash', 'cmd.exe'). */ executable: string; /** * The arguments required by the shell to execute a subsequent string argument. @@ -39,305 +32,6 @@ export interface ShellConfiguration { shell: ShellType; } -const requireModule = createModuleRequire(import.meta.url); - -let bashLanguage: Language | null = null; -let treeSitterInitialization: Promise | null = null; - -async function loadBashLanguage(): Promise { - try { - const treeSitterWasmPath = requireModule.resolve( - 'web-tree-sitter/tree-sitter.wasm', - ); - const bashWasmPath = requireModule.resolve( - 'tree-sitter-bash/tree-sitter-bash.wasm', - ); - - await Parser.init({ - locateFile() { - return treeSitterWasmPath; - }, - }); - bashLanguage = await Language.load(bashWasmPath); - } catch { - bashLanguage = null; - } -} - -export async function initializeShellParsers(): Promise { - if (!treeSitterInitialization) { - treeSitterInitialization = loadBashLanguage().catch(() => { - // Swallow errors; bashLanguage will remain null. - }); - } - - try { - await treeSitterInitialization; - } catch { - // Initialization errors are non-fatal; parsing will gracefully fall back. - } -} - -interface ParsedCommandDetail { - name: string; - text: string; -} - -interface CommandParseResult { - details: ParsedCommandDetail[]; - hasError: boolean; -} - -const POWERSHELL_COMMAND_ENV = '__GCLI_POWERSHELL_COMMAND__'; - -// Encode the parser script as UTF-16LE base64 so we can pass it via PowerShell's -EncodedCommand flag; -// this avoids brittle quoting/escaping when spawning PowerShell and ensures the script is received byte-for-byte. -const POWERSHELL_PARSER_SCRIPT = Buffer.from( - ` -$ErrorActionPreference = 'Stop' -$commandText = $env:${POWERSHELL_COMMAND_ENV} -if ([string]::IsNullOrEmpty($commandText)) { - Write-Output '{"success":false}' - exit 0 -} -$tokens = $null -$errors = $null -$ast = [System.Management.Automation.Language.Parser]::ParseInput($commandText, [ref]$tokens, [ref]$errors) -if ($errors -and $errors.Count -gt 0) { - Write-Output '{"success":false}' - exit 0 -} -$commandAsts = $ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.CommandAst] }, $true) -$commandObjects = @() -foreach ($commandAst in $commandAsts) { - $name = $commandAst.GetCommandName() - if ([string]::IsNullOrWhiteSpace($name)) { - continue - } - $commandObjects += [PSCustomObject]@{ - name = $name - text = $commandAst.Extent.Text.Trim() - } -} -[PSCustomObject]@{ - success = $true - commands = $commandObjects -} | ConvertTo-Json -Compress -`, - 'utf16le', -).toString('base64'); - -function createParser(): Parser | null { - if (!bashLanguage) { - return null; - } - - try { - const parser = new Parser(); - parser.setLanguage(bashLanguage); - return parser; - } catch { - return null; - } -} - -function parseCommandTree(command: string) { - const parser = createParser(); - if (!parser || !command.trim()) { - return null; - } - - try { - return parser.parse(command); - } catch { - return null; - } -} - -function normalizeCommandName(raw: string): string { - if (raw.length >= 2) { - const first = raw[0]; - const last = raw[raw.length - 1]; - if ((first === '"' && last === '"') || (first === "'" && last === "'")) { - return raw.slice(1, -1); - } - } - const trimmed = raw.trim(); - if (!trimmed) { - return trimmed; - } - return trimmed.split(/[\\/]/).pop() ?? trimmed; -} - -function extractNameFromNode(node: Node): string | null { - switch (node.type) { - case 'command': { - const nameNode = node.childForFieldName('name'); - if (!nameNode) { - return null; - } - return normalizeCommandName(nameNode.text); - } - case 'declaration_command': - case 'unset_command': - case 'test_command': { - const firstChild = node.child(0); - if (!firstChild) { - return null; - } - return normalizeCommandName(firstChild.text); - } - default: - return null; - } -} - -function collectCommandDetails( - root: Node, - source: string, -): ParsedCommandDetail[] { - const stack: Node[] = [root]; - const details: ParsedCommandDetail[] = []; - - while (stack.length > 0) { - const current = stack.pop(); - if (!current) { - continue; - } - - const commandName = extractNameFromNode(current); - if (commandName) { - details.push({ - name: commandName, - text: 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; -} - -function parseBashCommandDetails(command: string): CommandParseResult | null { - if (!bashLanguage) { - void initializeShellParsers(); - } - - const tree = parseCommandTree(command); - if (!tree) { - return null; - } - - const details = collectCommandDetails(tree.rootNode, command); - return { - details, - hasError: tree.rootNode.hasError || details.length === 0, - }; -} - -function parsePowerShellCommandDetails( - command: string, - executable: string, -): CommandParseResult | null { - const trimmed = command.trim(); - if (!trimmed) { - return { - details: [], - hasError: true, - }; - } - - try { - const result = spawnSync( - executable, - [ - '-NoLogo', - '-NoProfile', - '-NonInteractive', - '-EncodedCommand', - POWERSHELL_PARSER_SCRIPT, - ], - { - env: { - ...process.env, - [POWERSHELL_COMMAND_ENV]: command, - }, - encoding: 'utf-8', - maxBuffer: 1024 * 1024, - }, - ); - - if (result.error || result.status !== 0) { - return null; - } - - const output = (result.stdout ?? '').toString().trim(); - if (!output) { - return { details: [], hasError: true }; - } - - let parsed: { - success?: boolean; - commands?: Array<{ name?: string; text?: string }>; - } | null = null; - try { - parsed = JSON.parse(output); - } catch { - return { details: [], hasError: true }; - } - - if (!parsed?.success) { - return { details: [], hasError: true }; - } - - const details = (parsed.commands ?? []) - .map((commandDetail) => { - if (!commandDetail || typeof commandDetail.name !== 'string') { - return null; - } - - const name = normalizeCommandName(commandDetail.name); - const text = - typeof commandDetail.text === 'string' - ? commandDetail.text.trim() - : command; - - return { - name, - text, - }; - }) - .filter((detail): detail is ParsedCommandDetail => detail !== null); - - return { - details, - hasError: details.length === 0, - }; - } catch { - return null; - } -} - -function parseCommandDetails(command: string): CommandParseResult | null { - const configuration = getShellConfiguration(); - - if (configuration.shell === 'powershell') { - return parsePowerShellCommandDetails(command, configuration.executable); - } - - if (configuration.shell === 'bash') { - return parseBashCommandDetails(command); - } - - return null; -} - /** * Determines the appropriate shell configuration for the current platform. * @@ -348,26 +42,32 @@ function parseCommandDetails(command: string): CommandParseResult | null { */ export function getShellConfiguration(): ShellConfiguration { if (isWindows()) { - const comSpec = process.env['ComSpec']; - if (comSpec) { - const executable = comSpec.toLowerCase(); - if ( - executable.endsWith('powershell.exe') || - executable.endsWith('pwsh.exe') - ) { - return { - executable: comSpec, - argsPrefix: ['-NoProfile', '-Command'], - shell: 'powershell', - }; - } + const comSpec = process.env['ComSpec'] || 'cmd.exe'; + const executable = comSpec.toLowerCase(); + + if ( + executable.endsWith('powershell.exe') || + executable.endsWith('pwsh.exe') + ) { + // For PowerShell, the arguments are different. + // -NoProfile: Speeds up startup. + // -Command: Executes the following command. + return { + executable: comSpec, + argsPrefix: ['-NoProfile', '-Command'], + shell: 'powershell', + }; } - // Default to PowerShell for all other Windows configurations. + // Default to cmd.exe for anything else on Windows. + // Flags for CMD: + // /d: Skip execution of AutoRun commands. + // /s: Modifies the treatment of the command string (important for quoting). + // /c: Carries out the command specified by the string and then terminates. return { - executable: 'powershell.exe', - argsPrefix: ['-NoProfile', '-Command'], - shell: 'powershell', + executable: comSpec, + argsPrefix: ['/d', '/s', '/c'], + shell: 'cmd', }; } @@ -414,12 +114,53 @@ export function escapeShellArg(arg: string, shell: ShellType): string { * @returns An array of individual command strings */ export function splitCommands(command: string): string[] { - const parsed = parseCommandDetails(command); - if (!parsed || parsed.hasError) { - return []; + const commands: string[] = []; + let currentCommand = ''; + let inSingleQuotes = false; + let inDoubleQuotes = false; + let i = 0; + + while (i < command.length) { + const char = command[i]; + const nextChar = command[i + 1]; + + if (char === '\\' && i < command.length - 1) { + currentCommand += char + command[i + 1]; + i += 2; + continue; + } + + if (char === "'" && !inDoubleQuotes) { + inSingleQuotes = !inSingleQuotes; + } else if (char === '"' && !inSingleQuotes) { + inDoubleQuotes = !inDoubleQuotes; + } + + if (!inSingleQuotes && !inDoubleQuotes) { + if ( + (char === '&' && nextChar === '&') || + (char === '|' && nextChar === '|') + ) { + commands.push(currentCommand.trim()); + currentCommand = ''; + i++; // Skip the next character + } else if (char === ';' || char === '&' || char === '|') { + commands.push(currentCommand.trim()); + currentCommand = ''; + } else { + currentCommand += char; + } + } else { + currentCommand += char; + } + i++; } - return parsed.details.map((detail) => detail.text).filter(Boolean); + if (currentCommand.trim()) { + commands.push(currentCommand.trim()); + } + + return commands.filter(Boolean); // Filter out any empty strings } /** @@ -431,30 +172,40 @@ export function splitCommands(command: string): string[] { * @example getCommandRoot("git status && npm test") returns "git" */ export function getCommandRoot(command: string): string | undefined { - const parsed = parseCommandDetails(command); - if (!parsed || parsed.hasError || parsed.details.length === 0) { + const trimmedCommand = command.trim(); + if (!trimmedCommand) { return undefined; } - return parsed.details[0]?.name; + // This regex is designed to find the first "word" of a command, + // while respecting quotes. It looks for a sequence of non-whitespace + // characters that are not inside quotes. + const match = trimmedCommand.match(/^"([^"]+)"|^'([^']+)'|^(\S+)/); + if (match) { + // The first element in the match array is the full match. + // The subsequent elements are the capture groups. + // We prefer a captured group because it will be unquoted. + const commandRoot = match[1] || match[2] || match[3]; + if (commandRoot) { + // If the command is a path, return the last component. + return commandRoot.split(/[\\/]/).pop(); + } + } + + return undefined; } export function getCommandRoots(command: string): string[] { if (!command) { return []; } - - const parsed = parseCommandDetails(command); - if (!parsed || parsed.hasError) { - return []; - } - - return parsed.details.map((detail) => detail.name).filter(Boolean); + return splitCommands(command) + .map((c) => getCommandRoot(c)) + .filter((c): c is string => !!c); } export function stripShellWrapper(command: string): string { - const pattern = - /^\s*(?:(?:sh|bash|zsh)\s+-c|cmd\.exe\s+\/c|powershell(?:\.exe)?\s+(?:-NoProfile\s+)?-Command|pwsh(?:\.exe)?\s+(?:-NoProfile\s+)?-Command)\s+/i; + const pattern = /^\s*(?:sh|bash|zsh|cmd.exe)\s+(?:\/c|-c)\s+/; const match = command.match(pattern); if (match) { let newCommand = command.substring(match[0].length).trim(); @@ -477,6 +228,62 @@ export function stripShellWrapper(command: string): string { * @param command The shell command string to check * @returns true if command substitution would be executed by bash */ +export function detectCommandSubstitution(command: string): boolean { + let inSingleQuotes = false; + let inDoubleQuotes = false; + let inBackticks = false; + let i = 0; + + while (i < command.length) { + const char = command[i]; + const nextChar = command[i + 1]; + + // Handle escaping - only works outside single quotes + if (char === '\\' && !inSingleQuotes) { + i += 2; // Skip the escaped character + continue; + } + + // Handle quote state changes + if (char === "'" && !inDoubleQuotes && !inBackticks) { + inSingleQuotes = !inSingleQuotes; + } else if (char === '"' && !inSingleQuotes && !inBackticks) { + inDoubleQuotes = !inDoubleQuotes; + } else if (char === '`' && !inSingleQuotes) { + // Backticks work outside single quotes (including in double quotes) + inBackticks = !inBackticks; + } + + // Check for command substitution patterns that would be executed + if (!inSingleQuotes) { + // $(...) command substitution - works in double quotes and unquoted + if (char === '$' && nextChar === '(') { + return true; + } + + // <(...) process substitution - works unquoted only (not in double quotes) + if (char === '<' && nextChar === '(' && !inDoubleQuotes && !inBackticks) { + return true; + } + + // >(...) process substitution - works unquoted only (not in double quotes) + if (char === '>' && nextChar === '(' && !inDoubleQuotes && !inBackticks) { + return true; + } + + // Backtick command substitution - check for opening backtick + // (We track the state above, so this catches the start of backtick substitution) + if (char === '`' && !inBackticks) { + return true; + } + } + + i++; + } + + return false; +} + /** * Checks a shell command against security policies and allowlists. * @@ -511,20 +318,19 @@ export function checkCommandPermissions( blockReason?: string; isHardDenial?: boolean; } { - const parseResult = parseCommandDetails(command); - if (!parseResult || parseResult.hasError) { + // Disallow command substitution for security. + if (detectCommandSubstitution(command)) { return { allAllowed: false, disallowedCommands: [command], - blockReason: 'Command rejected because it could not be parsed safely', + blockReason: + 'Command substitution using $(), `` ` ``, <(), or >() is not allowed for security reasons', isHardDenial: true, }; } const normalize = (cmd: string): string => cmd.trim().replace(/\s+/g, ' '); - const commandsToValidate = parseResult.details - .map((detail) => normalize(detail.text)) - .filter(Boolean); + const commandsToValidate = splitCommands(command).map(normalize); const invocation: AnyToolInvocation & { params: { command: string } } = { params: { command: '' }, } as AnyToolInvocation & { params: { command: string } };