diff --git a/src/dir-listing.js b/src/dir-listing.js index 7243730..33891ef 100644 --- a/src/dir-listing.js +++ b/src/dir-listing.js @@ -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; diff --git a/src/fido.js b/src/fido.js index 6135375..1e3ad5c 100644 --- a/src/fido.js +++ b/src/fido.js @@ -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 diff --git a/src/tools/bash-exec.js b/src/tools/bash-exec.js new file mode 100644 index 0000000..b9e9920 --- /dev/null +++ b/src/tools/bash-exec.js @@ -0,0 +1,257 @@ +'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'; + +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; + }, +}); diff --git a/src/tools/index.js b/src/tools/index.js index 4224b85..28a17de 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -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, ]; diff --git a/src/tools/list-files.js b/src/tools/list-files.js index 89be3de..5852442 100644 --- a/src/tools/list-files.js +++ b/src/tools/list-files.js @@ -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;