Shell approval rework (#11073)

This commit is contained in:
cornmander
2025-10-14 12:51:32 -04:00
committed by GitHub
parent 061a89fc2b
commit 92dbdbb93b
12 changed files with 662 additions and 280 deletions
+3 -2
View File
@@ -292,8 +292,9 @@ Gemini CLI.
- **`!<shell_command>`** - **`!<shell_command>`**
- **Description:** Execute the given `<shell_command>` using `bash` on - **Description:** Execute the given `<shell_command>` using `bash` on
Linux/macOS or `cmd.exe` on Windows. Any output or errors from the command Linux/macOS or `powershell.exe -NoProfile -Command` on Windows (unless you
are displayed in the terminal. override `ComSpec`). Any output or errors from the command are displayed in
the terminal.
- **Examples:** - **Examples:**
- `!ls -la` (executes `ls -la` and returns to Gemini CLI) - `!ls -la` (executes `ls -la` and returns to Gemini CLI)
- `!git status` (executes `git status` and returns to Gemini CLI) - `!git status` (executes `git status` and returns to Gemini CLI)
+3 -2
View File
@@ -10,8 +10,9 @@ command, including interactive commands that require user input (e.g., `vim`,
`git rebase -i`) if the `tools.shell.enableInteractiveShell` setting is set to `git rebase -i`) if the `tools.shell.enableInteractiveShell` setting is set to
`true`. `true`.
On Windows, commands are executed with `cmd.exe /c`. On other platforms, they On Windows, commands are executed with `powershell.exe -NoProfile -Command`
are executed with `bash -c`. (unless you explicitly point `ComSpec` at another shell). On other platforms,
they are executed with `bash -c`.
### Arguments ### Arguments
+55
View File
@@ -12432,6 +12432,17 @@
"webidl-conversions": "^3.0.0" "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": { "node_modules/node-pty": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz", "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz",
@@ -16145,6 +16156,34 @@
"tslib": "2" "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": { "node_modules/triple-beam": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz",
@@ -16936,6 +16975,20 @@
"node": ">=18" "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": { "node_modules/webidl-conversions": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
@@ -18031,7 +18084,9 @@
"shell-quote": "^1.8.3", "shell-quote": "^1.8.3",
"simple-git": "^3.28.0", "simple-git": "^3.28.0",
"strip-ansi": "^7.1.0", "strip-ansi": "^7.1.0",
"tree-sitter-bash": "^0.25.0",
"undici": "^7.10.0", "undici": "^7.10.0",
"web-tree-sitter": "^0.25.10",
"ws": "^8.18.0" "ws": "^8.18.0"
}, },
"devDependencies": { "devDependencies": {
@@ -9,8 +9,7 @@ import { ConfirmationRequiredError, ShellProcessor } from './shellProcessor.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import type { CommandContext } from '../../ui/commands/types.js'; import type { CommandContext } from '../../ui/commands/types.js';
import type { Config } from '@google/gemini-cli-core'; import type { Config } from '@google/gemini-cli-core';
import { ApprovalMode } from '@google/gemini-cli-core'; import { ApprovalMode, getShellConfiguration } from '@google/gemini-cli-core';
import os from 'node:os';
import { quote } from 'shell-quote'; import { quote } from 'shell-quote';
import { createPartFromText } from '@google/genai'; import { createPartFromText } from '@google/genai';
import type { PromptPipelineContent } from './types.js'; import type { PromptPipelineContent } from './types.js';
@@ -18,17 +17,15 @@ import type { PromptPipelineContent } from './types.js';
// Helper function to determine the expected escaped string based on the current OS, // Helper function to determine the expected escaped string based on the current OS,
// mirroring the logic in the actual `escapeShellArg` implementation. // mirroring the logic in the actual `escapeShellArg` implementation.
function getExpectedEscapedArgForPlatform(arg: string): string { function getExpectedEscapedArgForPlatform(arg: string): string {
if (os.platform() === 'win32') { const { shell } = getShellConfiguration();
const comSpec = (process.env['ComSpec'] || 'cmd.exe').toLowerCase();
const isPowerShell =
comSpec.endsWith('powershell.exe') || comSpec.endsWith('pwsh.exe');
if (isPowerShell) { switch (shell) {
case 'powershell':
return `'${arg.replace(/'/g, "''")}'`; return `'${arg.replace(/'/g, "''")}'`;
} else { case 'cmd':
return `"${arg.replace(/"/g, '""')}"`; return `"${arg.replace(/"/g, '""')}"`;
} case 'bash':
} else { default:
return quote([arg]); return quote([arg]);
} }
} }
+4 -2
View File
@@ -20,10 +20,10 @@
"dist" "dist"
], ],
"dependencies": { "dependencies": {
"@google/genai": "1.16.0", "@google-cloud/logging": "^11.2.1",
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0", "@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0",
"@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0", "@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0",
"@google-cloud/logging": "^11.2.1", "@google/genai": "1.16.0",
"@joshua.litt/get-ripgrep": "^0.0.2", "@joshua.litt/get-ripgrep": "^0.0.2",
"@modelcontextprotocol/sdk": "^1.11.0", "@modelcontextprotocol/sdk": "^1.11.0",
"@opentelemetry/api": "^1.9.0", "@opentelemetry/api": "^1.9.0",
@@ -61,7 +61,9 @@
"shell-quote": "^1.8.3", "shell-quote": "^1.8.3",
"simple-git": "^3.28.0", "simple-git": "^3.28.0",
"strip-ansi": "^7.1.0", "strip-ansi": "^7.1.0",
"tree-sitter-bash": "^0.25.0",
"undici": "^7.10.0", "undici": "^7.10.0",
"web-tree-sitter": "^0.25.10",
"ws": "^8.18.0" "ws": "^8.18.0"
}, },
"optionalDependencies": { "optionalDependencies": {
@@ -24,9 +24,14 @@ const mockSerializeTerminalToObject = vi.hoisted(() => vi.fn());
vi.mock('@lydell/node-pty', () => ({ vi.mock('@lydell/node-pty', () => ({
spawn: mockPtySpawn, spawn: mockPtySpawn,
})); }));
vi.mock('child_process', () => ({ vi.mock('node:child_process', async (importOriginal) => {
const actual =
(await importOriginal()) as typeof import('node:child_process');
return {
...actual,
spawn: mockCpSpawn, spawn: mockCpSpawn,
})); };
});
vi.mock('../utils/textUtils.js', () => ({ vi.mock('../utils/textUtils.js', () => ({
isBinary: mockIsBinary, isBinary: mockIsBinary,
})); }));
@@ -465,15 +470,15 @@ describe('ShellExecutionService', () => {
}); });
describe('Platform-Specific Behavior', () => { describe('Platform-Specific Behavior', () => {
it('should use cmd.exe on Windows', async () => { it('should use powershell.exe on Windows', async () => {
mockPlatform.mockReturnValue('win32'); mockPlatform.mockReturnValue('win32');
await simulateExecution('dir "foo bar"', (pty) => await simulateExecution('dir "foo bar"', (pty) =>
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }), pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }),
); );
expect(mockPtySpawn).toHaveBeenCalledWith( expect(mockPtySpawn).toHaveBeenCalledWith(
'cmd.exe', 'powershell.exe',
'/c dir "foo bar"', ['-NoProfile', '-Command', 'dir "foo bar"'],
expect.any(Object), expect.any(Object),
); );
}); });
@@ -637,9 +642,9 @@ describe('ShellExecutionService child_process fallback', () => {
}); });
expect(mockCpSpawn).toHaveBeenCalledWith( expect(mockCpSpawn).toHaveBeenCalledWith(
'ls -l', 'bash',
[], ['-c', 'ls -l'],
expect.objectContaining({ shell: 'bash' }), expect.objectContaining({ shell: false, detached: true }),
); );
expect(result.exitCode).toBe(0); expect(result.exitCode).toBe(0);
expect(result.signal).toBeNull(); expect(result.signal).toBeNull();
@@ -905,18 +910,19 @@ describe('ShellExecutionService child_process fallback', () => {
}); });
describe('Platform-Specific Behavior', () => { describe('Platform-Specific Behavior', () => {
it('should use cmd.exe on Windows', async () => { it('should use powershell.exe on Windows', async () => {
mockPlatform.mockReturnValue('win32'); mockPlatform.mockReturnValue('win32');
await simulateExecution('dir "foo bar"', (cp) => await simulateExecution('dir "foo bar"', (cp) =>
cp.emit('exit', 0, null), cp.emit('exit', 0, null),
); );
expect(mockCpSpawn).toHaveBeenCalledWith( expect(mockCpSpawn).toHaveBeenCalledWith(
'dir "foo bar"', 'powershell.exe',
[], ['-NoProfile', '-Command', 'dir "foo bar"'],
expect.objectContaining({ expect.objectContaining({
shell: true, shell: false,
detached: false, detached: false,
windowsVerbatimArguments: false,
}), }),
); );
}); });
@@ -926,10 +932,10 @@ describe('ShellExecutionService child_process fallback', () => {
await simulateExecution('ls "foo bar"', (cp) => cp.emit('exit', 0, null)); await simulateExecution('ls "foo bar"', (cp) => cp.emit('exit', 0, null));
expect(mockCpSpawn).toHaveBeenCalledWith( expect(mockCpSpawn).toHaveBeenCalledWith(
'ls "foo bar"', 'bash',
[], ['-c', 'ls "foo bar"'],
expect.objectContaining({ expect.objectContaining({
shell: 'bash', shell: false,
detached: true, detached: true,
}), }),
); );
@@ -12,6 +12,7 @@ import { TextDecoder } from 'node:util';
import os from 'node:os'; import os from 'node:os';
import type { IPty } from '@lydell/node-pty'; import type { IPty } from '@lydell/node-pty';
import { getCachedEncodingForBuffer } from '../utils/systemEncoding.js'; import { getCachedEncodingForBuffer } from '../utils/systemEncoding.js';
import { getShellConfiguration } from '../utils/shell-utils.js';
import { isBinary } from '../utils/textUtils.js'; import { isBinary } from '../utils/textUtils.js';
import pkg from '@xterm/headless'; import pkg from '@xterm/headless';
import { import {
@@ -189,12 +190,14 @@ export class ShellExecutionService {
): ShellExecutionHandle { ): ShellExecutionHandle {
try { try {
const isWindows = os.platform() === 'win32'; const isWindows = os.platform() === 'win32';
const { executable, argsPrefix } = getShellConfiguration();
const spawnArgs = [...argsPrefix, commandToExecute];
const child = cpSpawn(commandToExecute, [], { const child = cpSpawn(executable, spawnArgs, {
cwd, cwd,
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
windowsVerbatimArguments: true, windowsVerbatimArguments: isWindows ? false : undefined,
shell: isWindows ? true : 'bash', shell: false,
detached: !isWindows, detached: !isWindows,
env: { env: {
...process.env, ...process.env,
@@ -400,13 +403,10 @@ export class ShellExecutionService {
try { try {
const cols = shellExecutionConfig.terminalWidth ?? 80; const cols = shellExecutionConfig.terminalWidth ?? 80;
const rows = shellExecutionConfig.terminalHeight ?? 30; const rows = shellExecutionConfig.terminalHeight ?? 30;
const isWindows = os.platform() === 'win32'; const { executable, argsPrefix } = getShellConfiguration();
const shell = isWindows ? 'cmd.exe' : 'bash'; const args = [...argsPrefix, commandToExecute];
const args = isWindows
? `/c ${commandToExecute}`
: ['-c', commandToExecute];
const ptyProcess = ptyInfo.module.spawn(shell, args, { const ptyProcess = ptyInfo.module.spawn(executable, args, {
cwd, cwd,
name: 'xterm', name: 'xterm',
cols, cols,
@@ -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`] = ` exports[`ShellTool > getDescription > should return the windows description when on windows 1`] = `
"This tool executes a given shell command as \`cmd.exe /c <command>\`. Command can start background processes using \`start /b\`. "This tool executes a given shell command as \`powershell.exe -NoProfile -Command <command>\`. Command can start background processes using PowerShell constructs such as \`Start-Process -NoNewWindow\` or \`Start-Job\`.
The following information is returned: The following information is returned:
+32 -7
View File
@@ -9,6 +9,7 @@ import {
describe, describe,
it, it,
expect, expect,
beforeAll,
beforeEach, beforeEach,
afterEach, afterEach,
type Mock, type Mock,
@@ -23,7 +24,10 @@ vi.mock('os');
vi.mock('crypto'); vi.mock('crypto');
vi.mock('../utils/summarizer.js'); vi.mock('../utils/summarizer.js');
import { isCommandAllowed } from '../utils/shell-utils.js'; import {
initializeShellParsers,
isCommandAllowed,
} from '../utils/shell-utils.js';
import { ShellTool } from './shell.js'; import { ShellTool } from './shell.js';
import { type Config } from '../config/config.js'; import { type Config } from '../config/config.js';
import { import {
@@ -41,6 +45,9 @@ import { ToolConfirmationOutcome } from './tools.js';
import { OUTPUT_UPDATE_INTERVAL_MS } from './shell.js'; import { OUTPUT_UPDATE_INTERVAL_MS } from './shell.js';
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js'; import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
const originalComSpec = process.env['ComSpec'];
const itWindowsOnly = process.platform === 'win32' ? it : it.skip;
describe('ShellTool', () => { describe('ShellTool', () => {
let shellTool: ShellTool; let shellTool: ShellTool;
let mockConfig: Config; let mockConfig: Config;
@@ -71,6 +78,8 @@ describe('ShellTool', () => {
(vi.mocked(crypto.randomBytes) as Mock).mockReturnValue( (vi.mocked(crypto.randomBytes) as Mock).mockReturnValue(
Buffer.from('abcdef', 'hex'), 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 // Capture the output callback to simulate streaming events from the service
mockShellExecutionService.mockImplementation((_cmd, _cwd, callback) => { mockShellExecutionService.mockImplementation((_cmd, _cwd, callback) => {
@@ -84,23 +93,36 @@ describe('ShellTool', () => {
}); });
}); });
afterEach(() => {
if (originalComSpec === undefined) {
delete process.env['ComSpec'];
} else {
process.env['ComSpec'] = originalComSpec;
}
});
describe('isCommandAllowed', () => { describe('isCommandAllowed', () => {
it('should allow a command if no restrictions are provided', () => { it('should allow a command if no restrictions are provided', () => {
(mockConfig.getCoreTools as Mock).mockReturnValue(undefined); (mockConfig.getCoreTools as Mock).mockReturnValue(undefined);
(mockConfig.getExcludeTools as Mock).mockReturnValue(undefined); (mockConfig.getExcludeTools as Mock).mockReturnValue(undefined);
expect(isCommandAllowed('ls -l', mockConfig).allowed).toBe(true); expect(isCommandAllowed('goodCommand --safe', mockConfig).allowed).toBe(
true,
);
}); });
it('should block a command with command substitution using $()', () => { it('should allow a command with command substitution using $()', () => {
expect(isCommandAllowed('echo $(rm -rf /)', mockConfig).allowed).toBe( const evaluation = isCommandAllowed(
false, 'echo $(goodCommand --safe)',
mockConfig,
); );
expect(evaluation.allowed).toBe(true);
expect(evaluation.reason).toBeUndefined();
}); });
}); });
describe('build', () => { describe('build', () => {
it('should return an invocation for a valid command', () => { it('should return an invocation for a valid command', () => {
const invocation = shellTool.build({ command: 'ls -l' }); const invocation = shellTool.build({ command: 'goodCommand --safe' });
expect(invocation).toBeDefined(); expect(invocation).toBeDefined();
}); });
@@ -207,7 +229,7 @@ describe('ShellTool', () => {
); );
}); });
it('should not wrap command on windows', async () => { itWindowsOnly('should not wrap command on windows', async () => {
vi.mocked(os.platform).mockReturnValue('win32'); vi.mocked(os.platform).mockReturnValue('win32');
const invocation = shellTool.build({ command: 'dir' }); const invocation = shellTool.build({ command: 'dir' });
const promise = invocation.execute(mockAbortSignal); const promise = invocation.execute(mockAbortSignal);
@@ -426,3 +448,6 @@ describe('ShellTool', () => {
}); });
}); });
}); });
beforeAll(async () => {
await initializeShellParsers();
});
+9 -14
View File
@@ -34,6 +34,7 @@ import { formatMemoryUsage } from '../utils/formatters.js';
import type { AnsiOutput } from '../utils/terminalSerializer.js'; import type { AnsiOutput } from '../utils/terminalSerializer.js';
import { import {
getCommandRoots, getCommandRoots,
initializeShellParsers,
isCommandAllowed, isCommandAllowed,
SHELL_TOOL_NAMES, SHELL_TOOL_NAMES,
stripShellWrapper, stripShellWrapper,
@@ -388,25 +389,17 @@ function getShellToolDescription(): string {
Process Group PGID: Process group started or \`(none)\``; Process Group PGID: Process group started or \`(none)\``;
if (os.platform() === 'win32') { if (os.platform() === 'win32') {
return `This tool executes a given shell command as \`cmd.exe /c <command>\`. Command can start background processes using \`start /b\`.${returnedInfo}`; return `This tool executes a given shell command as \`powershell.exe -NoProfile -Command <command>\`. Command can start background processes using PowerShell constructs such as \`Start-Process -NoNewWindow\` or \`Start-Job\`.${returnedInfo}`;
} else { } else {
return `This tool executes a given shell command as \`bash -c <command>\`. 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}`; return `This tool executes a given shell command as \`bash -c <command>\`. 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 { function getCommandDescription(): string {
const cmd_substitution_warning =
'\n*** WARNING: Command substitution using $(), `` ` ``, <(), or >() is not allowed for security reasons.';
if (os.platform() === 'win32') { if (os.platform() === 'win32') {
return ( return 'Exact command to execute as `powershell.exe -NoProfile -Command <command>`';
'Exact command to execute as `cmd.exe /c <command>`' +
cmd_substitution_warning
);
} else { } else {
return ( return 'Exact bash command to execute as `bash -c <command>`';
'Exact bash command to execute as `bash -c <command>`' +
cmd_substitution_warning
);
} }
} }
@@ -418,6 +411,7 @@ export class ShellTool extends BaseDeclarativeTool<
private allowlist: Set<string> = new Set(); private allowlist: Set<string> = new Set();
constructor(private readonly config: Config) { constructor(private readonly config: Config) {
void initializeShellParsers();
super( super(
ShellTool.Name, ShellTool.Name,
'Shell', 'Shell',
@@ -451,6 +445,10 @@ export class ShellTool extends BaseDeclarativeTool<
protected override validateToolParamValues( protected override validateToolParamValues(
params: ShellToolParams, params: ShellToolParams,
): string | null { ): string | null {
if (!params.command.trim()) {
return 'Command cannot be empty.';
}
const commandCheck = isCommandAllowed(params.command, this.config); const commandCheck = isCommandAllowed(params.command, this.config);
if (!commandCheck.allowed) { if (!commandCheck.allowed) {
if (!commandCheck.reason) { if (!commandCheck.reason) {
@@ -461,9 +459,6 @@ export class ShellTool extends BaseDeclarativeTool<
} }
return commandCheck.reason; return commandCheck.reason;
} }
if (!params.command.trim()) {
return 'Command cannot be empty.';
}
if (getCommandRoots(params.command).length === 0) { if (getCommandRoots(params.command).length === 0) {
return 'Could not identify command root to obtain permission from user.'; return 'Could not identify command root to obtain permission from user.';
} }
+169 -63
View File
@@ -4,13 +4,22 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { expect, describe, it, beforeEach, vi, afterEach } from 'vitest'; import {
expect,
describe,
it,
beforeEach,
beforeAll,
vi,
afterEach,
} from 'vitest';
import { import {
checkCommandPermissions, checkCommandPermissions,
escapeShellArg, escapeShellArg,
getCommandRoots, getCommandRoots,
getShellConfiguration, getShellConfiguration,
isCommandAllowed, isCommandAllowed,
initializeShellParsers,
stripShellWrapper, stripShellWrapper,
} from './shell-utils.js'; } from './shell-utils.js';
import type { Config } from '../config/config.js'; import type { Config } from '../config/config.js';
@@ -32,6 +41,13 @@ vi.mock('shell-quote', () => ({
})); }));
let config: Config; let config: Config;
const isWindowsRuntime = process.platform === 'win32';
const describeWindowsOnly = isWindowsRuntime ? describe : describe.skip;
beforeAll(async () => {
mockPlatform.mockReturnValue('linux');
await initializeShellParsers();
});
beforeEach(() => { beforeEach(() => {
mockPlatform.mockReturnValue('linux'); mockPlatform.mockReturnValue('linux');
@@ -51,41 +67,41 @@ afterEach(() => {
describe('isCommandAllowed', () => { describe('isCommandAllowed', () => {
it('should allow a command if no restrictions are provided', () => { it('should allow a command if no restrictions are provided', () => {
const result = isCommandAllowed('ls -l', config); const result = isCommandAllowed('goodCommand --safe', config);
expect(result.allowed).toBe(true); expect(result.allowed).toBe(true);
}); });
it('should allow a command if it is in the global allowlist', () => { it('should allow a command if it is in the global allowlist', () => {
config.getCoreTools = () => ['ShellTool(ls)']; config.getCoreTools = () => ['ShellTool(goodCommand)'];
const result = isCommandAllowed('ls -l', config); const result = isCommandAllowed('goodCommand --safe', config);
expect(result.allowed).toBe(true); expect(result.allowed).toBe(true);
}); });
it('should block a command if it is not in a strict global allowlist', () => { it('should block a command if it is not in a strict global allowlist', () => {
config.getCoreTools = () => ['ShellTool(ls -l)']; config.getCoreTools = () => ['ShellTool(goodCommand --safe)'];
const result = isCommandAllowed('rm -rf /', config); const result = isCommandAllowed('badCommand --danger', config);
expect(result.allowed).toBe(false); expect(result.allowed).toBe(false);
expect(result.reason).toBe( expect(result.reason).toBe(
`Command(s) not in the allowed commands list. Disallowed commands: "rm -rf /"`, `Command(s) not in the allowed commands list. Disallowed commands: "badCommand --danger"`,
); );
}); });
it('should block a command if it is in the blocked list', () => { it('should block a command if it is in the blocked list', () => {
config.getExcludeTools = () => ['ShellTool(rm -rf /)']; config.getExcludeTools = () => ['ShellTool(badCommand --danger)'];
const result = isCommandAllowed('rm -rf /', config); const result = isCommandAllowed('badCommand --danger', config);
expect(result.allowed).toBe(false); expect(result.allowed).toBe(false);
expect(result.reason).toBe( expect(result.reason).toBe(
`Command 'rm -rf /' is blocked by configuration`, `Command 'badCommand --danger' is blocked by configuration`,
); );
}); });
it('should prioritize the blocklist over the allowlist', () => { it('should prioritize the blocklist over the allowlist', () => {
config.getCoreTools = () => ['ShellTool(rm -rf /)']; config.getCoreTools = () => ['ShellTool(badCommand --danger)'];
config.getExcludeTools = () => ['ShellTool(rm -rf /)']; config.getExcludeTools = () => ['ShellTool(badCommand --danger)'];
const result = isCommandAllowed('rm -rf /', config); const result = isCommandAllowed('badCommand --danger', config);
expect(result.allowed).toBe(false); expect(result.allowed).toBe(false);
expect(result.reason).toBe( expect(result.reason).toBe(
`Command 'rm -rf /' is blocked by configuration`, `Command 'badCommand --danger' is blocked by configuration`,
); );
}); });
@@ -106,58 +122,64 @@ describe('isCommandAllowed', () => {
it('should block a command on the blocklist even with a wildcard allow', () => { it('should block a command on the blocklist even with a wildcard allow', () => {
config.getCoreTools = () => ['ShellTool']; config.getCoreTools = () => ['ShellTool'];
config.getExcludeTools = () => ['ShellTool(rm -rf /)']; config.getExcludeTools = () => ['ShellTool(badCommand --danger)'];
const result = isCommandAllowed('rm -rf /', config); const result = isCommandAllowed('badCommand --danger', config);
expect(result.allowed).toBe(false); expect(result.allowed).toBe(false);
expect(result.reason).toBe( expect(result.reason).toBe(
`Command 'rm -rf /' is blocked by configuration`, `Command 'badCommand --danger' is blocked by configuration`,
); );
}); });
it('should allow a chained command if all parts are on the global allowlist', () => { it('should allow a chained command if all parts are on the global allowlist', () => {
config.getCoreTools = () => [ config.getCoreTools = () => [
'run_shell_command(echo)', 'run_shell_command(echo)',
'run_shell_command(ls)', 'run_shell_command(goodCommand)',
]; ];
const result = isCommandAllowed('echo "hello" && ls -l', config); const result = isCommandAllowed(
'echo "hello" && goodCommand --safe',
config,
);
expect(result.allowed).toBe(true); expect(result.allowed).toBe(true);
}); });
it('should block a chained command if any part is blocked', () => { it('should block a chained command if any part is blocked', () => {
config.getExcludeTools = () => ['run_shell_command(rm)']; config.getExcludeTools = () => ['run_shell_command(badCommand)'];
const result = isCommandAllowed('echo "hello" && rm -rf /', config); const result = isCommandAllowed(
'echo "hello" && badCommand --danger',
config,
);
expect(result.allowed).toBe(false); expect(result.allowed).toBe(false);
expect(result.reason).toBe( expect(result.reason).toBe(
`Command 'rm -rf /' is blocked by configuration`, `Command 'badCommand --danger' is blocked by configuration`,
); );
}); });
describe('command substitution', () => { describe('command substitution', () => {
it('should block command substitution using `$(...)`', () => { it('should allow command substitution using `$(...)`', () => {
const result = isCommandAllowed('echo $(rm -rf /)', config); const result = isCommandAllowed('echo $(goodCommand --safe)', config);
expect(result.allowed).toBe(false); expect(result.allowed).toBe(true);
expect(result.reason).toContain('Command substitution'); expect(result.reason).toBeUndefined();
}); });
it('should block command substitution using `<(...)`', () => { it('should allow command substitution using `<(...)`', () => {
const result = isCommandAllowed('diff <(ls) <(ls -a)', config); const result = isCommandAllowed('diff <(ls) <(ls -a)', config);
expect(result.allowed).toBe(false); expect(result.allowed).toBe(true);
expect(result.reason).toContain('Command substitution'); expect(result.reason).toBeUndefined();
}); });
it('should block command substitution using `>(...)`', () => { it('should allow command substitution using `>(...)`', () => {
const result = isCommandAllowed( const result = isCommandAllowed(
'echo "Log message" > >(tee log.txt)', 'echo "Log message" > >(tee log.txt)',
config, config,
); );
expect(result.allowed).toBe(false); expect(result.allowed).toBe(true);
expect(result.reason).toContain('Command substitution'); expect(result.reason).toBeUndefined();
}); });
it('should block command substitution using backticks', () => { it('should allow command substitution using backticks', () => {
const result = isCommandAllowed('echo `rm -rf /`', config); const result = isCommandAllowed('echo `goodCommand --safe`', config);
expect(result.allowed).toBe(false); expect(result.allowed).toBe(true);
expect(result.reason).toContain('Command substitution'); expect(result.reason).toBeUndefined();
}); });
it('should allow substitution-like patterns inside single quotes', () => { it('should allow substitution-like patterns inside single quotes', () => {
@@ -165,33 +187,54 @@ describe('isCommandAllowed', () => {
const result = isCommandAllowed("echo '$(pwd)'", config); const result = isCommandAllowed("echo '$(pwd)'", config);
expect(result.allowed).toBe(true); 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('checkCommandPermissions', () => {
describe('in "Default Allow" mode (no sessionAllowlist)', () => { describe('in "Default Allow" mode (no sessionAllowlist)', () => {
it('should return a detailed success object for an allowed command', () => { it('should return a detailed success object for an allowed command', () => {
const result = checkCommandPermissions('ls -l', config); const result = checkCommandPermissions('goodCommand --safe', config);
expect(result).toEqual({ expect(result).toEqual({
allAllowed: true, allAllowed: true,
disallowedCommands: [], disallowedCommands: [],
}); });
}); });
it('should return a detailed failure object for a blocked command', () => { it('should block commands that cannot be parsed safely', () => {
config.getExcludeTools = () => ['ShellTool(rm)']; const result = checkCommandPermissions('ls &&', config);
const result = checkCommandPermissions('rm -rf /', config);
expect(result).toEqual({ expect(result).toEqual({
allAllowed: false, allAllowed: false,
disallowedCommands: ['rm -rf /'], disallowedCommands: ['ls &&'],
blockReason: `Command 'rm -rf /' is blocked by configuration`, 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);
expect(result).toEqual({
allAllowed: false,
disallowedCommands: ['badCommand --danger'],
blockReason: `Command 'badCommand --danger' is blocked by configuration`,
isHardDenial: true, isHardDenial: true,
}); });
}); });
it('should return a detailed failure object for a command not on a strict allowlist', () => { it('should return a detailed failure object for a command not on a strict allowlist', () => {
config.getCoreTools = () => ['ShellTool(ls)']; config.getCoreTools = () => ['ShellTool(goodCommand)'];
const result = checkCommandPermissions('git status && ls', config); const result = checkCommandPermissions(
'git status && goodCommand',
config,
);
expect(result).toEqual({ expect(result).toEqual({
allAllowed: false, allAllowed: false,
disallowedCommands: ['git status'], disallowedCommands: ['git status'],
@@ -204,24 +247,24 @@ describe('checkCommandPermissions', () => {
describe('in "Default Deny" mode (with sessionAllowlist)', () => { describe('in "Default Deny" mode (with sessionAllowlist)', () => {
it('should allow a command on the sessionAllowlist', () => { it('should allow a command on the sessionAllowlist', () => {
const result = checkCommandPermissions( const result = checkCommandPermissions(
'ls -l', 'goodCommand --safe',
config, config,
new Set(['ls -l']), new Set(['goodCommand --safe']),
); );
expect(result.allAllowed).toBe(true); expect(result.allAllowed).toBe(true);
}); });
it('should block a command not on the sessionAllowlist or global allowlist', () => { it('should block a command not on the sessionAllowlist or global allowlist', () => {
const result = checkCommandPermissions( const result = checkCommandPermissions(
'rm -rf /', 'badCommand --danger',
config, config,
new Set(['ls -l']), new Set(['goodCommand --safe']),
); );
expect(result.allAllowed).toBe(false); expect(result.allAllowed).toBe(false);
expect(result.blockReason).toContain( expect(result.blockReason).toContain(
'not on the global or session allowlist', 'not on the global or session allowlist',
); );
expect(result.disallowedCommands).toEqual(['rm -rf /']); expect(result.disallowedCommands).toEqual(['badCommand --danger']);
}); });
it('should allow a command on the global allowlist even if not on the session allowlist', () => { it('should allow a command on the global allowlist even if not on the session allowlist', () => {
@@ -229,7 +272,7 @@ describe('checkCommandPermissions', () => {
const result = checkCommandPermissions( const result = checkCommandPermissions(
'git status', 'git status',
config, config,
new Set(['ls -l']), new Set(['goodCommand --safe']),
); );
expect(result.allAllowed).toBe(true); expect(result.allAllowed).toBe(true);
}); });
@@ -245,11 +288,11 @@ describe('checkCommandPermissions', () => {
}); });
it('should block a command on the sessionAllowlist if it is also globally blocked', () => { it('should block a command on the sessionAllowlist if it is also globally blocked', () => {
config.getExcludeTools = () => ['run_shell_command(rm)']; config.getExcludeTools = () => ['run_shell_command(badCommand)'];
const result = checkCommandPermissions( const result = checkCommandPermissions(
'rm -rf /', 'badCommand --danger',
config, config,
new Set(['rm -rf /']), new Set(['badCommand --danger']),
); );
expect(result.allAllowed).toBe(false); expect(result.allAllowed).toBe(false);
expect(result.blockReason).toContain('is blocked by configuration'); expect(result.blockReason).toContain('is blocked by configuration');
@@ -258,12 +301,12 @@ describe('checkCommandPermissions', () => {
it('should block a chained command if one part is not on any allowlist', () => { it('should block a chained command if one part is not on any allowlist', () => {
config.getCoreTools = () => ['run_shell_command(echo)']; config.getCoreTools = () => ['run_shell_command(echo)'];
const result = checkCommandPermissions( const result = checkCommandPermissions(
'echo "hello" && rm -rf /', 'echo "hello" && badCommand --danger',
config, config,
new Set(['echo']), new Set(['echo']),
); );
expect(result.allAllowed).toBe(false); expect(result.allAllowed).toBe(false);
expect(result.disallowedCommands).toEqual(['rm -rf /']); expect(result.disallowedCommands).toEqual(['badCommand --danger']);
}); });
}); });
}); });
@@ -290,6 +333,54 @@ describe('getCommandRoots', () => {
const result = getCommandRoots('echo "hello" && git commit -m "feat"'); const result = getCommandRoots('echo "hello" && git commit -m "feat"');
expect(result).toEqual(['echo', 'git']); 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', () => { describe('stripShellWrapper', () => {
@@ -309,6 +400,21 @@ describe('stripShellWrapper', () => {
expect(stripShellWrapper('cmd.exe /c "dir"')).toEqual('dir'); 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', () => { it('should not strip anything if no wrapper is present', () => {
expect(stripShellWrapper('ls -l')).toEqual('ls -l'); expect(stripShellWrapper('ls -l')).toEqual('ls -l');
}); });
@@ -400,21 +506,21 @@ describe('getShellConfiguration', () => {
mockPlatform.mockReturnValue('win32'); mockPlatform.mockReturnValue('win32');
}); });
it('should return cmd.exe configuration by default', () => { it('should return PowerShell configuration by default', () => {
delete process.env['ComSpec']; delete process.env['ComSpec'];
const config = getShellConfiguration(); const config = getShellConfiguration();
expect(config.executable).toBe('cmd.exe'); expect(config.executable).toBe('powershell.exe');
expect(config.argsPrefix).toEqual(['/d', '/s', '/c']); expect(config.argsPrefix).toEqual(['-NoProfile', '-Command']);
expect(config.shell).toBe('cmd'); expect(config.shell).toBe('powershell');
}); });
it('should respect ComSpec for cmd.exe', () => { it('should ignore ComSpec when pointing to cmd.exe', () => {
const cmdPath = 'C:\\WINDOWS\\system32\\cmd.exe'; const cmdPath = 'C:\\WINDOWS\\system32\\cmd.exe';
process.env['ComSpec'] = cmdPath; process.env['ComSpec'] = cmdPath;
const config = getShellConfiguration(); const config = getShellConfiguration();
expect(config.executable).toBe(cmdPath); expect(config.executable).toBe('powershell.exe');
expect(config.argsPrefix).toEqual(['/d', '/s', '/c']); expect(config.argsPrefix).toEqual(['-NoProfile', '-Command']);
expect(config.shell).toBe('cmd'); expect(config.shell).toBe('powershell');
}); });
it('should return PowerShell configuration if ComSpec points to powershell.exe', () => { it('should return PowerShell configuration if ComSpec points to powershell.exe', () => {
+337 -143
View File
@@ -4,12 +4,19 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { createRequire as createModuleRequire } from 'node:module';
import type { AnyToolInvocation } from '../index.js'; import type { AnyToolInvocation } from '../index.js';
import type { Config } from '../config/config.js'; import type { Config } from '../config/config.js';
import os from 'node:os'; import os from 'node:os';
import { quote } from 'shell-quote'; import { quote } from 'shell-quote';
import { doesToolInvocationMatch } from './tool-utils.js'; import { doesToolInvocationMatch } from './tool-utils.js';
import { spawn, type SpawnOptionsWithoutStdio } from 'node:child_process'; import {
spawn,
spawnSync,
type SpawnOptionsWithoutStdio,
} from 'node:child_process';
import type { Node } from 'web-tree-sitter';
import { Language, Parser } from 'web-tree-sitter';
export const SHELL_TOOL_NAMES = ['run_shell_command', 'ShellTool']; export const SHELL_TOOL_NAMES = ['run_shell_command', 'ShellTool'];
@@ -22,7 +29,7 @@ export type ShellType = 'cmd' | 'powershell' | 'bash';
* Defines the configuration required to execute a command string within a specific shell. * Defines the configuration required to execute a command string within a specific shell.
*/ */
export interface ShellConfiguration { export interface ShellConfiguration {
/** The path or name of the shell executable (e.g., 'bash', 'cmd.exe'). */ /** The path or name of the shell executable (e.g., 'bash', 'powershell.exe'). */
executable: string; executable: string;
/** /**
* The arguments required by the shell to execute a subsequent string argument. * The arguments required by the shell to execute a subsequent string argument.
@@ -32,6 +39,305 @@ export interface ShellConfiguration {
shell: ShellType; shell: ShellType;
} }
const requireModule = createModuleRequire(import.meta.url);
let bashLanguage: Language | null = null;
let treeSitterInitialization: Promise<void> | null = null;
async function loadBashLanguage(): Promise<void> {
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<void> {
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. * Determines the appropriate shell configuration for the current platform.
* *
@@ -42,32 +348,26 @@ export interface ShellConfiguration {
*/ */
export function getShellConfiguration(): ShellConfiguration { export function getShellConfiguration(): ShellConfiguration {
if (isWindows()) { if (isWindows()) {
const comSpec = process.env['ComSpec'] || 'cmd.exe'; const comSpec = process.env['ComSpec'];
if (comSpec) {
const executable = comSpec.toLowerCase(); const executable = comSpec.toLowerCase();
if ( if (
executable.endsWith('powershell.exe') || executable.endsWith('powershell.exe') ||
executable.endsWith('pwsh.exe') executable.endsWith('pwsh.exe')
) { ) {
// For PowerShell, the arguments are different.
// -NoProfile: Speeds up startup.
// -Command: Executes the following command.
return { return {
executable: comSpec, executable: comSpec,
argsPrefix: ['-NoProfile', '-Command'], argsPrefix: ['-NoProfile', '-Command'],
shell: 'powershell', shell: 'powershell',
}; };
} }
}
// Default to cmd.exe for anything else on Windows. // Default to PowerShell for all other Windows configurations.
// 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 { return {
executable: comSpec, executable: 'powershell.exe',
argsPrefix: ['/d', '/s', '/c'], argsPrefix: ['-NoProfile', '-Command'],
shell: 'cmd', shell: 'powershell',
}; };
} }
@@ -114,53 +414,12 @@ export function escapeShellArg(arg: string, shell: ShellType): string {
* @returns An array of individual command strings * @returns An array of individual command strings
*/ */
export function splitCommands(command: string): string[] { export function splitCommands(command: string): string[] {
const commands: string[] = []; const parsed = parseCommandDetails(command);
let currentCommand = ''; if (!parsed || parsed.hasError) {
let inSingleQuotes = false; return [];
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) { return parsed.details.map((detail) => detail.text).filter(Boolean);
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++;
}
if (currentCommand.trim()) {
commands.push(currentCommand.trim());
}
return commands.filter(Boolean); // Filter out any empty strings
} }
/** /**
@@ -172,40 +431,30 @@ export function splitCommands(command: string): string[] {
* @example getCommandRoot("git status && npm test") returns "git" * @example getCommandRoot("git status && npm test") returns "git"
*/ */
export function getCommandRoot(command: string): string | undefined { export function getCommandRoot(command: string): string | undefined {
const trimmedCommand = command.trim(); const parsed = parseCommandDetails(command);
if (!trimmedCommand) { if (!parsed || parsed.hasError || parsed.details.length === 0) {
return undefined; return undefined;
} }
// This regex is designed to find the first "word" of a command, return parsed.details[0]?.name;
// 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[] { export function getCommandRoots(command: string): string[] {
if (!command) { if (!command) {
return []; return [];
} }
return splitCommands(command)
.map((c) => getCommandRoot(c)) const parsed = parseCommandDetails(command);
.filter((c): c is string => !!c); if (!parsed || parsed.hasError) {
return [];
}
return parsed.details.map((detail) => detail.name).filter(Boolean);
} }
export function stripShellWrapper(command: string): string { export function stripShellWrapper(command: string): string {
const pattern = /^\s*(?:sh|bash|zsh|cmd.exe)\s+(?:\/c|-c)\s+/; 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 match = command.match(pattern); const match = command.match(pattern);
if (match) { if (match) {
let newCommand = command.substring(match[0].length).trim(); let newCommand = command.substring(match[0].length).trim();
@@ -228,62 +477,6 @@ export function stripShellWrapper(command: string): string {
* @param command The shell command string to check * @param command The shell command string to check
* @returns true if command substitution would be executed by bash * @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. * Checks a shell command against security policies and allowlists.
* *
@@ -318,19 +511,20 @@ export function checkCommandPermissions(
blockReason?: string; blockReason?: string;
isHardDenial?: boolean; isHardDenial?: boolean;
} { } {
// Disallow command substitution for security. const parseResult = parseCommandDetails(command);
if (detectCommandSubstitution(command)) { if (!parseResult || parseResult.hasError) {
return { return {
allAllowed: false, allAllowed: false,
disallowedCommands: [command], disallowedCommands: [command],
blockReason: blockReason: 'Command rejected because it could not be parsed safely',
'Command substitution using $(), `` ` ``, <(), or >() is not allowed for security reasons',
isHardDenial: true, isHardDenial: true,
}; };
} }
const normalize = (cmd: string): string => cmd.trim().replace(/\s+/g, ' '); const normalize = (cmd: string): string => cmd.trim().replace(/\s+/g, ' ');
const commandsToValidate = splitCommands(command).map(normalize); const commandsToValidate = parseResult.details
.map((detail) => normalize(detail.text))
.filter(Boolean);
const invocation: AnyToolInvocation & { params: { command: string } } = { const invocation: AnyToolInvocation & { params: { command: string } } = {
params: { command: '' }, params: { command: '' },
} as AnyToolInvocation & { params: { command: string } }; } as AnyToolInvocation & { params: { command: string } };