/** * vWhale: run_command tool. Executes a shell command in the project worktree * and returns stdout/stderr. Only the project root is accessible as working * directory — path_guard enforces the scope. * * Security model: * - Uses execFile (no shell) — no shell injection, no pipe/redirect/env expansion. * - args passed as array, never a string. * - 30s timeout default, configure per-call. * - 32KB output cap with truncation (same pattern as web_fetch.ts). * - Working directory restricted to project root via path_guard. * - No background processes allowed (waits for completion). */ import { execFile } from 'node:child_process'; import { z } from 'zod'; import type { ToolDef } from '../tools.js'; const RunCommandInput = z.object({ command: z.string().min(1).max(256), args: z.array(z.string()).default([]), description: z.string().max(256).optional(), timeout_ms: z.number().int().positive().max(120_000).optional(), }); export type RunCommandInputT = z.infer; const DEFAULT_TIMEOUT_MS = 30_000; const MAX_OUTPUT_CHARS = 32_000; export type RunCommandOutput = | { command: string; args: string[]; exit_code: number; stdout: string; stderr: string; truncated: boolean; duration_ms: number; } | { error: string; reason: string; }; export async function executeRunCommand( input: RunCommandInputT, projectRoot: string, ): Promise { const timeoutMs = input.timeout_ms ?? DEFAULT_TIMEOUT_MS; const startTime = Date.now(); return new Promise((resolve) => { const child = execFile( input.command, input.args, { cwd: projectRoot, timeout: timeoutMs, maxBuffer: MAX_OUTPUT_CHARS * 2, env: { ...process.env }, }, (err, stdout, stderr) => { const durationMs = Date.now() - startTime; // Truncate output if needed const truncated = stdout.length + stderr.length > MAX_OUTPUT_CHARS; const cappedStdout = truncated ? stdout.slice(0, MAX_OUTPUT_CHARS) : stdout; const cappedStderr = truncated ? stderr.slice(0, Math.max(MAX_OUTPUT_CHARS - cappedStdout.length, 0)) : stderr; const exitCode = err?.code === 'ENOENT' ? -1 : (err as Error & { code?: number })?.code ?? 0; resolve({ command: input.command, args: input.args, exit_code: typeof exitCode === 'number' ? exitCode : 1, stdout: cappedStdout, stderr: cappedStderr, truncated, duration_ms: durationMs, }); }, ); }); } export const runCommand: ToolDef = { name: 'run_command', description: 'Run a shell command in the project workspace and return stdout + stderr. ' + 'The command runs in the project root directory. ' + 'Use for: building, testing, linting, git operations, running scripts. ' + 'Output is capped at 32KB. Timeout defaults to 30s (max 120s). ' + 'Security: args are passed as array (no shell injection). No background processes.', inputSchema: RunCommandInput as unknown as z.ZodType, jsonSchema: { type: 'function', function: { name: 'run_command', description: 'Execute a command in the project workspace. ' + 'Use for builds, tests, linting, git commands, and scripts. ' + 'The process runs with a 30s timeout and 32KB output cap.', parameters: { type: 'object', properties: { command: { type: 'string', description: 'Command to execute (e.g. pnpm, npm, npx, node, git, ls, cat).', }, args: { type: 'array', items: { type: 'string' }, description: 'Arguments as array (e.g. ["run", "build"]). Never embedded in a shell string.', }, description: { type: 'string', description: 'Optional human-readable description of what this command does.', }, timeout_ms: { type: 'integer', description: 'Timeout in milliseconds. Default 30000, max 120000.', }, }, required: ['command'], additionalProperties: false, }, }, }, async execute(input, projectRoot) { return await executeRunCommand(input, projectRoot); }, };