summaryrefslogtreecommitdiff
path: root/packages/core/src
diff options
context:
space:
mode:
authormatt korwel <[email protected]>2025-07-25 12:25:32 -0700
committerGitHub <[email protected]>2025-07-25 19:25:32 +0000
commit820105e982e594b1bcee46ab866a7c70e5795b34 (patch)
tree124152666499b293fd4fa27dc7fc9e544d0baa70 /packages/core/src
parent7ddbf97634e906d7c294bdf5a94dbe12419ee7c1 (diff)
Safer Shell command Execution (#4795)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: N. Taylor Mullen <[email protected]>
Diffstat (limited to 'packages/core/src')
-rw-r--r--packages/core/src/tools/shell.test.ts420
-rw-r--r--packages/core/src/tools/shell.ts212
-rw-r--r--packages/core/src/utils/shell-utils.test.ts583
-rw-r--r--packages/core/src/utils/shell-utils.ts288
4 files changed, 948 insertions, 555 deletions
diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts
index 22ae9a0b..b0c7d317 100644
--- a/packages/core/src/tools/shell.test.ts
+++ b/packages/core/src/tools/shell.test.ts
@@ -9,397 +9,9 @@ import { ShellTool } from './shell.js';
import { Config } from '../config/config.js';
import * as summarizer from '../utils/summarizer.js';
import { GeminiClient } from '../core/client.js';
+import { ToolExecuteConfirmationDetails } from './tools.js';
import os from 'os';
-describe('ShellTool', () => {
- it('should allow a command if no restrictions are provided', async () => {
- const config = {
- getCoreTools: () => undefined,
- getExcludeTools: () => undefined,
- } as Config;
- const shellTool = new ShellTool(config);
- const result = shellTool.isCommandAllowed('ls -l');
- expect(result.allowed).toBe(true);
- });
-
- it('should allow a command if it is in the allowed list', async () => {
- const config = {
- getCoreTools: () => ['ShellTool(ls -l)'],
- getExcludeTools: () => undefined,
- } as unknown as Config;
- const shellTool = new ShellTool(config);
- const result = shellTool.isCommandAllowed('ls -l');
- expect(result.allowed).toBe(true);
- });
-
- it('should block a command if it is not in the allowed list', async () => {
- const config = {
- getCoreTools: () => ['ShellTool(ls -l)'],
- getExcludeTools: () => undefined,
- } as unknown as Config;
- const shellTool = new ShellTool(config);
- const result = shellTool.isCommandAllowed('rm -rf /');
- expect(result.allowed).toBe(false);
- expect(result.reason).toBe(
- "Command 'rm -rf /' is not in the allowed commands list",
- );
- });
-
- it('should block a command if it is in the blocked list', async () => {
- const config = {
- getCoreTools: () => undefined,
- getExcludeTools: () => ['ShellTool(rm -rf /)'],
- } as unknown as Config;
- const shellTool = new ShellTool(config);
- const result = shellTool.isCommandAllowed('rm -rf /');
- expect(result.allowed).toBe(false);
- expect(result.reason).toBe(
- "Command 'rm -rf /' is blocked by configuration",
- );
- });
-
- it('should allow a command if it is not in the blocked list', async () => {
- const config = {
- getCoreTools: () => undefined,
- getExcludeTools: () => ['ShellTool(rm -rf /)'],
- } as unknown as Config;
- const shellTool = new ShellTool(config);
- const result = shellTool.isCommandAllowed('ls -l');
- expect(result.allowed).toBe(true);
- });
-
- it('should block a command if it is in both the allowed and blocked lists', async () => {
- const config = {
- getCoreTools: () => ['ShellTool(rm -rf /)'],
- getExcludeTools: () => ['ShellTool(rm -rf /)'],
- } as unknown as Config;
- const shellTool = new ShellTool(config);
- const result = shellTool.isCommandAllowed('rm -rf /');
- expect(result.allowed).toBe(false);
- expect(result.reason).toBe(
- "Command 'rm -rf /' is blocked by configuration",
- );
- });
-
- it('should allow any command when ShellTool is in coreTools without specific commands', async () => {
- const config = {
- getCoreTools: () => ['ShellTool'],
- getExcludeTools: () => [],
- } as unknown as Config;
- const shellTool = new ShellTool(config);
- const result = shellTool.isCommandAllowed('any command');
- expect(result.allowed).toBe(true);
- });
-
- it('should block any command when ShellTool is in excludeTools without specific commands', async () => {
- const config = {
- getCoreTools: () => [],
- getExcludeTools: () => ['ShellTool'],
- } as unknown as Config;
- const shellTool = new ShellTool(config);
- const result = shellTool.isCommandAllowed('any command');
- expect(result.allowed).toBe(false);
- expect(result.reason).toBe(
- 'Shell tool is globally disabled in configuration',
- );
- });
-
- it('should allow a command if it is in the allowed list using the public-facing name', async () => {
- const config = {
- getCoreTools: () => ['run_shell_command(ls -l)'],
- getExcludeTools: () => undefined,
- } as unknown as Config;
- const shellTool = new ShellTool(config);
- const result = shellTool.isCommandAllowed('ls -l');
- expect(result.allowed).toBe(true);
- });
-
- it('should block a command if it is in the blocked list using the public-facing name', async () => {
- const config = {
- getCoreTools: () => undefined,
- getExcludeTools: () => ['run_shell_command(rm -rf /)'],
- } as unknown as Config;
- const shellTool = new ShellTool(config);
- const result = shellTool.isCommandAllowed('rm -rf /');
- expect(result.allowed).toBe(false);
- expect(result.reason).toBe(
- "Command 'rm -rf /' is blocked by configuration",
- );
- });
-
- it('should block any command when ShellTool is in excludeTools using the public-facing name', async () => {
- const config = {
- getCoreTools: () => [],
- getExcludeTools: () => ['run_shell_command'],
- } as unknown as Config;
- const shellTool = new ShellTool(config);
- const result = shellTool.isCommandAllowed('any command');
- expect(result.allowed).toBe(false);
- expect(result.reason).toBe(
- 'Shell tool is globally disabled in configuration',
- );
- });
-
- it('should block any command if coreTools contains an empty ShellTool command list using the public-facing name', async () => {
- const config = {
- getCoreTools: () => ['run_shell_command()'],
- getExcludeTools: () => [],
- } as unknown as Config;
- const shellTool = new ShellTool(config);
- const result = shellTool.isCommandAllowed('any command');
- expect(result.allowed).toBe(false);
- expect(result.reason).toBe(
- "Command 'any command' is not in the allowed commands list",
- );
- });
-
- it('should block any command if coreTools contains an empty ShellTool command list', async () => {
- const config = {
- getCoreTools: () => ['ShellTool()'],
- getExcludeTools: () => [],
- } as unknown as Config;
- const shellTool = new ShellTool(config);
- const result = shellTool.isCommandAllowed('any command');
- expect(result.allowed).toBe(false);
- expect(result.reason).toBe(
- "Command 'any command' is not in the allowed commands list",
- );
- });
-
- it('should block a command with extra whitespace if it is in the blocked list', async () => {
- const config = {
- getCoreTools: () => undefined,
- getExcludeTools: () => ['ShellTool(rm -rf /)'],
- } as unknown as Config;
- const shellTool = new ShellTool(config);
- const result = shellTool.isCommandAllowed(' rm -rf / ');
- expect(result.allowed).toBe(false);
- expect(result.reason).toBe(
- "Command 'rm -rf /' is blocked by configuration",
- );
- });
-
- it('should allow any command when ShellTool is present with specific commands', async () => {
- const config = {
- getCoreTools: () => ['ShellTool', 'ShellTool(ls)'],
- getExcludeTools: () => [],
- } as unknown as Config;
- const shellTool = new ShellTool(config);
- const result = shellTool.isCommandAllowed('any command');
- expect(result.allowed).toBe(true);
- });
-
- it('should block a command on the blocklist even with a wildcard allow', async () => {
- const config = {
- getCoreTools: () => ['ShellTool'],
- getExcludeTools: () => ['ShellTool(rm -rf /)'],
- } as unknown as Config;
- const shellTool = new ShellTool(config);
- const result = shellTool.isCommandAllowed('rm -rf /');
- expect(result.allowed).toBe(false);
- expect(result.reason).toBe(
- "Command 'rm -rf /' is blocked by configuration",
- );
- });
-
- it('should allow a command that starts with an allowed command prefix', async () => {
- const config = {
- getCoreTools: () => ['ShellTool(gh issue edit)'],
- getExcludeTools: () => [],
- } as unknown as Config;
- const shellTool = new ShellTool(config);
- const result = shellTool.isCommandAllowed(
- 'gh issue edit 1 --add-label "kind/feature"',
- );
- expect(result.allowed).toBe(true);
- });
-
- it('should allow a command that starts with an allowed command prefix using the public-facing name', async () => {
- const config = {
- getCoreTools: () => ['run_shell_command(gh issue edit)'],
- getExcludeTools: () => [],
- } as unknown as Config;
- const shellTool = new ShellTool(config);
- const result = shellTool.isCommandAllowed(
- 'gh issue edit 1 --add-label "kind/feature"',
- );
- expect(result.allowed).toBe(true);
- });
-
- it('should not allow a command that starts with an allowed command prefix but is chained with another command', async () => {
- const config = {
- getCoreTools: () => ['run_shell_command(gh issue edit)'],
- getExcludeTools: () => [],
- } as unknown as Config;
- const shellTool = new ShellTool(config);
- const result = shellTool.isCommandAllowed('gh issue edit&&rm -rf /');
- expect(result.allowed).toBe(false);
- expect(result.reason).toBe(
- "Command 'rm -rf /' is not in the allowed commands list",
- );
- });
-
- it('should not allow a command that is a prefix of an allowed command', async () => {
- const config = {
- getCoreTools: () => ['run_shell_command(gh issue edit)'],
- getExcludeTools: () => [],
- } as unknown as Config;
- const shellTool = new ShellTool(config);
- const result = shellTool.isCommandAllowed('gh issue');
- expect(result.allowed).toBe(false);
- expect(result.reason).toBe(
- "Command 'gh issue' is not in the allowed commands list",
- );
- });
-
- it('should not allow a command that is a prefix of a blocked command', async () => {
- const config = {
- getCoreTools: () => [],
- getExcludeTools: () => ['run_shell_command(gh issue edit)'],
- } as unknown as Config;
- const shellTool = new ShellTool(config);
- const result = shellTool.isCommandAllowed('gh issue');
- expect(result.allowed).toBe(true);
- });
-
- it('should not allow a command that is chained with a pipe', async () => {
- const config = {
- getCoreTools: () => ['run_shell_command(gh issue list)'],
- getExcludeTools: () => [],
- } as unknown as Config;
- const shellTool = new ShellTool(config);
- const result = shellTool.isCommandAllowed('gh issue list | rm -rf /');
- expect(result.allowed).toBe(false);
- expect(result.reason).toBe(
- "Command 'rm -rf /' is not in the allowed commands list",
- );
- });
-
- it('should not allow a command that is chained with a semicolon', async () => {
- const config = {
- getCoreTools: () => ['run_shell_command(gh issue list)'],
- getExcludeTools: () => [],
- } as unknown as Config;
- const shellTool = new ShellTool(config);
- const result = shellTool.isCommandAllowed('gh issue list; rm -rf /');
- expect(result.allowed).toBe(false);
- expect(result.reason).toBe(
- "Command 'rm -rf /' is not in the allowed commands list",
- );
- });
-
- it('should block a chained command if any part is blocked', async () => {
- const config = {
- getCoreTools: () => ['run_shell_command(echo "hello")'],
- getExcludeTools: () => ['run_shell_command(rm)'],
- } as unknown as Config;
- const shellTool = new ShellTool(config);
- const result = shellTool.isCommandAllowed('echo "hello" && rm -rf /');
- expect(result.allowed).toBe(false);
- expect(result.reason).toBe(
- "Command 'rm -rf /' is blocked by configuration",
- );
- });
-
- it('should block a command if its prefix is on the blocklist, even if the command itself is on the allowlist', async () => {
- const config = {
- getCoreTools: () => ['run_shell_command(git push)'],
- getExcludeTools: () => ['run_shell_command(git)'],
- } as unknown as Config;
- const shellTool = new ShellTool(config);
- const result = shellTool.isCommandAllowed('git push');
- expect(result.allowed).toBe(false);
- expect(result.reason).toBe(
- "Command 'git push' is blocked by configuration",
- );
- });
-
- it('should be case-sensitive in its matching', async () => {
- const config = {
- getCoreTools: () => ['run_shell_command(echo)'],
- getExcludeTools: () => [],
- } as unknown as Config;
- const shellTool = new ShellTool(config);
- const result = shellTool.isCommandAllowed('ECHO "hello"');
- expect(result.allowed).toBe(false);
- expect(result.reason).toBe(
- 'Command \'ECHO "hello"\' is not in the allowed commands list',
- );
- });
-
- it('should correctly handle commands with extra whitespace around chaining operators', async () => {
- const config = {
- getCoreTools: () => ['run_shell_command(ls -l)'],
- getExcludeTools: () => ['run_shell_command(rm)'],
- } as unknown as Config;
- const shellTool = new ShellTool(config);
- const result = shellTool.isCommandAllowed('ls -l ; rm -rf /');
- expect(result.allowed).toBe(false);
- expect(result.reason).toBe(
- "Command 'rm -rf /' is blocked by configuration",
- );
- });
-
- it('should allow a chained command if all parts are allowed', async () => {
- const config = {
- getCoreTools: () => [
- 'run_shell_command(echo)',
- 'run_shell_command(ls -l)',
- ],
- getExcludeTools: () => [],
- } as unknown as Config;
- const shellTool = new ShellTool(config);
- const result = shellTool.isCommandAllowed('echo "hello" && ls -l');
- expect(result.allowed).toBe(true);
- });
-
- it('should allow a command with command substitution using backticks', async () => {
- const config = {
- getCoreTools: () => ['run_shell_command(echo)'],
- getExcludeTools: () => [],
- } as unknown as Config;
- const shellTool = new ShellTool(config);
- const result = shellTool.isCommandAllowed('echo `rm -rf /`');
- expect(result.allowed).toBe(true);
- });
-
- it('should block a command with command substitution using $()', async () => {
- const config = {
- getCoreTools: () => ['run_shell_command(echo)'],
- getExcludeTools: () => [],
- } as unknown as Config;
- const shellTool = new ShellTool(config);
- const result = shellTool.isCommandAllowed('echo $(rm -rf /)');
- expect(result.allowed).toBe(false);
- expect(result.reason).toBe(
- 'Command substitution using $() is not allowed for security reasons',
- );
- });
-
- it('should allow a command with I/O redirection', async () => {
- const config = {
- getCoreTools: () => ['run_shell_command(echo)'],
- getExcludeTools: () => [],
- } as unknown as Config;
- const shellTool = new ShellTool(config);
- const result = shellTool.isCommandAllowed('echo "hello" > file.txt');
- expect(result.allowed).toBe(true);
- });
-
- it('should not allow a command that is chained with a double pipe', async () => {
- const config = {
- getCoreTools: () => ['run_shell_command(gh issue list)'],
- getExcludeTools: () => [],
- } as unknown as Config;
- const shellTool = new ShellTool(config);
- const result = shellTool.isCommandAllowed('gh issue list || rm -rf /');
- expect(result.allowed).toBe(false);
- expect(result.reason).toBe(
- "Command 'rm -rf /' is not in the allowed commands list",
- );
- });
-});
-
describe('ShellTool Bug Reproduction', () => {
let shellTool: ShellTool;
let config: Config;
@@ -427,6 +39,7 @@ describe('ShellTool Bug Reproduction', () => {
const result = await shellTool.execute(
{ command: 'echo hello' },
abortSignal,
+ () => {},
);
expect(result.returnDisplay).toBe('hello' + os.EOL);
@@ -453,6 +66,7 @@ describe('ShellTool Bug Reproduction', () => {
const result = await shellTool.execute(
{ command: 'echo hello' },
abortSignal,
+ () => {},
);
expect(result.returnDisplay).toBe('hello' + os.EOL);
@@ -478,7 +92,7 @@ describe('ShellTool Bug Reproduction', () => {
.mockResolvedValue('summarized output');
const abortSignal = new AbortController().signal;
- await shellTool.execute({ command: 'echo hello' }, abortSignal);
+ await shellTool.execute({ command: 'echo "hello"' }, abortSignal, () => {});
expect(summarizeSpy).toHaveBeenCalledWith(
expect.any(String),
@@ -506,7 +120,7 @@ describe('ShellTool Bug Reproduction', () => {
.mockResolvedValue('summarized output');
const abortSignal = new AbortController().signal;
- await shellTool.execute({ command: 'echo hello' }, abortSignal);
+ await shellTool.execute({ command: 'echo "hello"' }, abortSignal, () => {});
expect(summarizeSpy).toHaveBeenCalledWith(
expect.any(String),
@@ -528,10 +142,28 @@ describe('ShellTool Bug Reproduction', () => {
shellTool = new ShellTool(config);
const abortSignal = new AbortController().signal;
- const command =
- os.platform() === 'win32' ? 'echo %GEMINI_CLI%' : 'echo $GEMINI_CLI';
- const result = await shellTool.execute({ command }, abortSignal);
+ const result = await shellTool.execute(
+ { command: 'echo "$GEMINI_CLI"' },
+ abortSignal,
+ () => {},
+ );
expect(result.returnDisplay).toBe('1' + os.EOL);
});
});
+
+describe('shouldConfirmExecute', () => {
+ it('should de-duplicate command roots before asking for confirmation', async () => {
+ const shellTool = new ShellTool({
+ getCoreTools: () => ['run_shell_command'],
+ getExcludeTools: () => [],
+ } as unknown as Config);
+ const result = (await shellTool.shouldConfirmExecute(
+ {
+ command: 'git status && git log',
+ },
+ new AbortController().signal,
+ )) as ToolExecuteConfirmationDetails;
+ expect(result.rootCommand).toEqual('git');
+ });
+});
diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts
index 44df5ece..d90ae678 100644
--- a/packages/core/src/tools/shell.ts
+++ b/packages/core/src/tools/shell.ts
@@ -21,6 +21,11 @@ import { Type } from '@google/genai';
import { SchemaValidator } from '../utils/schemaValidator.js';
import { getErrorMessage } from '../utils/errors.js';
import stripAnsi from 'strip-ansi';
+import {
+ getCommandRoots,
+ isCommandAllowed,
+ stripShellWrapper,
+} from '../utils/shell-utils.js';
export interface ShellToolParams {
command: string;
@@ -34,7 +39,7 @@ const OUTPUT_UPDATE_INTERVAL_MS = 1000;
export class ShellTool extends BaseTool<ShellToolParams, ToolResult> {
static Name: string = 'run_shell_command';
- private whitelist: Set<string> = new Set();
+ private allowlist: Set<string> = new Set();
constructor(private readonly config: Config) {
super(
@@ -42,17 +47,17 @@ export class ShellTool extends BaseTool<ShellToolParams, ToolResult> {
'Shell',
`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\`.
-The following information is returned:
+ The following information is returned:
-Command: Executed command.
-Directory: Directory (relative to project root) where command was executed, or \`(root)\`.
-Stdout: Output on stdout stream. Can be \`(empty)\` or partial on error and for any unwaited background processes.
-Stderr: Output on stderr stream. Can be \`(empty)\` or partial on error and for any unwaited background processes.
-Error: Error or \`(none)\` if no error was reported for the subprocess.
-Exit Code: Exit code or \`(none)\` if terminated by signal.
-Signal: Signal number or \`(none)\` if no signal was received.
-Background PIDs: List of background processes started or \`(none)\`.
-Process Group PGID: Process group started or \`(none)\``,
+ Command: Executed command.
+ Directory: Directory (relative to project root) where command was executed, or \`(root)\`.
+ Stdout: Output on stdout stream. Can be \`(empty)\` or partial on error and for any unwaited background processes.
+ Stderr: Output on stderr stream. Can be \`(empty)\` or partial on error and for any unwaited background processes.
+ Error: Error or \`(none)\` if no error was reported for the subprocess.
+ Exit Code: Exit code or \`(none)\` if terminated by signal.
+ Signal: Signal number or \`(none)\` if no signal was received.
+ Background PIDs: List of background processes started or \`(none)\`.
+ Process Group PGID: Process group started or \`(none)\``,
Icon.Terminal,
{
type: Type.OBJECT,
@@ -93,131 +98,8 @@ Process Group PGID: Process group started or \`(none)\``,
return description;
}
- /**
- * Extracts the root command from a given shell command string.
- * This is used to identify the base command for permission checks.
- *
- * @param command The shell command string to parse
- * @returns The root command name, or undefined if it cannot be determined
- * @example getCommandRoot("ls -la /tmp") returns "ls"
- * @example getCommandRoot("git status && npm test") returns "git"
- */
- getCommandRoot(command: string): string | undefined {
- return command
- .trim() // remove leading and trailing whitespace
- .replace(/[{}()]/g, '') // remove all grouping operators
- .split(/[\s;&|]+/)[0] // split on any whitespace or separator or chaining operators and take first part
- ?.split(/[/\\]/) // split on any path separators (or return undefined if previous line was undefined)
- .pop(); // take last part and return command root (or undefined if previous line was empty)
- }
-
- /**
- * Determines whether a given shell command is allowed to execute based on
- * the tool's configuration including allowlists and blocklists.
- *
- * @param command The shell command string to validate
- * @returns An object with 'allowed' boolean and optional 'reason' string if not allowed
- */
- isCommandAllowed(command: string): { allowed: boolean; reason?: string } {
- // 0. Disallow command substitution
- if (command.includes('$(')) {
- return {
- allowed: false,
- reason:
- 'Command substitution using $() is not allowed for security reasons',
- };
- }
-
- const SHELL_TOOL_NAMES = [ShellTool.name, ShellTool.Name];
-
- const normalize = (cmd: string): string => cmd.trim().replace(/\s+/g, ' ');
-
- /**
- * Checks if a command string starts with a given prefix, ensuring it's a
- * whole word match (i.e., followed by a space or it's an exact match).
- * e.g., `isPrefixedBy('npm install', 'npm')` -> true
- * e.g., `isPrefixedBy('npm', 'npm')` -> true
- * e.g., `isPrefixedBy('npminstall', 'npm')` -> false
- */
- const isPrefixedBy = (cmd: string, prefix: string): boolean => {
- if (!cmd.startsWith(prefix)) {
- return false;
- }
- return cmd.length === prefix.length || cmd[prefix.length] === ' ';
- };
-
- /**
- * Extracts and normalizes shell commands from a list of tool strings.
- * e.g., 'ShellTool("ls -l")' becomes 'ls -l'
- */
- const extractCommands = (tools: string[]): string[] =>
- tools.flatMap((tool) => {
- for (const toolName of SHELL_TOOL_NAMES) {
- if (tool.startsWith(`${toolName}(`) && tool.endsWith(')')) {
- return [normalize(tool.slice(toolName.length + 1, -1))];
- }
- }
- return [];
- });
-
- const coreTools = this.config.getCoreTools() || [];
- const excludeTools = this.config.getExcludeTools() || [];
-
- // 1. Check if the shell tool is globally disabled.
- if (SHELL_TOOL_NAMES.some((name) => excludeTools.includes(name))) {
- return {
- allowed: false,
- reason: 'Shell tool is globally disabled in configuration',
- };
- }
-
- const blockedCommands = new Set(extractCommands(excludeTools));
- const allowedCommands = new Set(extractCommands(coreTools));
-
- const hasSpecificAllowedCommands = allowedCommands.size > 0;
- const isWildcardAllowed = SHELL_TOOL_NAMES.some((name) =>
- coreTools.includes(name),
- );
-
- const commandsToValidate = command.split(/&&|\|\||\||;/).map(normalize);
-
- const blockedCommandsArr = [...blockedCommands];
-
- for (const cmd of commandsToValidate) {
- // 2. Check if the command is on the blocklist.
- const isBlocked = blockedCommandsArr.some((blocked) =>
- isPrefixedBy(cmd, blocked),
- );
- if (isBlocked) {
- return {
- allowed: false,
- reason: `Command '${cmd}' is blocked by configuration`,
- };
- }
-
- // 3. If in strict allow-list mode, check if the command is permitted.
- const isStrictAllowlist =
- hasSpecificAllowedCommands && !isWildcardAllowed;
- const allowedCommandsArr = [...allowedCommands];
- if (isStrictAllowlist) {
- const isAllowed = allowedCommandsArr.some((allowed) =>
- isPrefixedBy(cmd, allowed),
- );
- if (!isAllowed) {
- return {
- allowed: false,
- reason: `Command '${cmd}' is not in the allowed commands list`,
- };
- }
- }
- }
-
- // 4. If all checks pass, the command is allowed.
- return { allowed: true };
- }
-
validateToolParams(params: ShellToolParams): string | null {
- const commandCheck = this.isCommandAllowed(params.command);
+ const commandCheck = isCommandAllowed(params.command, this.config);
if (!commandCheck.allowed) {
if (!commandCheck.reason) {
console.error(
@@ -234,7 +116,7 @@ Process Group PGID: Process group started or \`(none)\``,
if (!params.command.trim()) {
return 'Command cannot be empty.';
}
- if (!this.getCommandRoot(params.command)) {
+ if (getCommandRoots(params.command).length === 0) {
return 'Could not identify command root to obtain permission from user.';
}
if (params.directory) {
@@ -259,18 +141,25 @@ Process Group PGID: Process group started or \`(none)\``,
if (this.validateToolParams(params)) {
return false; // skip confirmation, execute call will fail immediately
}
- const rootCommand = this.getCommandRoot(params.command)!; // must be non-empty string post-validation
- if (this.whitelist.has(rootCommand)) {
+
+ const command = stripShellWrapper(params.command);
+ const rootCommands = [...new Set(getCommandRoots(command))];
+ const commandsToConfirm = rootCommands.filter(
+ (command) => !this.allowlist.has(command),
+ );
+
+ if (commandsToConfirm.length === 0) {
return false; // already approved and whitelisted
}
+
const confirmationDetails: ToolExecuteConfirmationDetails = {
type: 'exec',
title: 'Confirm Shell Command',
command: params.command,
- rootCommand,
+ rootCommand: commandsToConfirm.join(', '),
onConfirm: async (outcome: ToolConfirmationOutcome) => {
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
- this.whitelist.add(rootCommand);
+ commandsToConfirm.forEach((command) => this.allowlist.add(command));
}
},
};
@@ -279,21 +168,22 @@ Process Group PGID: Process group started or \`(none)\``,
async execute(
params: ShellToolParams,
- abortSignal: AbortSignal,
- updateOutput?: (chunk: string) => void,
+ signal: AbortSignal,
+ updateOutput?: (output: string) => void,
): Promise<ToolResult> {
- const validationError = this.validateToolParams(params);
+ const strippedCommand = stripShellWrapper(params.command);
+ const validationError = this.validateToolParams({
+ ...params,
+ command: strippedCommand,
+ });
if (validationError) {
return {
- llmContent: [
- `Command rejected: ${params.command}`,
- `Reason: ${validationError}`,
- ].join('\n'),
- returnDisplay: `Error: ${validationError}`,
+ llmContent: validationError,
+ returnDisplay: validationError,
};
}
- if (abortSignal.aborted) {
+ if (signal.aborted) {
return {
llmContent: 'Command was cancelled by user before it could start.',
returnDisplay: 'Command cancelled by user.',
@@ -307,18 +197,18 @@ Process Group PGID: Process group started or \`(none)\``,
const tempFilePath = path.join(os.tmpdir(), tempFileName);
// pgrep is not available on Windows, so we can't get background PIDs
- const command = isWindows
- ? params.command
+ const commandToExecute = isWindows
+ ? strippedCommand
: (() => {
// wrap command to append subprocess pids (via pgrep) to temporary file
- let command = params.command.trim();
+ let command = strippedCommand.trim();
if (!command.endsWith('&')) command += ';';
return `{ ${command} }; __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; exit $__code;`;
})();
// spawn command in specified directory (or project root if not specified)
const shell = isWindows
- ? spawn('cmd.exe', ['/c', command], {
+ ? spawn('cmd.exe', ['/c', commandToExecute], {
stdio: ['ignore', 'pipe', 'pipe'],
// detached: true, // ensure subprocess starts its own process group (esp. in Linux)
cwd: path.resolve(this.config.getTargetDir(), params.directory || ''),
@@ -327,7 +217,7 @@ Process Group PGID: Process group started or \`(none)\``,
GEMINI_CLI: '1',
},
})
- : spawn('bash', ['-c', command], {
+ : spawn('bash', ['-c', commandToExecute], {
stdio: ['ignore', 'pipe', 'pipe'],
detached: true, // ensure subprocess starts its own process group (esp. in Linux)
cwd: path.resolve(this.config.getTargetDir(), params.directory || ''),
@@ -377,7 +267,7 @@ Process Group PGID: Process group started or \`(none)\``,
shell.on('error', (err: Error) => {
error = err;
// remove wrapper from user's command in error message
- error.message = error.message.replace(command, params.command);
+ error.message = error.message.replace(commandToExecute, params.command);
});
let code: number | null = null;
@@ -419,13 +309,13 @@ Process Group PGID: Process group started or \`(none)\``,
}
}
};
- abortSignal.addEventListener('abort', abortHandler);
+ signal.addEventListener('abort', abortHandler);
// wait for the shell to exit
try {
await new Promise((resolve) => shell.on('exit', resolve));
} finally {
- abortSignal.removeEventListener('abort', abortHandler);
+ signal.removeEventListener('abort', abortHandler);
}
// parse pids (pgrep output) from temporary file and remove it
@@ -448,14 +338,14 @@ Process Group PGID: Process group started or \`(none)\``,
}
fs.unlinkSync(tempFilePath);
} else {
- if (!abortSignal.aborted) {
+ if (!signal.aborted) {
console.error('missing pgrep output');
}
}
}
let llmContent = '';
- if (abortSignal.aborted) {
+ if (signal.aborted) {
llmContent = 'Command was cancelled by user before it could complete.';
if (output.trim()) {
llmContent += ` Below is the output (on stdout and stderr) before it was cancelled:\n${output}`;
@@ -484,7 +374,7 @@ Process Group PGID: Process group started or \`(none)\``,
returnDisplayMessage = output;
} else {
// Output is empty, let's provide a reason if the command failed or was cancelled
- if (abortSignal.aborted) {
+ if (signal.aborted) {
returnDisplayMessage = 'Command cancelled by user.';
} else if (processSignal) {
returnDisplayMessage = `Command terminated by signal: ${processSignal}`;
@@ -504,7 +394,7 @@ Process Group PGID: Process group started or \`(none)\``,
const summary = await summarizeToolOutput(
llmContent,
this.config.getGeminiClient(),
- abortSignal,
+ signal,
summarizeConfig[this.name].tokenBudget,
);
return {
diff --git a/packages/core/src/utils/shell-utils.test.ts b/packages/core/src/utils/shell-utils.test.ts
new file mode 100644
index 00000000..a3a2b6c2
--- /dev/null
+++ b/packages/core/src/utils/shell-utils.test.ts
@@ -0,0 +1,583 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { expect, describe, it, beforeEach } from 'vitest';
+import {
+ getCommandRoots,
+ isCommandAllowed,
+ stripShellWrapper,
+} from './shell-utils.js';
+import { Config } from '../config/config.js';
+
+describe('isCommandAllowed', () => {
+ let config: Config;
+
+ beforeEach(() => {
+ config = {
+ getCoreTools: () => undefined,
+ getExcludeTools: () => undefined,
+ } as unknown as Config;
+ });
+
+ it('should allow a command if no restrictions are provided', async () => {
+ const result = isCommandAllowed('ls -l', config);
+ expect(result.allowed).toBe(true);
+ });
+
+ it('should allow a command if it is in the allowed list', async () => {
+ config = {
+ getCoreTools: () => ['ShellTool(ls -l)'],
+ getExcludeTools: () => undefined,
+ } as unknown as Config;
+ const result = isCommandAllowed('ls -l', config);
+ expect(result.allowed).toBe(true);
+ });
+
+ it('should block a command if it is not in the allowed list', async () => {
+ config = {
+ getCoreTools: () => ['ShellTool(ls -l)'],
+ getExcludeTools: () => undefined,
+ } as unknown as Config;
+ const result = isCommandAllowed('rm -rf /', config);
+ expect(result.allowed).toBe(false);
+ expect(result.reason).toBe(
+ "Command 'rm -rf /' is not in the allowed commands list",
+ );
+ });
+
+ it('should block a command if it is in the blocked list', async () => {
+ config = {
+ getCoreTools: () => undefined,
+ getExcludeTools: () => ['ShellTool(rm -rf /)'],
+ } as unknown as Config;
+ const result = isCommandAllowed('rm -rf /', config);
+ expect(result.allowed).toBe(false);
+ expect(result.reason).toBe(
+ "Command 'rm -rf /' is blocked by configuration",
+ );
+ });
+
+ it('should allow a command if it is not in the blocked list', async () => {
+ config = {
+ getCoreTools: () => undefined,
+ getExcludeTools: () => ['ShellTool(rm -rf /)'],
+ } as unknown as Config;
+ const result = isCommandAllowed('ls -l', config);
+ expect(result.allowed).toBe(true);
+ });
+
+ it('should block a command if it is in both the allowed and blocked lists', async () => {
+ config = {
+ getCoreTools: () => ['ShellTool(rm -rf /)'],
+ getExcludeTools: () => ['ShellTool(rm -rf /)'],
+ } as unknown as Config;
+ const result = isCommandAllowed('rm -rf /', config);
+ expect(result.allowed).toBe(false);
+ expect(result.reason).toBe(
+ "Command 'rm -rf /' is blocked by configuration",
+ );
+ });
+
+ it('should allow any command when ShellTool is in coreTools without specific commands', async () => {
+ config = {
+ getCoreTools: () => ['ShellTool'],
+ getExcludeTools: () => [],
+ } as unknown as Config;
+ const result = isCommandAllowed('any command', config);
+ expect(result.allowed).toBe(true);
+ });
+
+ it('should block any command when ShellTool is in excludeTools without specific commands', async () => {
+ config = {
+ getCoreTools: () => [],
+ getExcludeTools: () => ['ShellTool'],
+ } as unknown as Config;
+ const result = isCommandAllowed('any command', config);
+ expect(result.allowed).toBe(false);
+ expect(result.reason).toBe(
+ 'Shell tool is globally disabled in configuration',
+ );
+ });
+
+ it('should allow a command if it is in the allowed list using the public-facing name', async () => {
+ config = {
+ getCoreTools: () => ['run_shell_command(ls -l)'],
+ getExcludeTools: () => undefined,
+ } as unknown as Config;
+ const result = isCommandAllowed('ls -l', config);
+ expect(result.allowed).toBe(true);
+ });
+
+ it('should block a command if it is in the blocked list using the public-facing name', async () => {
+ config = {
+ getCoreTools: () => undefined,
+ getExcludeTools: () => ['run_shell_command(rm -rf /)'],
+ } as unknown as Config;
+ const result = isCommandAllowed('rm -rf /', config);
+ expect(result.allowed).toBe(false);
+ expect(result.reason).toBe(
+ "Command 'rm -rf /' is blocked by configuration",
+ );
+ });
+
+ it('should block any command when ShellTool is in excludeTools using the public-facing name', async () => {
+ config = {
+ getCoreTools: () => [],
+ getExcludeTools: () => ['run_shell_command'],
+ } as unknown as Config;
+ const result = isCommandAllowed('any command', config);
+ expect(result.allowed).toBe(false);
+ expect(result.reason).toBe(
+ 'Shell tool is globally disabled in configuration',
+ );
+ });
+
+ it('should block any command if coreTools contains an empty ShellTool command list using the public-facing name', async () => {
+ config = {
+ getCoreTools: () => ['run_shell_command()'],
+ getExcludeTools: () => [],
+ } as unknown as Config;
+ const result = isCommandAllowed('any command', config);
+ expect(result.allowed).toBe(false);
+ expect(result.reason).toBe(
+ "Command 'any command' is not in the allowed commands list",
+ );
+ });
+
+ it('should block any command if coreTools contains an empty ShellTool command list', async () => {
+ config = {
+ getCoreTools: () => ['ShellTool()'],
+ getExcludeTools: () => [],
+ } as unknown as Config;
+ const result = isCommandAllowed('any command', config);
+ expect(result.allowed).toBe(false);
+ expect(result.reason).toBe(
+ "Command 'any command' is not in the allowed commands list",
+ );
+ });
+
+ it('should block a command with extra whitespace if it is in the blocked list', async () => {
+ config = {
+ getCoreTools: () => undefined,
+ getExcludeTools: () => ['ShellTool(rm -rf /)'],
+ } as unknown as Config;
+ const result = isCommandAllowed(' rm -rf / ', config);
+ expect(result.allowed).toBe(false);
+ expect(result.reason).toBe(
+ "Command 'rm -rf /' is blocked by configuration",
+ );
+ });
+
+ it('should allow any command when ShellTool is in present with specific commands', async () => {
+ config = {
+ getCoreTools: () => ['ShellTool', 'ShellTool(ls)'],
+ getExcludeTools: () => [],
+ } as unknown as Config;
+ const result = isCommandAllowed('any command', config);
+ expect(result.allowed).toBe(true);
+ });
+
+ it('should block a command on the blocklist even with a wildcard allow', async () => {
+ config = {
+ getCoreTools: () => ['ShellTool'],
+ getExcludeTools: () => ['ShellTool(rm -rf /)'],
+ } as unknown as Config;
+ const result = isCommandAllowed('rm -rf /', config);
+ expect(result.allowed).toBe(false);
+ expect(result.reason).toBe(
+ "Command 'rm -rf /' is blocked by configuration",
+ );
+ });
+
+ it('should allow a command that starts with an allowed command prefix', async () => {
+ config = {
+ getCoreTools: () => ['ShellTool(gh issue edit)'],
+ getExcludeTools: () => [],
+ } as unknown as Config;
+ const result = isCommandAllowed(
+ 'gh issue edit 1 --add-label "kind/feature"',
+ config,
+ );
+ expect(result.allowed).toBe(true);
+ });
+
+ it('should allow a command that starts with an allowed command prefix using the public-facing name', async () => {
+ config = {
+ getCoreTools: () => ['run_shell_command(gh issue edit)'],
+ getExcludeTools: () => [],
+ } as unknown as Config;
+ const result = isCommandAllowed(
+ 'gh issue edit 1 --add-label "kind/feature"',
+ config,
+ );
+ expect(result.allowed).toBe(true);
+ });
+
+ it('should not allow a command that starts with an allowed command prefix but is chained with another command', async () => {
+ config = {
+ getCoreTools: () => ['run_shell_command(gh issue edit)'],
+ getExcludeTools: () => [],
+ } as unknown as Config;
+ const result = isCommandAllowed('gh issue edit&&rm -rf /', config);
+ expect(result.allowed).toBe(false);
+ expect(result.reason).toBe(
+ "Command 'rm -rf /' is not in the allowed commands list",
+ );
+ });
+
+ it('should not allow a command that is a prefix of an allowed command', async () => {
+ config = {
+ getCoreTools: () => ['run_shell_command(gh issue edit)'],
+ getExcludeTools: () => [],
+ } as unknown as Config;
+ const result = isCommandAllowed('gh issue', config);
+ expect(result.allowed).toBe(false);
+ expect(result.reason).toBe(
+ "Command 'gh issue' is not in the allowed commands list",
+ );
+ });
+
+ it('should not allow a command that is a prefix of a blocked command', async () => {
+ config = {
+ getCoreTools: () => [],
+ getExcludeTools: () => ['run_shell_command(gh issue edit)'],
+ } as unknown as Config;
+ const result = isCommandAllowed('gh issue', config);
+ expect(result.allowed).toBe(true);
+ });
+
+ it('should not allow a command that is chained with a pipe', async () => {
+ config = {
+ getCoreTools: () => ['run_shell_command(gh issue list)'],
+ getExcludeTools: () => [],
+ } as unknown as Config;
+ const result = isCommandAllowed('gh issue list | rm -rf /', config);
+ expect(result.allowed).toBe(false);
+ expect(result.reason).toBe(
+ "Command 'rm -rf /' is not in the allowed commands list",
+ );
+ });
+
+ it('should not allow a command that is chained with a semicolon', async () => {
+ config = {
+ getCoreTools: () => ['run_shell_command(gh issue list)'],
+ getExcludeTools: () => [],
+ } as unknown as Config;
+ const result = isCommandAllowed('gh issue list; rm -rf /', config);
+ expect(result.allowed).toBe(false);
+ expect(result.reason).toBe(
+ "Command 'rm -rf /' is not in the allowed commands list",
+ );
+ });
+
+ it('should block a chained command if any part is blocked', async () => {
+ config = {
+ getCoreTools: () => ['run_shell_command(echo "hello")'],
+ getExcludeTools: () => ['run_shell_command(rm)'],
+ } as unknown as Config;
+ const result = isCommandAllowed('echo "hello" && rm -rf /', config);
+ expect(result.allowed).toBe(false);
+ expect(result.reason).toBe(
+ "Command 'rm -rf /' is blocked by configuration",
+ );
+ });
+
+ it('should block a command if its prefix is on the blocklist, even if the command itself is on the allowlist', async () => {
+ config = {
+ getCoreTools: () => ['run_shell_command(git push)'],
+ getExcludeTools: () => ['run_shell_command(git)'],
+ } as unknown as Config;
+ const result = isCommandAllowed('git push', config);
+ expect(result.allowed).toBe(false);
+ expect(result.reason).toBe(
+ "Command 'git push' is blocked by configuration",
+ );
+ });
+
+ it('should be case-sensitive in its matching', async () => {
+ config = {
+ getCoreTools: () => ['run_shell_command(echo)'],
+ getExcludeTools: () => [],
+ } as unknown as Config;
+ const result = isCommandAllowed('ECHO "hello"', config);
+ expect(result.allowed).toBe(false);
+ expect(result.reason).toBe(
+ 'Command \'ECHO "hello"\' is not in the allowed commands list',
+ );
+ });
+
+ it('should correctly handle commands with extra whitespace around chaining operators', async () => {
+ config = {
+ getCoreTools: () => ['run_shell_command(ls -l)'],
+ getExcludeTools: () => ['run_shell_command(rm)'],
+ } as unknown as Config;
+ const result = isCommandAllowed('ls -l ; rm -rf /', config);
+ expect(result.allowed).toBe(false);
+ expect(result.reason).toBe(
+ "Command 'rm -rf /' is blocked by configuration",
+ );
+ });
+
+ it('should allow a chained command if all parts are allowed', async () => {
+ config = {
+ getCoreTools: () => [
+ 'run_shell_command(echo)',
+ 'run_shell_command(ls -l)',
+ ],
+ getExcludeTools: () => [],
+ } as unknown as Config;
+ const result = isCommandAllowed('echo "hello" && ls -l', config);
+ expect(result.allowed).toBe(true);
+ });
+
+ it('should block a command with command substitution using backticks', async () => {
+ config = {
+ getCoreTools: () => ['run_shell_command(echo)'],
+ getExcludeTools: () => [],
+ } as unknown as Config;
+ const result = isCommandAllowed('echo `rm -rf /`', config);
+ expect(result.allowed).toBe(false);
+ expect(result.reason).toBe(
+ 'Command substitution using $(), <(), or >() is not allowed for security reasons',
+ );
+ });
+
+ it('should block a command with command substitution using $()', async () => {
+ config = {
+ getCoreTools: () => ['run_shell_command(echo)'],
+ getExcludeTools: () => [],
+ } as unknown as Config;
+ const result = isCommandAllowed('echo $(rm -rf /)', config);
+ expect(result.allowed).toBe(false);
+ expect(result.reason).toBe(
+ 'Command substitution using $(), <(), or >() is not allowed for security reasons',
+ );
+ });
+
+ it('should block a command with process substitution using <()', async () => {
+ config = {
+ getCoreTools: () => ['run_shell_command(diff)'],
+ getExcludeTools: () => [],
+ } as unknown as Config;
+ const result = isCommandAllowed('diff <(ls) <(ls -a)', config);
+ expect(result.allowed).toBe(false);
+ expect(result.reason).toBe(
+ 'Command substitution using $(), <(), or >() is not allowed for security reasons',
+ );
+ });
+
+ it('should allow a command with I/O redirection', async () => {
+ config = {
+ getCoreTools: () => ['run_shell_command(echo)'],
+ getExcludeTools: () => [],
+ } as unknown as Config;
+ const result = isCommandAllowed('echo "hello" > file.txt', config);
+ expect(result.allowed).toBe(true);
+ });
+
+ it('should not allow a command that is chained with a double pipe', async () => {
+ config = {
+ getCoreTools: () => ['run_shell_command(gh issue list)'],
+ getExcludeTools: () => [],
+ } as unknown as Config;
+ const result = isCommandAllowed('gh issue list || rm -rf /', config);
+ expect(result.allowed).toBe(false);
+ expect(result.reason).toBe(
+ "Command 'rm -rf /' is not in the allowed commands list",
+ );
+ });
+});
+
+describe('getCommandRoots', () => {
+ it('should return a single command', () => {
+ const result = getCommandRoots('ls -l');
+ expect(result).toEqual(['ls']);
+ });
+
+ it('should return multiple commands', () => {
+ const result = getCommandRoots('ls -l | grep "test"');
+ expect(result).toEqual(['ls', 'grep']);
+ });
+
+ it('should handle multiple commands with &&', () => {
+ const result = getCommandRoots('npm run build && npm test');
+ expect(result).toEqual(['npm', 'npm']);
+ });
+
+ it('should handle multiple commands with ;', () => {
+ const result = getCommandRoots('echo "hello"; echo "world"');
+ expect(result).toEqual(['echo', 'echo']);
+ });
+
+ it('should handle a mix of operators', () => {
+ const result = getCommandRoots(
+ 'cat package.json | grep "version" && echo "done"',
+ );
+ expect(result).toEqual(['cat', 'grep', 'echo']);
+ });
+
+ it('should handle commands with paths', () => {
+ const result = getCommandRoots('/usr/local/bin/node script.js');
+ expect(result).toEqual(['node']);
+ });
+
+ it('should return an empty array for an empty string', () => {
+ const result = getCommandRoots('');
+ expect(result).toEqual([]);
+ });
+});
+
+describe('stripShellWrapper', () => {
+ it('should strip sh -c from the beginning of the command', () => {
+ const result = stripShellWrapper('sh -c "ls -l"');
+ expect(result).toEqual('ls -l');
+ });
+
+ it('should strip bash -c from the beginning of the command', () => {
+ const result = stripShellWrapper('bash -c "ls -l"');
+ expect(result).toEqual('ls -l');
+ });
+
+ it('should strip zsh -c from the beginning of the command', () => {
+ const result = stripShellWrapper('zsh -c "ls -l"');
+ expect(result).toEqual('ls -l');
+ });
+
+ it('should not strip anything if the command does not start with a shell wrapper', () => {
+ const result = stripShellWrapper('ls -l');
+ expect(result).toEqual('ls -l');
+ });
+
+ it('should handle extra whitespace', () => {
+ const result = stripShellWrapper(' sh -c "ls -l" ');
+ expect(result).toEqual('ls -l');
+ });
+
+ it('should handle commands without quotes', () => {
+ const result = stripShellWrapper('sh -c ls -l');
+ expect(result).toEqual('ls -l');
+ });
+
+ it('should strip cmd.exe /c from the beginning of the command', () => {
+ const result = stripShellWrapper('cmd.exe /c "dir"');
+ expect(result).toEqual('dir');
+ });
+});
+
+describe('getCommandRoots', () => {
+ it('should handle multiple commands with &', () => {
+ const result = getCommandRoots('echo "hello" & echo "world"');
+ expect(result).toEqual(['echo', 'echo']);
+ });
+});
+
+describe('command substitution', () => {
+ let config: Config;
+
+ beforeEach(() => {
+ config = {
+ getCoreTools: () => ['run_shell_command(echo)', 'run_shell_command(gh)'],
+ getExcludeTools: () => [],
+ } as unknown as Config;
+ });
+
+ it('should block unquoted command substitution `$(...)`', () => {
+ const result = isCommandAllowed('echo $(pwd)', config);
+ expect(result.allowed).toBe(false);
+ });
+
+ it('should block unquoted command substitution `<(...)`', () => {
+ const result = isCommandAllowed('echo <(pwd)', config);
+ expect(result.allowed).toBe(false);
+ });
+
+ it('should allow command substitution in single quotes', () => {
+ const result = isCommandAllowed("echo '$(pwd)'", config);
+ expect(result.allowed).toBe(true);
+ });
+
+ it('should allow backticks in single quotes', () => {
+ const result = isCommandAllowed("echo '`rm -rf /`'", config);
+ expect(result.allowed).toBe(true);
+ });
+
+ it('should block command substitution in double quotes', () => {
+ const result = isCommandAllowed('echo "$(pwd)"', config);
+ expect(result.allowed).toBe(false);
+ });
+
+ it('should allow escaped command substitution', () => {
+ const result = isCommandAllowed('echo \\$(pwd)', config);
+ expect(result.allowed).toBe(true);
+ });
+
+ it('should allow complex commands with quoted substitution-like patterns', () => {
+ const command =
+ "gh pr comment 4795 --body 'This is a test comment with $(pwd) style text'";
+ const result = isCommandAllowed(command, config);
+ expect(result.allowed).toBe(true);
+ });
+
+ it('should block complex commands with unquoted substitution-like patterns', () => {
+ const command =
+ 'gh pr comment 4795 --body "This is a test comment with $(pwd) style text"';
+ const result = isCommandAllowed(command, config);
+ expect(result.allowed).toBe(false);
+ });
+
+ it('should allow a command with markdown content using proper quoting', () => {
+ // Simple test with safe content in single quotes
+ const result = isCommandAllowed(
+ "gh pr comment 4795 --body 'This is safe markdown content'",
+ config,
+ );
+ expect(result.allowed).toBe(true);
+ });
+});
+
+describe('getCommandRoots with quote handling', () => {
+ it('should correctly parse a simple command', () => {
+ const result = getCommandRoots('git status');
+ expect(result).toEqual(['git']);
+ });
+
+ it('should correctly parse a command with a quoted argument', () => {
+ const result = getCommandRoots('git commit -m "feat: new feature"');
+ expect(result).toEqual(['git']);
+ });
+
+ it('should correctly parse a command with single quotes', () => {
+ const result = getCommandRoots("echo 'hello world'");
+ expect(result).toEqual(['echo']);
+ });
+
+ it('should correctly parse a chained command with quotes', () => {
+ const result = getCommandRoots('echo "hello" && git status');
+ expect(result).toEqual(['echo', 'git']);
+ });
+
+ it('should correctly parse a complex chained command', () => {
+ const result = getCommandRoots(
+ 'git commit -m "feat: new feature" && echo "done"',
+ );
+ expect(result).toEqual(['git', 'echo']);
+ });
+
+ it('should handle escaped quotes', () => {
+ const result = getCommandRoots('echo "this is a "quote""');
+ expect(result).toEqual(['echo']);
+ });
+
+ it('should handle commands with no spaces', () => {
+ const result = getCommandRoots('command');
+ expect(result).toEqual(['command']);
+ });
+
+ it('should handle multiple separators', () => {
+ const result = getCommandRoots('a;b|c&&d||e&f');
+ expect(result).toEqual(['a', 'b', 'c', 'd', 'e', 'f']);
+ });
+});
diff --git a/packages/core/src/utils/shell-utils.ts b/packages/core/src/utils/shell-utils.ts
new file mode 100644
index 00000000..7008fb1b
--- /dev/null
+++ b/packages/core/src/utils/shell-utils.ts
@@ -0,0 +1,288 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { Config } from '../config/config.js';
+
+/**
+ * Splits a shell command into a list of individual commands, respecting quotes.
+ * This is used to separate chained commands (e.g., using &&, ||, ;).
+ * @param command The shell command string to parse
+ * @returns An array of individual command strings
+ */
+export function splitCommands(command: string): string[] {
+ 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++;
+ }
+
+ if (currentCommand.trim()) {
+ commands.push(currentCommand.trim());
+ }
+
+ return commands.filter(Boolean); // Filter out any empty strings
+}
+
+/**
+ * Extracts the root command from a given shell command string.
+ * This is used to identify the base command for permission checks.
+ * @param command The shell command string to parse
+ * @returns The root command name, or undefined if it cannot be determined
+ * @example getCommandRoot("ls -la /tmp") returns "ls"
+ * @example getCommandRoot("git status && npm test") returns "git"
+ */
+export function getCommandRoot(command: string): string | undefined {
+ const trimmedCommand = command.trim();
+ if (!trimmedCommand) {
+ return undefined;
+ }
+
+ // 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 [];
+ }
+ 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|cmd.exe)\s+(?:\/c|-c)\s+/;
+ const match = command.match(pattern);
+ if (match) {
+ let newCommand = command.substring(match[0].length).trim();
+ if (
+ (newCommand.startsWith('"') && newCommand.endsWith('"')) ||
+ (newCommand.startsWith("'") && newCommand.endsWith("'"))
+ ) {
+ newCommand = newCommand.substring(1, newCommand.length - 1);
+ }
+ return newCommand;
+ }
+ return command.trim();
+}
+
+/**
+ * Detects command substitution patterns in a shell command, following bash quoting rules:
+ * - Single quotes ('): Everything literal, no substitution possible
+ * - Double quotes ("): Command substitution with $() and backticks unless escaped with \
+ * - No quotes: Command substitution with $(), <(), and backticks
+ * @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;
+ }
+
+ // 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;
+}
+
+/**
+ * Determines whether a given shell command is allowed to execute based on
+ * the tool's configuration including allowlists and blocklists.
+ * @param command The shell command string to validate
+ * @param config The application configuration
+ * @returns An object with 'allowed' boolean and optional 'reason' string if not allowed
+ */
+export function isCommandAllowed(
+ command: string,
+ config: Config,
+): { allowed: boolean; reason?: string } {
+ // 0. Disallow command substitution
+ // Parse the command to check for unquoted/unescaped command substitution
+ const hasCommandSubstitution = detectCommandSubstitution(command);
+ if (hasCommandSubstitution) {
+ return {
+ allowed: false,
+ reason:
+ 'Command substitution using $(), <(), or >() is not allowed for security reasons',
+ };
+ }
+
+ const SHELL_TOOL_NAMES = ['run_shell_command', 'ShellTool'];
+
+ const normalize = (cmd: string): string => cmd.trim().replace(/\s+/g, ' ');
+
+ /**
+ * Checks if a command string starts with a given prefix, ensuring it's a
+ * whole word match (i.e., followed by a space or it's an exact match).
+ * e.g., `isPrefixedBy('npm install', 'npm')` -> true
+ * e.g., `isPrefixedBy('npm', 'npm')` -> true
+ * e.g., `isPrefixedBy('npminstall', 'npm')` -> false
+ */
+ const isPrefixedBy = (cmd: string, prefix: string): boolean => {
+ if (!cmd.startsWith(prefix)) {
+ return false;
+ }
+ return cmd.length === prefix.length || cmd[prefix.length] === ' ';
+ };
+
+ /**
+ * Extracts and normalizes shell commands from a list of tool strings.
+ * e.g., 'ShellTool("ls -l")' becomes 'ls -l'
+ */
+ const extractCommands = (tools: string[]): string[] =>
+ tools.flatMap((tool) => {
+ for (const toolName of SHELL_TOOL_NAMES) {
+ if (tool.startsWith(`${toolName}(`) && tool.endsWith(')')) {
+ return [normalize(tool.slice(toolName.length + 1, -1))];
+ }
+ }
+ return [];
+ });
+
+ const coreTools = config.getCoreTools() || [];
+ const excludeTools = config.getExcludeTools() || [];
+
+ // 1. Check if the shell tool is globally disabled.
+ if (SHELL_TOOL_NAMES.some((name) => excludeTools.includes(name))) {
+ return {
+ allowed: false,
+ reason: 'Shell tool is globally disabled in configuration',
+ };
+ }
+
+ const blockedCommands = new Set(extractCommands(excludeTools));
+ const allowedCommands = new Set(extractCommands(coreTools));
+
+ const hasSpecificAllowedCommands = allowedCommands.size > 0;
+ const isWildcardAllowed = SHELL_TOOL_NAMES.some((name) =>
+ coreTools.includes(name),
+ );
+
+ const commandsToValidate = splitCommands(command).map(normalize);
+
+ const blockedCommandsArr = [...blockedCommands];
+
+ for (const cmd of commandsToValidate) {
+ // 2. Check if the command is on the blocklist.
+ const isBlocked = blockedCommandsArr.some((blocked) =>
+ isPrefixedBy(cmd, blocked),
+ );
+ if (isBlocked) {
+ return {
+ allowed: false,
+ reason: `Command '${cmd}' is blocked by configuration`,
+ };
+ }
+
+ // 3. If in strict allow-list mode, check if the command is permitted.
+ const isStrictAllowlist = hasSpecificAllowedCommands && !isWildcardAllowed;
+ const allowedCommandsArr = [...allowedCommands];
+ if (isStrictAllowlist) {
+ const isAllowed = allowedCommandsArr.some((allowed) =>
+ isPrefixedBy(cmd, allowed),
+ );
+ if (!isAllowed) {
+ return {
+ allowed: false,
+ reason: `Command '${cmd}' is not in the allowed commands list`,
+ };
+ }
+ }
+ }
+
+ // 4. If all checks pass, the command is allowed.
+ return { allowed: true };
+}