This commit is contained in:
seb
2026-09-22 19:21:14 +02:00
parent d74ca40076
commit eb885621c7
5 changed files with 276 additions and 5 deletions

257
src/tools/bash-exec.js Normal file
View File

@@ -0,0 +1,257 @@
'use strict';
/**
* bash_exec — run a shell command in the workspace after explicit user approval.
*
* Design notes:
* - Runs `bash -c <command>` 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';
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;
},
});