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

View File

@@ -47,9 +47,11 @@ export function buildIgnoreFilter(dir) {
* Returns { entries: Array<{path,type,size}>, total, truncated }
* - type: 'd' for directory, 'f' for file
* - size: file size in bytes (null for directories)
* Dot-directories (names starting with '.', e.g. .git) are skipped unless
* `includeDotDirs` is true.
* All paths are relative to ROOT_DIR and use forward slashes.
*/
export function listDir(dir, { seek = 0, limit = 50, recursive = false } = {}) {
export function listDir(dir, { seek = 0, limit = 50, recursive = false, includeDotDirs = false } = {}) {
const ig = buildIgnoreFilter(dir);
const allEntries = [];
@@ -61,6 +63,8 @@ export function listDir(dir, { seek = 0, limit = 50, recursive = false } = {}) {
return;
}
for (const e of entries) {
// Skip dot-directories (e.g. .git) by default.
if (e.isDirectory() && e.name.startsWith('.') && !includeDotDirs) continue;
const full = path.join(current, e.name);
const rel = path.relative(ROOT_DIR, full).replace(/\\/g, '/');
if (ig.ignores(e.isDirectory() ? rel + '/' : rel)) continue;

View File

@@ -47,7 +47,7 @@ function buildSystemPrompt() {
}
return `You are Fido, a coding assistant.
You inspect files, list directories, and apply precise code changes within the workspace.
You inspect files, list directories, apply precise code changes, and run shell commands within the workspace.
## Workspace
Root directory: ${ROOT_DIR}
@@ -58,7 +58,7 @@ ${dirSection}
## Available Tools & Guidelines
- \`read_file(filename, seek, limit)\`: Inspect file contents and line numbers before modifying them.
- \`list_files(dirname, recursive, seek, limit)\`: Explore workspace folders (respects .gitignore).
- \`list_files(dirname, recursive, seek, limit, includeDotDirs)\`: Explore workspace folders (respects .gitignore; dot-directories such as .git are excluded unless includeDotDirs=true).
- \`ripgrep(pattern, path, glob, ignore_case, literal, max_results)\`: Search file contents (regex by default) across the workspace; respects .gitignore and skips binary files. Returns matches with 0-indexed line numbers (usable directly as read_file's seek).
- \`patch(operations)\`: Apply file updates or create files.
- Create or overwrite: \`{"file": "path", "search": null, "replace": "content"}\` (parent directories auto-created).
@@ -67,6 +67,7 @@ ${dirSection}
- \`mkdir(dirname)\`: Create directories (auto-recursive).
- \`rmdir(dirname)\`: Remove a directory and all its contents.
- \`rm(filename)\`: Delete an individual file.
- \`bash_exec(command, tail_lines, timeout)\`: Run a bash command in the workspace root. Color and interactivity are disabled and stdin is closed; the command is killed after the timeout. Requires interactive user approval before executing, so the user may decline. stdout/stderr are trimmed to the last \`tail_lines\` lines (default 100).
- \`ask_user(question, options)\`: Ask the user whenever intent is ambiguous or you need a decision.
## Rules

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;
},
});

View File

@@ -11,6 +11,7 @@ import { patchTool } from './patch.js';
import { mkdirTool } from './mkdir.js';
import { rmdirTool } from './rmdir.js';
import { rmTool } from './rm.js';
import { bashExecTool } from './bash-exec.js';
import { askUserTool } from './ask-user.js';
export {
@@ -21,6 +22,7 @@ export {
mkdirTool,
rmdirTool,
rmTool,
bashExecTool,
askUserTool,
};
@@ -32,5 +34,6 @@ export const tools = [
mkdirTool,
rmdirTool,
rmTool,
bashExecTool,
askUserTool,
];

View File

@@ -10,6 +10,8 @@ export const listFilesTool = defineTool({
description:
'List the immediate contents of a directory within the workspace (non-recursive by default). ' +
'Entries matching .gitignore patterns (and node_modules/) are excluded automatically. ' +
'Dot-directories (names starting with ".", e.g. .git) are excluded by default; ' +
'set includeDotDirs=true to include them. ' +
'Set recursive=true to walk the full subtree. ' +
'Use seek/limit for pagination. Paths are relative to the workspace root.',
parameters: {
@@ -33,9 +35,13 @@ export const listFilesTool = defineTool({
type: 'number',
description: 'Maximum number of entries to return. Default: 50.',
},
includeDotDirs: {
type: 'boolean',
description: 'If true, include dot-directories (e.g. .git). Default: false.',
},
},
},
invoke: async ({ dirname, recursive = false, seek = 0, limit = 50 }) => {
invoke: async ({ dirname, recursive = false, seek = 0, limit = 50, includeDotDirs = false }) => {
if (!Number.isInteger(limit) || limit < 1) {
throw new ToolError(`"limit" must be a positive integer (received ${limit}).`);
}
@@ -49,7 +55,7 @@ export const listFilesTool = defineTool({
if (!fs.statSync(absDir).isDirectory()) {
throw new ToolError(`"${dirname}" is not a directory.`);
}
const { entries, total, truncated } = listDir(absDir, { seek, limit, recursive });
const { entries, total, truncated } = listDir(absDir, { seek, limit, recursive, includeDotDirs });
const result = { dirname, recursive, entries, total, returned: entries.length, seek };
if (truncated > 0) {
result.truncated = truncated;