mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-12 15:10:59 -07:00
813 lines
23 KiB
TypeScript
813 lines
23 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import type { AnyToolInvocation } from '../index.js';
|
|
import type { Config } from '../config/config.js';
|
|
import os from 'node:os';
|
|
import { quote } from 'shell-quote';
|
|
import { doesToolInvocationMatch } from './tool-utils.js';
|
|
import {
|
|
spawn,
|
|
spawnSync,
|
|
type SpawnOptionsWithoutStdio,
|
|
} from 'node:child_process';
|
|
import type { Node } from 'web-tree-sitter';
|
|
import { Language, Parser } from 'web-tree-sitter';
|
|
import { loadWasmBinary } from './fileUtils.js';
|
|
|
|
export const SHELL_TOOL_NAMES = ['run_shell_command', 'ShellTool'];
|
|
|
|
/**
|
|
* 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', 'powershell.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;
|
|
}
|
|
|
|
let bashLanguage: Language | null = null;
|
|
let treeSitterInitialization: Promise<void> | null = null;
|
|
let treeSitterInitializationError: Error | null = null;
|
|
|
|
class ShellParserInitializationError extends Error {
|
|
constructor(cause: Error) {
|
|
super(`Failed to initialize bash parser: ${cause.message}`, { cause });
|
|
this.name = 'ShellParserInitializationError';
|
|
}
|
|
}
|
|
|
|
function toError(value: unknown): Error {
|
|
if (value instanceof Error) {
|
|
return value;
|
|
}
|
|
if (typeof value === 'string') {
|
|
return new Error(value);
|
|
}
|
|
return new Error('Unknown tree-sitter initialization error', {
|
|
cause: value,
|
|
});
|
|
}
|
|
|
|
async function loadBashLanguage(): Promise<void> {
|
|
try {
|
|
treeSitterInitializationError = null;
|
|
const [treeSitterBinary, bashBinary] = await Promise.all([
|
|
loadWasmBinary(
|
|
() =>
|
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
// @ts-ignore resolved by esbuild-plugin-wasm during bundling
|
|
import('web-tree-sitter/tree-sitter.wasm?binary'),
|
|
'web-tree-sitter/tree-sitter.wasm',
|
|
),
|
|
loadWasmBinary(
|
|
() =>
|
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
// @ts-ignore resolved by esbuild-plugin-wasm during bundling
|
|
import('tree-sitter-bash/tree-sitter-bash.wasm?binary'),
|
|
'tree-sitter-bash/tree-sitter-bash.wasm',
|
|
),
|
|
]);
|
|
|
|
await Parser.init({ wasmBinary: treeSitterBinary });
|
|
bashLanguage = await Language.load(bashBinary);
|
|
} catch (error) {
|
|
bashLanguage = null;
|
|
const normalized = toError(error);
|
|
const initializationError =
|
|
normalized instanceof ShellParserInitializationError
|
|
? normalized
|
|
: new ShellParserInitializationError(normalized);
|
|
treeSitterInitializationError = initializationError;
|
|
throw initializationError;
|
|
}
|
|
}
|
|
|
|
export async function initializeShellParsers(): Promise<void> {
|
|
if (!treeSitterInitialization) {
|
|
treeSitterInitialization = loadBashLanguage().catch((error) => {
|
|
treeSitterInitialization = null;
|
|
throw error;
|
|
});
|
|
}
|
|
|
|
await treeSitterInitialization;
|
|
}
|
|
|
|
interface ParsedCommandDetail {
|
|
name: string;
|
|
text: string;
|
|
}
|
|
|
|
interface CommandParseResult {
|
|
details: ParsedCommandDetail[];
|
|
hasError: boolean;
|
|
}
|
|
|
|
const POWERSHELL_COMMAND_ENV = '__GCLI_POWERSHELL_COMMAND__';
|
|
|
|
// Encode the parser script as UTF-16LE base64 so we can pass it via PowerShell's -EncodedCommand flag;
|
|
// this avoids brittle quoting/escaping when spawning PowerShell and ensures the script is received byte-for-byte.
|
|
const POWERSHELL_PARSER_SCRIPT = Buffer.from(
|
|
`
|
|
$ErrorActionPreference = 'Stop'
|
|
$commandText = $env:${POWERSHELL_COMMAND_ENV}
|
|
if ([string]::IsNullOrEmpty($commandText)) {
|
|
Write-Output '{"success":false}'
|
|
exit 0
|
|
}
|
|
$tokens = $null
|
|
$errors = $null
|
|
$ast = [System.Management.Automation.Language.Parser]::ParseInput($commandText, [ref]$tokens, [ref]$errors)
|
|
if ($errors -and $errors.Count -gt 0) {
|
|
Write-Output '{"success":false}'
|
|
exit 0
|
|
}
|
|
$commandAsts = $ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.CommandAst] }, $true)
|
|
$commandObjects = @()
|
|
foreach ($commandAst in $commandAsts) {
|
|
$name = $commandAst.GetCommandName()
|
|
if ([string]::IsNullOrWhiteSpace($name)) {
|
|
continue
|
|
}
|
|
$commandObjects += [PSCustomObject]@{
|
|
name = $name
|
|
text = $commandAst.Extent.Text.Trim()
|
|
}
|
|
}
|
|
[PSCustomObject]@{
|
|
success = $true
|
|
commands = $commandObjects
|
|
} | ConvertTo-Json -Compress
|
|
`,
|
|
'utf16le',
|
|
).toString('base64');
|
|
|
|
function createParser(): Parser | null {
|
|
if (!bashLanguage) {
|
|
if (treeSitterInitializationError) {
|
|
throw treeSitterInitializationError;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const parser = new Parser();
|
|
parser.setLanguage(bashLanguage);
|
|
return parser;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function parseCommandTree(command: string) {
|
|
const parser = createParser();
|
|
if (!parser || !command.trim()) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return parser.parse(command);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function normalizeCommandName(raw: string): string {
|
|
if (raw.length >= 2) {
|
|
const first = raw[0];
|
|
const last = raw[raw.length - 1];
|
|
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
return raw.slice(1, -1);
|
|
}
|
|
}
|
|
const trimmed = raw.trim();
|
|
if (!trimmed) {
|
|
return trimmed;
|
|
}
|
|
return trimmed.split(/[\\/]/).pop() ?? trimmed;
|
|
}
|
|
|
|
function extractNameFromNode(node: Node): string | null {
|
|
switch (node.type) {
|
|
case 'command': {
|
|
const nameNode = node.childForFieldName('name');
|
|
if (!nameNode) {
|
|
return null;
|
|
}
|
|
return normalizeCommandName(nameNode.text);
|
|
}
|
|
case 'declaration_command':
|
|
case 'unset_command':
|
|
case 'test_command': {
|
|
const firstChild = node.child(0);
|
|
if (!firstChild) {
|
|
return null;
|
|
}
|
|
return normalizeCommandName(firstChild.text);
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function collectCommandDetails(
|
|
root: Node,
|
|
source: string,
|
|
): ParsedCommandDetail[] {
|
|
const stack: Node[] = [root];
|
|
const details: ParsedCommandDetail[] = [];
|
|
|
|
while (stack.length > 0) {
|
|
const current = stack.pop();
|
|
if (!current) {
|
|
continue;
|
|
}
|
|
|
|
const commandName = extractNameFromNode(current);
|
|
if (commandName) {
|
|
details.push({
|
|
name: commandName,
|
|
text: source.slice(current.startIndex, current.endIndex).trim(),
|
|
});
|
|
}
|
|
|
|
for (let i = current.namedChildCount - 1; i >= 0; i -= 1) {
|
|
const child = current.namedChild(i);
|
|
if (child) {
|
|
stack.push(child);
|
|
}
|
|
}
|
|
}
|
|
|
|
return details;
|
|
}
|
|
|
|
function parseBashCommandDetails(command: string): CommandParseResult | null {
|
|
if (treeSitterInitializationError) {
|
|
throw treeSitterInitializationError;
|
|
}
|
|
|
|
if (!bashLanguage) {
|
|
initializeShellParsers().catch(() => {
|
|
// The failure path is surfaced via treeSitterInitializationError.
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const tree = parseCommandTree(command);
|
|
if (!tree) {
|
|
return null;
|
|
}
|
|
|
|
const details = collectCommandDetails(tree.rootNode, command);
|
|
return {
|
|
details,
|
|
hasError: tree.rootNode.hasError || details.length === 0,
|
|
};
|
|
}
|
|
|
|
function parsePowerShellCommandDetails(
|
|
command: string,
|
|
executable: string,
|
|
): CommandParseResult | null {
|
|
const trimmed = command.trim();
|
|
if (!trimmed) {
|
|
return {
|
|
details: [],
|
|
hasError: true,
|
|
};
|
|
}
|
|
|
|
try {
|
|
const result = spawnSync(
|
|
executable,
|
|
[
|
|
'-NoLogo',
|
|
'-NoProfile',
|
|
'-NonInteractive',
|
|
'-EncodedCommand',
|
|
POWERSHELL_PARSER_SCRIPT,
|
|
],
|
|
{
|
|
env: {
|
|
...process.env,
|
|
[POWERSHELL_COMMAND_ENV]: command,
|
|
},
|
|
encoding: 'utf-8',
|
|
},
|
|
);
|
|
|
|
if (result.error || result.status !== 0) {
|
|
return null;
|
|
}
|
|
|
|
const output = (result.stdout ?? '').toString().trim();
|
|
if (!output) {
|
|
return { details: [], hasError: true };
|
|
}
|
|
|
|
let parsed: {
|
|
success?: boolean;
|
|
commands?: Array<{ name?: string; text?: string }>;
|
|
} | null = null;
|
|
try {
|
|
parsed = JSON.parse(output);
|
|
} catch {
|
|
return { details: [], hasError: true };
|
|
}
|
|
|
|
if (!parsed?.success) {
|
|
return { details: [], hasError: true };
|
|
}
|
|
|
|
const details = (parsed.commands ?? [])
|
|
.map((commandDetail) => {
|
|
if (!commandDetail || typeof commandDetail.name !== 'string') {
|
|
return null;
|
|
}
|
|
|
|
const name = normalizeCommandName(commandDetail.name);
|
|
const text =
|
|
typeof commandDetail.text === 'string'
|
|
? commandDetail.text.trim()
|
|
: command;
|
|
|
|
return {
|
|
name,
|
|
text,
|
|
};
|
|
})
|
|
.filter((detail): detail is ParsedCommandDetail => detail !== null);
|
|
|
|
return {
|
|
details,
|
|
hasError: details.length === 0,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function parseCommandDetails(command: string): CommandParseResult | null {
|
|
const configuration = getShellConfiguration();
|
|
|
|
if (configuration.shell === 'powershell') {
|
|
return parsePowerShellCommandDetails(command, configuration.executable);
|
|
}
|
|
|
|
if (configuration.shell === 'bash') {
|
|
return parseBashCommandDetails(command);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Determines the appropriate shell configuration for the current platform.
|
|
*
|
|
* 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'];
|
|
if (comSpec) {
|
|
const executable = comSpec.toLowerCase();
|
|
if (
|
|
executable.endsWith('powershell.exe') ||
|
|
executable.endsWith('pwsh.exe')
|
|
) {
|
|
return {
|
|
executable: comSpec,
|
|
argsPrefix: ['-NoProfile', '-Command'],
|
|
shell: 'powershell',
|
|
};
|
|
}
|
|
}
|
|
|
|
// Default to PowerShell for all other Windows configurations.
|
|
return {
|
|
executable: 'powershell.exe',
|
|
argsPrefix: ['-NoProfile', '-Command'],
|
|
shell: 'powershell',
|
|
};
|
|
}
|
|
|
|
// 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 parsed = parseCommandDetails(command);
|
|
if (!parsed || parsed.hasError) {
|
|
return [];
|
|
}
|
|
|
|
return parsed.details.map((detail) => detail.text).filter(Boolean);
|
|
}
|
|
|
|
/**
|
|
* 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 parsed = parseCommandDetails(command);
|
|
if (!parsed || parsed.hasError || parsed.details.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
return parsed.details[0]?.name;
|
|
}
|
|
|
|
export function getCommandRoots(command: string): string[] {
|
|
if (!command) {
|
|
return [];
|
|
}
|
|
|
|
const parsed = parseCommandDetails(command);
|
|
if (!parsed || parsed.hasError) {
|
|
return [];
|
|
}
|
|
|
|
return parsed.details.map((detail) => detail.name).filter(Boolean);
|
|
}
|
|
|
|
export function stripShellWrapper(command: string): string {
|
|
const pattern =
|
|
/^\s*(?:(?:sh|bash|zsh)\s+-c|cmd\.exe\s+\/c|powershell(?:\.exe)?\s+(?:-NoProfile\s+)?-Command|pwsh(?:\.exe)?\s+(?:-NoProfile\s+)?-Command)\s+/i;
|
|
const match = command.match(pattern);
|
|
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
|
|
*/
|
|
/**
|
|
* 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<string>,
|
|
): {
|
|
allAllowed: boolean;
|
|
disallowedCommands: string[];
|
|
blockReason?: string;
|
|
isHardDenial?: boolean;
|
|
} {
|
|
const parseResult = parseCommandDetails(command);
|
|
if (!parseResult || parseResult.hasError) {
|
|
return {
|
|
allAllowed: false,
|
|
disallowedCommands: [command],
|
|
blockReason: 'Command rejected because it could not be parsed safely',
|
|
isHardDenial: true,
|
|
};
|
|
}
|
|
|
|
const normalize = (cmd: string): string => cmd.trim().replace(/\s+/g, ' ');
|
|
const commandsToValidate = parseResult.details
|
|
.map((detail) => normalize(detail.text))
|
|
.filter(Boolean);
|
|
const invocation: AnyToolInvocation & { params: { command: string } } = {
|
|
params: { command: '' },
|
|
} as AnyToolInvocation & { params: { command: string } };
|
|
|
|
// 1. Blocklist Check (Highest Priority)
|
|
const excludeTools = config.getExcludeTools() || [];
|
|
const isWildcardBlocked = SHELL_TOOL_NAMES.some((name) =>
|
|
excludeTools.includes(name),
|
|
);
|
|
|
|
if (isWildcardBlocked) {
|
|
return {
|
|
allAllowed: false,
|
|
disallowedCommands: commandsToValidate,
|
|
blockReason: 'Shell tool is globally disabled in configuration',
|
|
isHardDenial: true,
|
|
};
|
|
}
|
|
|
|
for (const cmd of commandsToValidate) {
|
|
invocation.params['command'] = cmd;
|
|
if (
|
|
doesToolInvocationMatch('run_shell_command', invocation, excludeTools)
|
|
) {
|
|
return {
|
|
allAllowed: false,
|
|
disallowedCommands: [cmd],
|
|
blockReason: `Command '${cmd}' is blocked by configuration`,
|
|
isHardDenial: true,
|
|
};
|
|
}
|
|
}
|
|
|
|
const coreTools = config.getCoreTools() || [];
|
|
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: [] };
|
|
}
|
|
|
|
const disallowedCommands: string[] = [];
|
|
|
|
if (sessionAllowlist) {
|
|
// "DEFAULT DENY" MODE: A session allowlist is provided.
|
|
// All commands must be in either the session or global allowlist.
|
|
const normalizedSessionAllowlist = new Set(
|
|
[...sessionAllowlist].flatMap((cmd) =>
|
|
SHELL_TOOL_NAMES.map((name) => `${name}(${cmd})`),
|
|
),
|
|
);
|
|
|
|
for (const cmd of commandsToValidate) {
|
|
invocation.params['command'] = cmd;
|
|
const isSessionAllowed = doesToolInvocationMatch(
|
|
'run_shell_command',
|
|
invocation,
|
|
[...normalizedSessionAllowlist],
|
|
);
|
|
if (isSessionAllowed) continue;
|
|
|
|
const isGloballyAllowed = doesToolInvocationMatch(
|
|
'run_shell_command',
|
|
invocation,
|
|
coreTools,
|
|
);
|
|
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 =
|
|
coreTools.filter((tool) =>
|
|
SHELL_TOOL_NAMES.some((name) => tool.startsWith(`${name}(`)),
|
|
).length > 0;
|
|
|
|
if (hasSpecificAllowedCommands) {
|
|
for (const cmd of commandsToValidate) {
|
|
invocation.params['command'] = cmd;
|
|
const isGloballyAllowed = doesToolInvocationMatch(
|
|
'run_shell_command',
|
|
invocation,
|
|
coreTools,
|
|
);
|
|
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 const spawnAsync = (
|
|
command: string,
|
|
args: string[],
|
|
options?: SpawnOptionsWithoutStdio,
|
|
): Promise<{ stdout: string; stderr: string }> =>
|
|
new Promise((resolve, reject) => {
|
|
const child = spawn(command, args, options);
|
|
let stdout = '';
|
|
let stderr = '';
|
|
|
|
child.stdout.on('data', (data) => {
|
|
stdout += data.toString();
|
|
});
|
|
|
|
child.stderr.on('data', (data) => {
|
|
stderr += data.toString();
|
|
});
|
|
|
|
child.on('close', (code) => {
|
|
if (code === 0) {
|
|
resolve({ stdout, stderr });
|
|
} else {
|
|
reject(new Error(`Command failed with exit code ${code}:\n${stderr}`));
|
|
}
|
|
});
|
|
|
|
child.on('error', (err) => {
|
|
reject(err);
|
|
});
|
|
});
|
|
|
|
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 };
|
|
}
|
|
|
|
/**
|
|
* Determines whether a shell invocation should be auto-approved based on an allowlist.
|
|
*
|
|
* This reuses the same parsing logic as command-permission enforcement so that
|
|
* chained commands must be individually covered by the allowlist.
|
|
*
|
|
* @param invocation The shell tool invocation being evaluated.
|
|
* @param allowedPatterns The configured allowlist patterns (e.g. `run_shell_command(git)`).
|
|
* @returns True if every parsed command segment is allowed by the patterns; false otherwise.
|
|
*/
|
|
export function isShellInvocationAllowlisted(
|
|
invocation: AnyToolInvocation,
|
|
allowedPatterns: string[],
|
|
): boolean {
|
|
if (!allowedPatterns.length) {
|
|
return false;
|
|
}
|
|
|
|
const hasShellWildcard = allowedPatterns.some((pattern) =>
|
|
SHELL_TOOL_NAMES.includes(pattern),
|
|
);
|
|
const hasShellSpecificPattern = allowedPatterns.some((pattern) =>
|
|
SHELL_TOOL_NAMES.some((name) => pattern.startsWith(`${name}(`)),
|
|
);
|
|
|
|
if (!hasShellWildcard && !hasShellSpecificPattern) {
|
|
return false;
|
|
}
|
|
|
|
if (hasShellWildcard) {
|
|
return true;
|
|
}
|
|
|
|
if (
|
|
!('params' in invocation) ||
|
|
typeof invocation.params !== 'object' ||
|
|
invocation.params === null ||
|
|
!('command' in invocation.params)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
const commandValue = (invocation.params as { command?: unknown }).command;
|
|
if (typeof commandValue !== 'string' || !commandValue.trim()) {
|
|
return false;
|
|
}
|
|
|
|
const command = commandValue.trim();
|
|
|
|
const parseResult = parseCommandDetails(command);
|
|
if (!parseResult || parseResult.hasError) {
|
|
return false;
|
|
}
|
|
|
|
const normalize = (cmd: string): string => cmd.trim().replace(/\s+/g, ' ');
|
|
const commandsToValidate = parseResult.details
|
|
.map((detail) => normalize(detail.text))
|
|
.filter(Boolean);
|
|
|
|
if (commandsToValidate.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
return commandsToValidate.every((commandSegment) =>
|
|
doesToolInvocationMatch(
|
|
SHELL_TOOL_NAMES[0],
|
|
{ params: { command: commandSegment } } as AnyToolInvocation,
|
|
allowedPatterns,
|
|
),
|
|
);
|
|
}
|