/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { Config } from '../config/config.js'; import os from 'os'; import { quote } from 'shell-quote'; /** * An identifier for the shell type. */ export type ShellType = 'cmd' | 'powershell' | 'bash'; /** * Defines the configuration required to execute a command string within a specific shell. */ export interface ShellConfiguration { /** The path or name of the shell executable (e.g., 'bash', 'cmd.exe'). */ executable: string; /** * The arguments required by the shell to execute a subsequent string argument. */ argsPrefix: string[]; /** An identifier for the shell type. */ shell: ShellType; } /** * Determines the appropriate shell configuration for the current platform. * * This ensures we can execute command strings predictably and securely across platforms * using the `spawn(executable, [...argsPrefix, commandString], { shell: false })` pattern. * * @returns The ShellConfiguration for the current environment. */ export function getShellConfiguration(): ShellConfiguration { if (isWindows()) { const comSpec = process.env.ComSpec || 'cmd.exe'; const executable = comSpec.toLowerCase(); if ( executable.endsWith('powershell.exe') || executable.endsWith('pwsh.exe') ) { // For PowerShell, the arguments are different. // -NoProfile: Speeds up startup. // -Command: Executes the following command. return { executable: comSpec, argsPrefix: ['-NoProfile', '-Command'], shell: 'powershell', }; } // Default to cmd.exe for anything else on Windows. // Flags for CMD: // /d: Skip execution of AutoRun commands. // /s: Modifies the treatment of the command string (important for quoting). // /c: Carries out the command specified by the string and then terminates. return { executable: comSpec, argsPrefix: ['/d', '/s', '/c'], shell: 'cmd', }; } // Unix-like systems (Linux, macOS) return { executable: 'bash', argsPrefix: ['-c'], shell: 'bash' }; } /** * Export the platform detection constant for use in process management (e.g., killing processes). */ export const isWindows = () => os.platform() === 'win32'; /** * Escapes a string so that it can be safely used as a single argument * in a shell command, preventing command injection. * * @param arg The argument string to escape. * @param shell The type of shell the argument is for. * @returns The shell-escaped string. */ export function escapeShellArg(arg: string, shell: ShellType): string { if (!arg) { return ''; } switch (shell) { case 'powershell': // For PowerShell, wrap in single quotes and escape internal single quotes by doubling them. return `'${arg.replace(/'/g, "''")}'`; case 'cmd': // Simple Windows escaping for cmd.exe: wrap in double quotes and escape inner double quotes. return `"${arg.replace(/"/g, '""')}"`; case 'bash': default: // POSIX shell escaping using shell-quote. return quote([arg]); } } /** * 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; } /** * Checks a shell command against security policies and allowlists. * * This function operates in one of two modes depending on the presence of * the `sessionAllowlist` parameter: * * 1. **"Default Deny" Mode (sessionAllowlist is provided):** This is the * strictest mode, used for user-defined scripts like custom commands. * A command is only permitted if it is found on the global `coreTools` * allowlist OR the provided `sessionAllowlist`. It must not be on the * global `excludeTools` blocklist. * * 2. **"Default Allow" Mode (sessionAllowlist is NOT provided):** This mode * is used for direct tool invocations (e.g., by the model). If a strict * global `coreTools` allowlist exists, commands must be on it. Otherwise, * any command is permitted as long as it is not on the `excludeTools` * blocklist. * * @param command The shell command string to validate. * @param config The application configuration. * @param sessionAllowlist A session-level list of approved commands. Its * presence activates "Default Deny" mode. * @returns An object detailing which commands are not allowed. */ export function checkCommandPermissions( command: string, config: Config, sessionAllowlist?: Set, ): { allAllowed: boolean; disallowedCommands: string[]; blockReason?: string; isHardDenial?: boolean; } { // Disallow command substitution for security. if (detectCommandSubstitution(command)) { return { allAllowed: false, disallowedCommands: [command], blockReason: 'Command substitution using $(), <(), or >() is not allowed for security reasons', isHardDenial: true, }; } const SHELL_TOOL_NAMES = ['run_shell_command', 'ShellTool']; const normalize = (cmd: string): string => cmd.trim().replace(/\s+/g, ' '); const isPrefixedBy = (cmd: string, prefix: string): boolean => { if (!cmd.startsWith(prefix)) { return false; } return cmd.length === prefix.length || cmd[prefix.length] === ' '; }; 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() || []; const commandsToValidate = splitCommands(command).map(normalize); // 1. Blocklist Check (Highest Priority) if (SHELL_TOOL_NAMES.some((name) => excludeTools.includes(name))) { return { allAllowed: false, disallowedCommands: commandsToValidate, blockReason: 'Shell tool is globally disabled in configuration', isHardDenial: true, }; } const blockedCommands = extractCommands(excludeTools); for (const cmd of commandsToValidate) { if (blockedCommands.some((blocked) => isPrefixedBy(cmd, blocked))) { return { allAllowed: false, disallowedCommands: [cmd], blockReason: `Command '${cmd}' is blocked by configuration`, isHardDenial: true, }; } } const globallyAllowedCommands = extractCommands(coreTools); const isWildcardAllowed = SHELL_TOOL_NAMES.some((name) => coreTools.includes(name), ); // If there's a global wildcard, all commands are allowed at this point // because they have already passed the blocklist check. if (isWildcardAllowed) { return { allAllowed: true, disallowedCommands: [] }; } if (sessionAllowlist) { // "DEFAULT DENY" MODE: A session allowlist is provided. // All commands must be in either the session or global allowlist. const disallowedCommands: string[] = []; for (const cmd of commandsToValidate) { const isSessionAllowed = [...sessionAllowlist].some((allowed) => isPrefixedBy(cmd, normalize(allowed)), ); if (isSessionAllowed) continue; const isGloballyAllowed = globallyAllowedCommands.some((allowed) => isPrefixedBy(cmd, allowed), ); if (isGloballyAllowed) continue; disallowedCommands.push(cmd); } if (disallowedCommands.length > 0) { return { allAllowed: false, disallowedCommands, blockReason: `Command(s) not on the global or session allowlist. Disallowed commands: ${disallowedCommands .map((c) => JSON.stringify(c)) .join(', ')}`, isHardDenial: false, // This is a soft denial; confirmation is possible. }; } } else { // "DEFAULT ALLOW" MODE: No session allowlist. const hasSpecificAllowedCommands = globallyAllowedCommands.length > 0; if (hasSpecificAllowedCommands) { const disallowedCommands: string[] = []; for (const cmd of commandsToValidate) { const isGloballyAllowed = globallyAllowedCommands.some((allowed) => isPrefixedBy(cmd, allowed), ); if (!isGloballyAllowed) { disallowedCommands.push(cmd); } } if (disallowedCommands.length > 0) { return { allAllowed: false, disallowedCommands, blockReason: `Command(s) not in the allowed commands list. Disallowed commands: ${disallowedCommands.map((c) => JSON.stringify(c)).join(', ')}`, isHardDenial: false, // This is a soft denial. }; } } // If no specific global allowlist exists, and it passed the blocklist, // the command is allowed by default. } // If all checks for the current mode pass, the command is allowed. return { allAllowed: true, disallowedCommands: [] }; } /** * Determines whether a given shell command is allowed to execute based on * the tool's configuration including allowlists and blocklists. * * This function operates in "default allow" mode. It is a wrapper around * `checkCommandPermissions`. * * @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 } { // By not providing a sessionAllowlist, we invoke "default allow" behavior. const { allAllowed, blockReason } = checkCommandPermissions(command, config); if (allAllowed) { return { allowed: true }; } return { allowed: false, reason: blockReason }; }