/** * BashGuard — the `bash` tool override (§14). * * pi's `bash.timeout` is optional with **no default** (F1, * `packages/coding-agent/src/core/tools/bash.ts:42`), so a command that never * exits hangs the tool, and therefore the session, forever. This override fixes * that without patching pi core (D13, R-BASH-18). * * Four layers: * 1. default + clamped timeout, with an actionable timeout message (R-BASH-3/4/5) * 2. pre-flight interactive classification, blocking not warning (R-BASH-6/7/8/9) * 3. non-interactive env + stdin closed, via `spawnHook` (R-BASH-10/11/13; * R-BASH-12 hook composition is deferred — see `spawnHookFor`) * 4. stall detection that surfaces and never kills (R-BASH-14/15/16) * * Execution itself is entirely pi's: we delegate to `createBashToolDefinition`, * so streaming, truncation, temp-file spill and process-tree kill are unchanged. * `renderCall`/`renderResult` are deliberately omitted so pi's bash UI is * inherited per slot (F10, R-BASH-1). * * BashGuard is installed in the parent Pi session. Workers deliberately keep * Pi's native tool surface: their detached process is supervised by the parent * and may run long computation without consuming the parent turn. */ import type { AgentToolResult } from "@earendil-works/pi-agent-core"; import { delimiter, join } from "node:path"; import { type BashSpawnContext, type BashSpawnHook, type BashToolDetails, type BashToolInput, createBashToolDefinition, createLocalBashOperations, type ExtensionAPI, type ExtensionContext, getAgentDir, } from "@earendil-works/pi-coding-agent"; import { type BashConfig, type ConfigDiagnostic, loadBashConfig, loadBashConfigWithDiagnostics } from "./config.ts"; import { classifyInteractive } from "./classify.ts"; import { applyHardening } from "./harden.ts"; /** How often the stall timer checks for silence (R-BASH-14). */ const STALL_CHECK_MS = 5000; /** Leave time for the parent turn to settle before the five-minute worker review. */ export const ORCHESTRATOR_FOREGROUND_BASH_SEC = 240; export interface BashGuardOptions { /** Live AGI-mode state; omitted means ordinary BashGuard behavior only. */ isAgiEnabled?: () => boolean; /** Test/embedding override. Production uses ORCHESTRATOR_FOREGROUND_BASH_SEC. */ orchestratorForegroundSec?: number; } /** * How often an ongoing stall is re-reported (R-BASH-15). * * The marker carries `quietSec`, so a consumer needs it refreshed to show a * growing stall; re-reporting on every 5s check would be needless churn. Derived * from `stallSec` rather than fixed at 60s so a deliberately short stallSec (a * test, or a user who wants fast feedback) gets proportionally fast updates * instead of one report and then a minute of silence. */ function stallRereportMs(stallSec: number): number { return Math.max(Math.min(stallSec, 60), 10) * 1000; } /** * R-BASH-16. Stall information lives in a namespaced sub-object so every existing * `BashToolDetails` consumer — pi's own renderer included — is unaffected. */ export interface AgiStallMarker { stalled: true; /** Seconds since the last byte of output. */ quietSec: number; /** Seconds since the command started. */ elapsedSec: number; /** The timeout that will eventually terminate this command. */ timeoutSec: number; } export interface AgiBashDetails extends BashToolDetails { agiStall?: AgiStallMarker; } /** * Resolve the effective timeout for a command (R-BASH-3, R-BASH-4). * * Pure and exported for tests. A model-supplied timeout is honoured but clamped; * an omitted one falls back to the longest matching per-command override, then to * the default. */ export function resolveTimeout(command: string, requested: number | undefined, config: BashConfig): number { if (requested !== undefined && Number.isFinite(requested) && requested > 0) { return Math.min(Math.floor(requested), config.maxTimeoutSec); } // Longest prefix wins, so "npm run build" is not shadowed by "npm". const normalized = command.trim().replace(/\s+/g, " "); let best: number | undefined; let bestLength = -1; for (const [prefix, seconds] of Object.entries(config.timeoutOverrides)) { if (prefix.length <= bestLength) continue; if (normalized === prefix || normalized.startsWith(`${prefix} `)) { best = seconds; bestLength = prefix.length; } } return Math.min(best ?? config.defaultTimeoutSec, config.maxTimeoutSec); } /** A general role/time budget: no command-name policy and no restriction on workers. */ export function applyOrchestratorForegroundBudget( timeoutSec: number, agiEnabled: boolean, foregroundSec = ORCHESTRATOR_FOREGROUND_BASH_SEC, ): number { if (!agiEnabled) return timeoutSec; const ceiling = Number.isFinite(foregroundSec) ? Math.max(1, Math.floor(foregroundSec)) : ORCHESTRATOR_FOREGROUND_BASH_SEC; return Math.min(timeoutSec, ceiling); } /** * R-BASH-5. pi's timeout error already carries the partial output; without the * guidance below the model retries the identical command and hangs again. */ export function timeoutGuidance(command: string, timeoutSec: number, orchestratorForeground = false): string { if (orchestratorForeground) { return ( `\n\nThe AGI orchestrator foreground budget ended so the parent can resume supervision.\n` + `Give expected multi-minute computation to a detached worker with agi_delegate, including the ` + `objective, context, constraints, desired evidence, and success check. ` + `The parent can review or steer it on the worker wake.\n` + `For a genuine external service, start a clearly identified background process with output redirected, ` + `record how to inspect it, then schedule a re-check.` ); } // BUG-14: `timeoutSec * 3` alone suggested 900s only when the default 300s // applied. For a command that had already been given a short explicit timeout // (say 30s), the suggestion was 90s — usually still too small, so the model // burns another turn on another timeout. A 900s floor makes the retry likely // to actually finish, still clamped to an hour. const suggested = Math.min(Math.max(timeoutSec * 3, 900), 3600); return ( `\n\nThis timeout was applied by pi-agi BashGuard (pi itself has no default timeout).\n` + `If this command is expected to take longer, retry with an explicit timeout:\n` + ` bash({ command: ${JSON.stringify(command.length > 120 ? `${command.slice(0, 117)}...` : command)}, timeout: ${suggested} })\n` + `For a long-running server or watcher, run it detached with output redirected to a file, then poll the file:\n` + ` nohup > /tmp/out.log 2>&1 &\n` + ` sleep 5 && tail -20 /tmp/out.log` ); } /** True when pi's error message is its own timeout error rather than a command failure. */ function isTimeoutError(message: string): boolean { return /Command timed out after \d+ seconds/.test(message); } /** * Messages already surfaced this session, so a config file read on every single * bash call produces one notification rather than one per command (R-CONF-2). * Keyed by message text, so a *changed* problem is still reported. */ const reportedDiagnostics = new Set(); function reportDiagnostics(diagnostics: ConfigDiagnostic[], ctx: ExtensionContext | undefined): void { for (const diagnostic of diagnostics) { if (reportedDiagnostics.has(diagnostic.message)) continue; reportedDiagnostics.add(diagnostic.message); // A config diagnostic is for the user, not the model: it names a file only // the user can fix, and putting it in the tool result would spend context on // something the model cannot act on. ctx?.ui.notify(`pi-agi bash config: ${diagnostic.message}`, diagnostic.severity); } } /** * Restore the bin-dir PATH prepend that pi applies to its own shells (BUG-12). * * pi's `createLocalBashOperations` only calls `getShellEnv()` when no `env` is * supplied (`packages/coding-agent/src/core/tools/bash.ts:100`). Because our * `user_bash` wrapper always supplies one, that fallback never ran and * `~/.pi/agent/bin` dropped off PATH — so `!fd` and `!rg` in the TUI stopped * resolving pi's managed binaries even though the model's `fd` still worked. * * `getShellEnv` is not exported, so this reimplements just the prepend, using the * exported `getAgentDir()` (`getBinDir()` is `getAgentDir()/bin`). The PATH key is * looked up case-insensitively because Windows uses `Path`. */ function withBinDirOnPath(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const binDir = join(getAgentDir(), "bin"); const pathKey = Object.keys(env).find((key) => key.toLowerCase() === "path") ?? "PATH"; const currentPath = env[pathKey] ?? ""; const entries = currentPath.split(delimiter).filter(Boolean); if (entries.includes(binDir)) return env; return { ...env, [pathKey]: [binDir, currentPath].filter(Boolean).join(delimiter) }; } /** * Our layer-3 hook (R-BASH-10/11). * * R-BASH-12 (composing with a `spawnHook` another extension already installed) * is **not implemented**, and is deferred in the same spirit as R-BASH-18. * pi 0.83 exposes no way to read another extension's hook: `spawnHook` is passed * into `createBashToolDefinition` by whoever constructs the definition, and a * registered tool is opaque to us. The previous version of this file took an * `existing` parameter and called it if present, but the only call site passed * `undefined`, so the branch was unreachable and the docblock claiming * composition worked was false. * * The practical exposure is small and is really R-BASH-17's "another extension * registers `bash` after ours and wins": tool registration is last-writer-wins on * the name, so two extensions overriding `bash` do not compose today either way. * If pi later exposes the installed hook, ours must still apply **last**, so the * stdin redirect wraps whatever the other hook produced — otherwise the other * hook's additions sit outside the redirect and can still read the real stdin. */ function spawnHookFor(config: BashConfig): BashSpawnHook { return (context: BashSpawnContext): BashSpawnContext => { const hardened = applyHardening({ command: context.command, env: context.env }, { env: config.hardenEnv, stdin: config.closeStdin, }); return { command: hardened.command, cwd: context.cwd, env: stripTelemetryEnv(hardened.env) }; }; } /** Keep observability configuration in Pi itself, never in model-run shells. */ export function stripTelemetryEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const childEnv = { ...env }; for (const key of Object.keys(childEnv)) { if (key.startsWith("LMNR_") || key.startsWith("OTEL_")) delete childEnv[key]; } return childEnv; } /** * Register the BashGuard `bash` override. * * Registered unconditionally at factory time (not on AGI enable): the tool * surface must be correct before the first turn, and re-registering a built-in * name mid-session would change the tool set — and therefore the prompt cache — * for a bug that has nothing to do with AGI mode. */ export function registerBashGuard(pi: ExtensionAPI, options: BashGuardOptions = {}): void { // Config is read per call so an edit to config.json takes effect without a // restart, but `enabled` is also checked here: when the guard is switched off // entirely we must not register at all, or we would shadow pi's bash with a // pass-through wrapper for no reason. // // R-CONF-1: no `ctx` exists at factory time, so no project-trust decision is // available and project config is deliberately NOT read here — user config and // env only. This is the safe asymmetry: an untrusted repo cannot prevent the // guard from registering (BUG-3 was exactly that), and a trusted repo that sets // `bash.enabled: false` still gets a pass-through wrapper, which costs a // negligible delegation but no behaviour. const startupConfig = loadBashConfig(process.cwd()); if (!startupConfig.enabled) return; // cwd is a placeholder: the definition's own cwd is only used when we do not // override it, and we always re-resolve from ctx below so /cd and worker cwds // are respected. const inner = createBashToolDefinition(process.cwd()); pi.registerTool({ name: inner.name, label: inner.label, // The description is baked into the system prompt, so it is built once from // the startup config rather than per call: changing it mid-session would // invalidate the prompt cache (F11). description: `${inner.description} Commands receive a default timeout of ${startupConfig.defaultTimeoutSec}s. Use non-interactive forms for editors, pagers, REPLs, and dev servers. While AGI mode is active, parent foreground execution is capped at ${options.orchestratorForegroundSec ?? ORCHESTRATOR_FOREGROUND_BASH_SEC}s; delegate expected multi-minute computation.`, promptSnippet: inner.promptSnippet, promptGuidelines: inner.promptGuidelines, parameters: inner.parameters, // renderCall / renderResult deliberately omitted: F10 inherits pi's bash // renderers per slot, which keeps the elapsed timer and truncation UI. async execute(toolCallId, params, signal, onUpdate, ctx) { const input = params as BashToolInput; const command = input.command; const cwd = ctx?.cwd ?? process.cwd(); // R-CONF-1: `ctx` carries the live project-trust decision, so it is passed // through rather than captured. Without a ctx there is no trust decision // to consult, and loadBashConfig then reads user config and env only. const { config, diagnostics } = loadBashConfigWithDiagnostics(cwd, { isProjectTrusted: ctx === undefined ? undefined : () => ctx.isProjectTrusted(), }); // R-CONF-2: config problems are surfaced, not swallowed. Notified once per // distinct message per session — a per-call notification for a config file // that is read on every bash call would be unusable. reportDiagnostics(diagnostics, ctx); // Layer 2 (R-BASH-6/7). A block costs one turn and teaches the right // form; a hang costs the timeout, the partial output, and often the run. // Throwing is the only error channel available to a tool (F8, F16). if (!config.allowInteractive) { const verdict = classifyInteractive(command); if (verdict !== undefined) { throw new Error( `${verdict.message}\n\n` + `Command: ${command}\n` + `(pi-agi BashGuard, layer 2. If this classification is wrong, set bash.allowInteractive ` + `in .pi/agi/config.json or export PI_AGI_BASH_ALLOW_INTERACTIVE=1, and report the false positive.)`, ); } } // Layer 1 (R-BASH-3/4). The whole point of the override. const configuredTimeoutSec = resolveTimeout(command, input.timeout, config); const orchestratorActive = options.isAgiEnabled?.() === true; const timeoutSec = applyOrchestratorForegroundBudget( configuredTimeoutSec, orchestratorActive, options.orchestratorForegroundSec, ); const foregroundBudgetApplied = orchestratorActive && timeoutSec < configuredTimeoutSec; // Layer 3 (R-BASH-10/11/12) is installed per call, because cwd is only // known here. Building the definition per call is cheap: it is a plain // object literal with no I/O. const definition = createBashToolDefinition(cwd, { spawnHook: spawnHookFor(config), }); // Layer 4 (R-BASH-14/15/16). pi does not expose `onData`, but it emits an // onUpdate per output flush (throttled to 100ms), so the update stream is // a faithful proxy for output arrival. We record the time of each update // and surface a marker; we never kill — the timeout owns termination // (R-BASH-15), because a long compile can legitimately be silent for // minutes. const startedAt = Date.now(); let lastOutputAt = startedAt; let lastForwarded: AgentToolResult | undefined; // BUG-5: the re-report gate was `stallReported && quietSec % 60 !== 0`. // `quietSec` counts from `lastOutputAt` while the timer ticks off // `startedAt`, so for any sub-second offset between the two (i.e. always, // once a single byte of output has arrived) `quietSec` never lands on a // multiple of 60 and the marker fired exactly once, forever. R-BASH-15's // consumer needs `quietSec` to keep refreshing, so instead of a modulus on // a value we do not control, track when we last reported and re-report on a // fixed interval. let lastStallReportAt: number | undefined; const wrappedUpdate = onUpdate === undefined ? undefined : (update: AgentToolResult) => { lastOutputAt = Date.now(); lastForwarded = update; // New output means the command is no longer stalled; the next // quiet period must be able to report again. lastStallReportAt = undefined; onUpdate(update); }; const rereportMs = stallRereportMs(config.stallSec); const stallTimer = setInterval(() => { if (onUpdate === undefined) return; const now = Date.now(); const quietSec = Math.floor((now - lastOutputAt) / 1000); const elapsedSec = Math.floor((now - startedAt) / 1000); // Both conditions per R-BASH-14: a command that has only just started // has not stalled, it is merely young. if (quietSec <= config.stallSec || elapsedSec <= config.stallSec) return; if (lastStallReportAt !== undefined && now - lastStallReportAt < rereportMs) return; lastStallReportAt = now; const details: AgiBashDetails = { ...(lastForwarded?.details ?? {}), agiStall: { stalled: true, quietSec, elapsedSec, timeoutSec }, }; // BUG-5: `content: lastForwarded?.content ?? []` replayed the last chunk // of real output as if it were new, so a consumer appending updates saw // the same lines again every report. The marker lives entirely in // `details` (R-BASH-16); an empty content array says "nothing new was // produced", which is precisely what a stall means. onUpdate({ content: [], details }); }, STALL_CHECK_MS); // An unref'd timer must never be the reason the process stays alive. stallTimer.unref?.(); try { const result = await definition.execute( toolCallId, { command, timeout: timeoutSec }, signal, wrappedUpdate, ctx, ); return result as AgentToolResult; } catch (error) { // R-BASH-5: append guidance to pi's own timeout error, preserving the // partial output it already carries. const message = error instanceof Error ? error.message : String(error); if (isTimeoutError(message)) { throw new Error(`${message}${timeoutGuidance(command, timeoutSec, foregroundBudgetApplied)}`); } throw error; } finally { clearInterval(stallTimer); } }, }); } /** * Layer 3 for the user's own `!`/`!!` commands (R-BASH-17). * * A human's command is **never** blocked and never gets a timeout: they are at a * terminal, they can see it hang, and they can Ctrl-C. What they do get is the * env hardening, so `!git log` does not open a pager inside the TUI. Returning * `operations` routes execution through our wrapper; returning nothing would let * pi use its own unhardened local operations. */ export function registerUserBashHardening(pi: ExtensionAPI): void { pi.on("user_bash", async (event, ctx) => { // R-CONF-1: same trust gate as the tool path. A `!` command is the user's // own, but the config that shapes it must still not come from an // untrusted repo. const { config, diagnostics } = loadBashConfigWithDiagnostics(event.cwd ?? ctx.cwd, { isProjectTrusted: () => ctx.isProjectTrusted(), }); reportDiagnostics(diagnostics, ctx); if (!config.enabled || !config.applyToUserBash) return undefined; if (!config.hardenEnv && !config.closeStdin) return undefined; return { operations: { exec: async (command, cwd, options) => { // pi's own local operations are reused for execution, so all the // spawn, kill-tree and transport details stay pi's. We only rewrite // the command and the env. // // BUG-12: the base env must carry pi's bin-dir PATH prepend. Supplying // any `env` at all suppresses pi's own `getShellEnv()` fallback, so // without this `!fd` and `!rg` stop finding pi's managed binaries. const hardened = applyHardening( { command, env: withBinDirOnPath(options.env ?? { ...process.env }) }, { env: config.hardenEnv, stdin: config.closeStdin }, ); return createLocalBashOperations().exec(hardened.command, cwd, { ...options, env: hardened.env, }); }, }, }; }); }