'use strict'; /** * bash_exec — run a shell command in the workspace after explicit user approval. * * Design notes: * - Runs `bash -c ` with cwd pinned to the workspace root, so * relative paths start there. NOTE: this is convenience, not confinement — * the shell can still touch absolute paths and escape with `cd ..`, so the * interactive approval prompt below is the only guard. * - Child processes run with color and interactivity disabled (see * NONINTERACTIVE_ENV) and with stdin closed, so a command can never block * on a terminal prompt. * - Every command requires interactive approval via the same inquirer/chalk * styling used by the ask_user tool. * - A hard timeout kills the whole process group; output is trimmed to the * last `tail_lines` lines before being returned to the model. */ import { spawn } from 'child_process'; import chalk from 'chalk'; import { confirm } from '@inquirer/prompts'; import { defineTool, ToolError } from 'llmorbiter'; import { ROOT_DIR } from '../config.js'; import { emitToolCall, emitToolResult, clip } from './notify.js'; const DEFAULT_TAIL_LINES = 100; // trailing lines of output returned to the model const DEFAULT_TIMEOUT_MS = 30_000; // 30s hard limit per command const MAX_CAPTURE_CHARS = 1_000_000; // cap raw output kept in memory (~1 MB of text) /** * Environment overrides applied to every child process. These disable ANSI * color and anything that would try to prompt, page, or open an editor, so a * command run by the agent is fully non-interactive. */ const NONINTERACTIVE_ENV = { // Color NO_COLOR: '1', FORCE_COLOR: '0', CLICOLOR: '0', CLICOLOR_FORCE: '0', TERM: 'dumb', // Non-interactive / automation signals CI: '1', DEBIAN_FRONTEND: 'noninteractive', // Credentials, pagers, editors — never block waiting on the user GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: 'echo', SSH_ASKPASS: 'echo', GIT_PAGER: 'cat', GIT_EDITOR: 'true', PAGER: 'cat', // npm / npx npm_config_color: 'false', npm_config_yes: 'true', npm_config_fund: 'false', npm_config_audit: 'false', npm_config_progress: 'false', // Python PYTHONUNBUFFERED: '1', PYTHONDONTWRITEBYTECODE: '1', }; /** * Keep only the last `maxLines` lines of `text`. * Returns { text, lines, omitted } where `lines` is the total before trimming * and `omitted` is how many lines were dropped. */ function tailLines(text, maxLines) { if (!text) return { text: '', lines: 0, omitted: 0 }; const lines = text.replace(/\r\n/g, '\n').split('\n'); // Drop the single trailing empty element produced by a trailing newline. if (lines.length && lines[lines.length - 1] === '') lines.pop(); const total = lines.length; if (total <= maxLines) return { text: lines.join('\n'), lines: total, omitted: 0 }; return { text: lines.slice(total - maxLines).join('\n'), lines: total, omitted: total - maxLines, }; } /** * Ask the user to approve a command before it is executed. * Throws ToolError when the user declines or when no TTY is available. */ async function requestApproval(command, timeoutMs) { if (!process.stdin.isTTY) { throw new ToolError( 'bash_exec requires interactive approval, but no TTY is attached. ' + 'Run fido in an interactive terminal to approve shell commands.' ); } console.log(''); console.log(chalk.yellow.bold(' ⚠ Fido wants to run a shell command')); console.log(chalk.dim(` cwd: ${ROOT_DIR}`)); console.log(chalk.dim(` timeout: ${timeoutMs} ms`)); console.log(chalk.white(' ' + command.replace(/\r?\n/g, '\n '))); console.log(''); let approved = false; try { approved = await confirm({ message: chalk.bold.yellow('Approve running this command?'), default: false, prefix: chalk.yellow('›'), theme: { prefix: chalk.yellow('›'), style: { message: chalk.bold.yellow }, }, }); } catch { // Ctrl+C / EOF during the prompt → treat as a rejection. approved = false; } console.log(''); if (!approved) { throw new ToolError('Command rejected by the user.'); } } /** * Spawn bash and collect stdout/stderr until the process exits or the timeout * fires. Resolves (never rejects) with the captured output and exit metadata. */ function runCommand(command, timeoutMs) { return new Promise((resolve) => { const detached = process.platform !== 'win32'; const child = spawn('bash', ['-c', command], { cwd: ROOT_DIR, env: { ...process.env, ...NONINTERACTIVE_ENV }, stdio: ['ignore', 'pipe', 'pipe'], detached, windowsHide: true, }); let stdout = ''; let stderr = ''; let outputTruncated = false; // true if the size cap dropped earlier output let timedOut = false; let settled = false; // Keep the *tail* of the output, not the head. The tool is designed to // return the last `tail_lines` lines, so once the cap is exceeded we must // retain the newest text and drop the oldest — capping the head would // return stale lines from the middle of the stream and lose the real end. const append = (prev, chunk) => { const next = prev + chunk.toString('utf8'); if (next.length <= MAX_CAPTURE_CHARS) return next; outputTruncated = true; return next.slice(next.length - MAX_CAPTURE_CHARS); }; child.stdout.on('data', (chunk) => { stdout = append(stdout, chunk); }); child.stderr.on('data', (chunk) => { stderr = append(stderr, chunk); }); // Kill the whole process tree (bash and anything it spawned). const killTree = () => { try { if (detached && child.pid) process.kill(-child.pid, 'SIGKILL'); else child.kill('SIGKILL'); } catch { try { child.kill('SIGKILL'); } catch { /* already gone */ } } }; const timer = setTimeout(() => { timedOut = true; killTree(); }, timeoutMs); const finish = (payload) => { if (settled) return; settled = true; clearTimeout(timer); resolve(payload); }; child.on('error', (err) => finish({ code: null, signal: null, stdout, stderr, timedOut, outputTruncated, spawnError: err })); child.on('close', (code, signal) => finish({ code, signal, stdout, stderr, timedOut, outputTruncated })); }); } export const bashExecTool = defineTool({ name: 'bash_exec', description: 'Run a bash command in the workspace root and return its output. ' + 'The command runs with color output and interactivity disabled, with stdin closed, ' + 'and is killed if it exceeds the timeout. ' + 'IMPORTANT: it requires explicit interactive user approval before executing, so the ' + 'user may decline. Output (stdout and stderr) is trimmed to the last `tail_lines` lines.', parameters: { type: 'object', additionalProperties: false, required: ['command'], properties: { command: { type: 'string', description: 'The bash command to run. Executed via `bash -c` with cwd set to the workspace root.', }, tail_lines: { type: 'number', description: `Number of trailing output lines to return for stdout and stderr. Default: ${DEFAULT_TAIL_LINES}.`, }, timeout: { type: 'number', description: `Maximum runtime in milliseconds before the command is killed. Default: ${DEFAULT_TIMEOUT_MS}.`, }, }, }, invoke: async ({ command, tail_lines = DEFAULT_TAIL_LINES, timeout = DEFAULT_TIMEOUT_MS }) => { if (!command || typeof command !== 'string' || !command.trim()) { throw new ToolError('"command" must be a non-empty string.'); } if (!Number.isInteger(tail_lines) || tail_lines < 1) { throw new ToolError(`"tail_lines" must be a positive integer (received ${tail_lines}).`); } if (!Number.isInteger(timeout) || timeout < 1) { throw new ToolError(`"timeout" must be a positive integer of milliseconds (received ${timeout}).`); } await requestApproval(command, timeout); const { code, signal, stdout, stderr, timedOut, outputTruncated, spawnError } = await runCommand(command, timeout); if (spawnError) { throw new ToolError(`Failed to launch bash: ${spawnError.message}`); } const out = tailLines(stdout, tail_lines); const err = tailLines(stderr, tail_lines); const omitted = []; if (outputTruncated) omitted.push('earlier output (size cap)'); if (out.omitted) omitted.push(`${out.omitted} stdout line(s)`); if (err.omitted) omitted.push(`${err.omitted} stderr line(s)`); const result = { command, exitCode: code, timedOut, stdout: out.text, stderr: err.text, }; if (signal) result.signal = signal; if (omitted.length) { result.truncated = true; result.truncationNote = `${omitted.join(' and ')} omitted; showing the last ${tail_lines} lines.`; } if (timedOut) { result.note = `Command exceeded the ${timeout} ms timeout and was killed.`; } return result; }, }); /** * Call notification: the command flattened to one line, plus a custom timeout * when one is set. The approval prompt that follows shows the full command. */ export function notifyCall(args = {}) { const { command, timeout } = args; const oneLine = String(command ?? '').replace(/\s*\n\s*/g, ' ; '); const extras = Number.isInteger(timeout) && timeout !== DEFAULT_TIMEOUT_MS ? ` timeout=${timeout}` : ''; emitToolCall(bashExecTool.name, clip(`${oneLine}${extras}`)); } /** * Result notification: the process exit status (or signal / timeout). */ export function notifyResult(result = {}) { const parts = []; if (result.exitCode !== undefined && result.exitCode !== null) parts.push(`exit ${result.exitCode}`); else if (result.signal) parts.push(`signal ${result.signal}`); if (result.timedOut) parts.push('timed out'); emitToolResult(bashExecTool.name, parts.join(' ') || 'done'); }