summaryrefslogtreecommitdiff
path: root/packages/core/src/utils
diff options
context:
space:
mode:
Diffstat (limited to 'packages/core/src/utils')
-rw-r--r--packages/core/src/utils/shell-utils.test.ts583
-rw-r--r--packages/core/src/utils/shell-utils.ts288
2 files changed, 871 insertions, 0 deletions
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 };
+}