Compare commits

...

2 Commits

Author SHA1 Message Date
galz10 09cd474098 fix(core): standardise shell AST validation on tree-sitter-bash
Addresses code review feedback for the recent AST-based shell execution policy enforcement.

Changes:
- Removed `bash-parser` and its dependencies in favor of standardizing entirely on `tree-sitter-bash` via `parseCommandDetails` for all shell parsing.
- Refactored `extractCommandsFromAst` to use the unified parser, correctly preserving prefix variable assignments (e.g., `FOO=bar ls`) and exact quotes.
- Fixed an issue where globally allowed shell tools (e.g., `['run_shell_command']`) would incorrectly fail-closed on empty or variable-only commands by evaluating global tool allowances before parsing.
- Updated AST validation tests to expect exact literal string matches (including quotes) and added `beforeAll` initialization for the WebAssembly parser.
2026-03-17 14:37:27 -07:00
galz10 459db523e2 fix(core): secure shell execution with AST validation
Replaces simplistic prefix-matching for shell command policies with robust Abstract Syntax Tree (AST) parsing using `bash-parser`.

Previously, policies for shell tools only checked if the command string started with an allowed prefix (e.g., `echo`), allowing trivial bypasses via shell operators like `&&` or `;` (e.g., `echo "ok" && rm -rf /`).

This update secures the execution pipeline by parsing the shell string and validating *every* extracted sub-command against the allowed policies.

Key changes:
- Integrated `bash-parser` to synchronously extract executable commands from pipelines, lists, and subshells.
- Updated `doesToolInvocationMatch` to enforce policy on all extracted sub-commands instead of just the string prefix.
- Enforced `coreTools` validation at execution time within `ShellTool` to prevent bypasses when tools are configured via `settings.json`.
- Updated the CLI `useShellCommandProcessor` to run human-input commands through the AST `PolicyEngine` check before spawning the process.
- Fixed asynchronous test flakiness in the CLI package caused by the new policy enforcement.
2026-03-16 15:07:51 -07:00
10 changed files with 255 additions and 57 deletions
+7 -3
View File
@@ -486,7 +486,8 @@
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz",
"integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
"license": "(Apache-2.0 AND BSD-3-Clause)",
"peer": true
},
"node_modules/@bundled-es-modules/cookie": {
"version": "2.0.1",
@@ -1489,6 +1490,7 @@
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz",
"integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@grpc/proto-loader": "^0.7.13",
"@js-sdsl/ordered-map": "^4.4.2"
@@ -7411,7 +7413,8 @@
"version": "0.0.1581282",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz",
"integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==",
"license": "BSD-3-Clause"
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/dezalgo": {
"version": "1.0.4",
@@ -16247,7 +16250,6 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"peer": true
},
@@ -17800,6 +17802,7 @@
"@types/js-yaml": "^4.0.9",
"@types/json-stable-stringify": "^1.1.0",
"@types/picomatch": "^4.0.1",
"@vitest/coverage-v8": "^3.2.4",
"chrome-devtools-mcp": "^0.19.0",
"msw": "^2.3.4",
"typescript": "^5.3.3",
@@ -17865,6 +17868,7 @@
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz",
"integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@grpc/proto-loader": "^0.8.0",
"@js-sdsl/ordered-map": "^4.4.2"
@@ -77,6 +77,7 @@ import {
type ShellExecutionResult,
type ShellOutputEvent,
CoreToolCallStatus,
PolicyDecision,
} from '@google/gemini-cli-core';
import * as fs from 'node:fs';
import * as os from 'node:os';
@@ -107,6 +108,7 @@ describe('useShellCommandProcessor', () => {
mockConfig = {
getTargetDir: () => '/test/dir',
getEnableInteractiveShell: () => false,
getPolicyEngine: () => ({ check: vi.fn().mockResolvedValue({ decision: PolicyDecision.ALLOW }) }),
getShellExecutionConfig: () => ({
terminalHeight: 20,
terminalWidth: 80,
@@ -228,8 +230,8 @@ describe('useShellCommandProcessor', () => {
it('should handle successful execution and update history correctly', async () => {
const { result } = renderProcessorHook();
act(() => {
result.current.handleShellCommand(
await act(async () => {
result.current.handleShellCommand(
'echo "ok"',
new AbortController().signal,
);
@@ -260,8 +262,8 @@ describe('useShellCommandProcessor', () => {
it('should handle command failure and display error status', async () => {
const { result } = renderProcessorHook();
act(() => {
result.current.handleShellCommand(
await act(async () => {
result.current.handleShellCommand(
'bad-cmd',
new AbortController().signal,
);
@@ -357,8 +359,8 @@ describe('useShellCommandProcessor', () => {
it('should show binary progress messages correctly', async () => {
const { result } = renderProcessorHook();
act(() => {
result.current.handleShellCommand(
await act(async () => {
result.current.handleShellCommand(
'cat img',
new AbortController().signal,
);
@@ -449,8 +451,8 @@ describe('useShellCommandProcessor', () => {
const { result } = renderProcessorHook();
const abortController = new AbortController();
act(() => {
result.current.handleShellCommand('sleep 5', abortController.signal);
await act(async () => {
result.current.handleShellCommand('sleep 5', abortController.signal);
});
const execPromise = onExecMock.mock.calls[0][0];
@@ -474,8 +476,8 @@ describe('useShellCommandProcessor', () => {
const binaryBuffer = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
mockIsBinary.mockReturnValue(true);
act(() => {
result.current.handleShellCommand(
await act(async () => {
result.current.handleShellCommand(
'cat image.png',
new AbortController().signal,
);
@@ -504,8 +506,8 @@ describe('useShellCommandProcessor', () => {
result: Promise.reject(testError),
}));
act(() => {
result.current.handleShellCommand(
await act(async () => {
result.current.handleShellCommand(
'a-command',
new AbortController().signal,
);
@@ -533,8 +535,8 @@ describe('useShellCommandProcessor', () => {
const { result } = renderProcessorHook();
act(() => {
result.current.handleShellCommand(
await act(async () => {
result.current.handleShellCommand(
'a-command',
new AbortController().signal,
);
@@ -562,8 +564,8 @@ describe('useShellCommandProcessor', () => {
vi.mocked(fs.readFileSync).mockReturnValue('/test/dir/new'); // A different directory
const { result } = renderProcessorHook();
act(() => {
result.current.handleShellCommand(
await act(async () => {
result.current.handleShellCommand(
'cd new',
new AbortController().signal,
);
@@ -587,8 +589,8 @@ describe('useShellCommandProcessor', () => {
vi.mocked(fs.readFileSync).mockReturnValue('/test/dir'); // The same directory
const { result } = renderProcessorHook();
act(() => {
result.current.handleShellCommand('ls', new AbortController().signal);
await act(async () => {
result.current.handleShellCommand('ls', new AbortController().signal);
});
const execPromise = onExecMock.mock.calls[0][0];
@@ -729,8 +731,8 @@ describe('useShellCommandProcessor', () => {
expect(result.current.activeShellPtyId).toBeNull(); // Pre-condition
act(() => {
result.current.handleShellCommand('cmd', new AbortController().signal);
await act(async () => {
result.current.handleShellCommand('cmd', new AbortController().signal);
});
const execPromise = onExecMock.mock.calls[0][0];
@@ -756,8 +758,8 @@ describe('useShellCommandProcessor', () => {
const { result } = renderProcessorHook();
act(() => {
result.current.handleShellCommand('ls', new AbortController().signal);
await act(async () => {
result.current.handleShellCommand('ls', new AbortController().signal);
});
// Let microtasks run
@@ -1104,8 +1106,8 @@ describe('useShellCommandProcessor', () => {
expect(result.current.isBackgroundShellVisible).toBe(true);
// 2. Start foreground shell
act(() => {
result.current.handleShellCommand('ls', new AbortController().signal);
await act(async () => {
result.current.handleShellCommand('ls', new AbortController().signal);
});
// Wait for PID to be set
@@ -1140,8 +1142,8 @@ describe('useShellCommandProcessor', () => {
expect(result.current.isBackgroundShellVisible).toBe(true);
// 2. Start foreground shell
act(() => {
result.current.handleShellCommand('ls', new AbortController().signal);
await act(async () => {
result.current.handleShellCommand('ls', new AbortController().signal);
});
await waitFor(() => expect(result.current.activeShellPtyId).toBe(12345));
expect(result.current.isBackgroundShellVisible).toBe(false);
@@ -14,6 +14,7 @@ import {
isBinary,
ShellExecutionService,
CoreToolCallStatus,
PolicyDecision,
} from '@google/gemini-cli-core';
import { type PartListUnion } from '@google/genai';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
@@ -298,6 +299,45 @@ export const useShellCommandProcessor = (
}
const executeCommand = async () => {
try {
const policyEngine = config.getPolicyEngine();
const { decision } = await policyEngine.check(
{ name: 'run_shell_command', args: { command: rawQuery } },
undefined,
);
if (decision === PolicyDecision.DENY) {
addItemToHistory(
{
type: 'error',
text: `Command cannot be run. Blocked command: "${rawQuery}". Reason: Blocked by policy.`,
},
userMessageTimestamp,
);
if (pwdFilePath && fs.existsSync(pwdFilePath)) {
fs.unlinkSync(pwdFilePath);
}
dispatch({ type: 'SET_ACTIVE_PTY', pid: null });
setShellInputFocused(false);
return;
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
addItemToHistory(
{
type: 'error',
text: `Policy validation error: ${errorMessage}`,
},
userMessageTimestamp,
);
if (pwdFilePath && fs.existsSync(pwdFilePath)) {
fs.unlinkSync(pwdFilePath);
}
dispatch({ type: 'SET_ACTIVE_PTY', pid: null });
setShellInputFocused(false);
return;
}
let cumulativeStdout: string | AnsiOutput = '';
let isBinaryStream = false;
let binaryBytesReceived = 0;
+1
View File
@@ -105,6 +105,7 @@
"@types/js-yaml": "^4.0.9",
"@types/json-stable-stringify": "^1.1.0",
"@types/picomatch": "^4.0.1",
"@vitest/coverage-v8": "^3.2.4",
"chrome-devtools-mcp": "^0.19.0",
"msw": "^2.3.4",
"typescript": "^5.3.3",
+15 -2
View File
@@ -102,9 +102,9 @@ describe('ShellTool', () => {
stripThoughtsFromHistory: vi.fn(),
},
getAllowedTools: vi.fn().mockReturnValue([]),
getAllowedTools: vi.fn().mockReturnValue(undefined),
getApprovalMode: vi.fn().mockReturnValue('strict'),
getCoreTools: vi.fn().mockReturnValue([]),
getCoreTools: vi.fn().mockReturnValue(undefined),
getExcludeTools: vi.fn().mockReturnValue(new Set([])),
getDebugMode: vi.fn().mockReturnValue(false),
getTargetDir: vi.fn().mockReturnValue(tempRootDir),
@@ -439,6 +439,19 @@ describe('ShellTool', () => {
);
});
it('should return a policy violation error if the command is disallowed', async () => {
(mockConfig.getAllowedTools as Mock).mockReturnValueOnce(['run_shell_command(ls)']);
const invocation = shellTool.build({ command: 'ls && cat /etc/passwd' });
const promise = invocation.execute(mockAbortSignal);
const result = await promise;
expect(result.error).toBeDefined();
expect(result.error?.type).toBe(ToolErrorType.SHELL_EXECUTE_ERROR);
expect(result.error?.message).toBe('Command rejected by policy.');
expect(result.llmContent).toBe('Command rejected by policy.');
});
it('should summarize output when configured', async () => {
(mockConfig.getSummarizeToolOutputConfig as Mock).mockReturnValue({
[SHELL_TOOL_NAME]: { tokenBudget: 1000 },
+29
View File
@@ -44,6 +44,7 @@ import { SHELL_TOOL_NAME } from './tool-names.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { getShellDefinition } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
import { doesToolInvocationMatch } from '../utils/tool-utils.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
export const OUTPUT_UPDATE_INTERVAL_MS = 1000;
@@ -153,6 +154,34 @@ export class ShellToolInvocation extends BaseToolInvocation<
shellExecutionConfig?: ShellExecutionConfig,
setExecutionIdCallback?: (executionId: number) => void,
): Promise<ToolResult> {
const allowedTools = this.context.config.getAllowedTools?.();
if (allowedTools !== undefined) {
if (!doesToolInvocationMatch('ShellTool', this.params.command, allowedTools)) {
return {
llmContent: 'Command rejected by policy.',
returnDisplay: 'Command rejected by policy.',
error: {
message: 'Command rejected by policy.',
type: ToolErrorType.SHELL_EXECUTE_ERROR,
},
};
}
}
const coreTools = this.context.config.getCoreTools?.();
if (coreTools !== undefined) {
if (!doesToolInvocationMatch('ShellTool', this.params.command, coreTools)) {
return {
llmContent: 'Command rejected by policy.',
returnDisplay: 'Command rejected by policy.',
error: {
message: 'Command rejected by policy.',
type: ToolErrorType.SHELL_EXECUTE_ERROR,
},
};
}
}
const strippedCommand = stripShellWrapper(this.params.command);
if (signal.aborted) {
@@ -0,0 +1,49 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { extractCommandsFromAst } from './shell-ast-parser.js';
import { initializeShellParsers } from './shell-utils.js';
describe('shell-ast-parser', () => {
beforeAll(async () => {
await initializeShellParsers();
});
it('extracts a simple command', () => {
const cmds = extractCommandsFromAst('echo "hello"');
expect(cmds).toEqual(['echo "hello"']);
});
it('extracts commands from a pipeline', () => {
const cmds = extractCommandsFromAst('echo "hello" | grep h');
expect(cmds).toEqual(['echo "hello"', 'grep h']);
});
it('extracts commands from lists', () => {
const cmds = extractCommandsFromAst('mkdir foo && cd foo || echo "failed" ; ls');
expect(cmds).toEqual(['mkdir foo', 'cd foo', 'echo "failed"', 'ls']);
});
it('extracts commands from subshells', () => {
const cmds = extractCommandsFromAst('echo $(ls -la) && (cd /tmp && pwd)');
// Depending on reconstruction, we should at least see the commands
expect(cmds).toContain('ls -la');
expect(cmds).toContain('cd /tmp');
expect(cmds).toContain('pwd');
expect(cmds).toContain('echo $(ls -la)');
});
it('returns empty array on syntax error', () => {
const cmds = extractCommandsFromAst('echo "unterminated');
expect(cmds).toEqual([]);
});
it('handles empty strings gracefully', () => {
expect(extractCommandsFromAst('')).toEqual([]);
expect(extractCommandsFromAst(' ')).toEqual([]);
});
});
@@ -0,0 +1,28 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { parseCommandDetails } from './shell-utils.js';
/**
* Parses a raw shell string and extracts all individual executable commands.
* Handles simple commands, pipelines, lists, and subshells.
*
* @param shellString The raw shell command string
* @returns An array of string representing the commands
*/
export function extractCommandsFromAst(shellString: string): string[] {
if (!shellString || !shellString.trim()) {
return [];
}
const parsed = parseCommandDetails(shellString);
if (!parsed || parsed.hasError) {
return [];
}
return parsed.details.map((detail) => detail.text);
}
+6 -1
View File
@@ -4,12 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { expect, describe, it } from 'vitest';
import { expect, describe, it, beforeAll } from 'vitest';
import {
doesToolInvocationMatch,
getToolSuggestion,
shouldHideToolCall,
} from './tool-utils.js';
import { initializeShellParsers } from './shell-utils.js';
import {
ReadFileTool,
ApprovalMode,
@@ -137,6 +138,10 @@ describe('getToolSuggestion', () => {
});
describe('doesToolInvocationMatch', () => {
beforeAll(async () => {
await initializeShellParsers();
});
it('should not match a partial command prefix', () => {
const invocation = {
params: { command: 'git commitsomething' },
+52 -25
View File
@@ -10,6 +10,7 @@ import {
type AnyToolInvocation,
} from '../index.js';
import { SHELL_TOOL_NAMES } from './shell-utils.js';
import { extractCommandsFromAst } from './shell-ast-parser.js';
import levenshtein from 'fast-levenshtein';
import { ApprovalMode } from '../policy/types.js';
import {
@@ -153,49 +154,75 @@ export function doesToolInvocationMatch(
toolNames = [toolOrToolName];
}
if (toolNames.some((name) => SHELL_TOOL_NAMES.includes(name))) {
const isShellTool = toolNames.some((name) => SHELL_TOOL_NAMES.includes(name));
if (isShellTool) {
toolNames = [...new Set([...toolNames, ...SHELL_TOOL_NAMES])];
}
// Globally allowed tools check (non-shell and shell)
for (const pattern of patterns) {
const openParen = pattern.indexOf('(');
if (openParen === -1) {
// No arguments, just a tool name
if (toolNames.includes(pattern)) {
return true;
}
continue;
if (openParen === -1 && toolNames.includes(pattern)) {
return true;
}
}
const patternToolName = pattern.substring(0, openParen);
if (!toolNames.includes(patternToolName)) {
continue;
}
if (!pattern.endsWith(')')) {
continue;
}
const argPattern = pattern.substring(openParen + 1, pattern.length - 1);
let command: string;
if (isShellTool) {
let command: string | undefined;
if (typeof invocation === 'string') {
command = invocation;
} else {
if (!('command' in invocation.params)) {
// This invocation has no command - nothing to check.
continue;
return false;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
command = String((invocation.params as { command: string }).command);
}
if (toolNames.some((name) => SHELL_TOOL_NAMES.includes(name))) {
if (command === argPattern || command.startsWith(argPattern + ' ')) {
return true;
if (!command) {
return false;
}
const subCommands = extractCommandsFromAst(command);
if (subCommands.length === 0) {
return false; // Fail-closed for empty or unparseable commands
}
// Every extracted sub-command must match at least one pattern.
for (const subCommand of subCommands) {
let subCommandMatched = false;
for (const pattern of patterns) {
const openParen = pattern.indexOf('(');
if (openParen === -1) {
// No arguments, just a tool name
if (toolNames.includes(pattern)) {
subCommandMatched = true;
break;
}
continue;
}
const patternToolName = pattern.substring(0, openParen);
if (!toolNames.includes(patternToolName) || !pattern.endsWith(')')) {
continue;
}
const argPattern = pattern.substring(openParen + 1, pattern.length - 1);
if (subCommand === argPattern || subCommand.startsWith(argPattern + ' ')) {
subCommandMatched = true;
break;
}
}
if (!subCommandMatched) {
return false; // This sub-command failed all patterns, so the whole invocation fails
}
}
return true; // All sub-commands matched at least one pattern
}
return false;