/** * Minimal configuration loader for the `bash` block only (§17.2). * * Deliberately not a general config subsystem: Phase 3 needs exactly one block, * and a loader that covers every key in §17.2 would have to guess at the schema * of rules that are not implemented yet. When the real loader arrives, this file * becomes its `bash` section reader and nothing else changes. * * Precedence, lowest to highest: defaults, `~/.pi/agent/agi/config.json`, * `/.pi/agi/config.json`, `PI_AGI_BASH_*` env vars. Project overrides user * because timeouts are a property of the repo being worked on; env wins over * both because it is the only channel available to a spawned worker. * * Project config is read **only** when the caller proves project trust * (R-CONF-1). See `loadBashConfig`. */ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { getAgentDir } from "@earendil-works/pi-coding-agent"; export interface BashConfig { /** Master switch. False leaves pi's own bash tool untouched. */ enabled: boolean; /** Injected when the model omits `timeout` (R-BASH-3). */ defaultTimeoutSec: number; /** Upper clamp for a model-supplied `timeout` (R-BASH-3). */ maxTimeoutSec: number; /** Leading-token prefix -> timeout seconds, for known-slow commands (R-BASH-4). */ timeoutOverrides: Record; /** True disables layer 2 entirely (R-BASH-9). */ allowInteractive: boolean; /** Inject the non-interactive env (R-BASH-10). */ hardenEnv: boolean; /** Wrap the command so stdin is /dev/null (R-BASH-11). */ closeStdin: boolean; /** Quiet seconds before a stall is surfaced (R-BASH-14). */ stallSec: number; /** Apply layer 3 to the user's own `!`/`!!` commands (R-BASH-17). */ applyToUserBash: boolean; } /** * R-BASH-4's defaults. Matched against the command's leading tokens, longest * prefix first, so "npm run build" beats a hypothetical "npm" entry. */ const DEFAULT_TIMEOUT_OVERRIDES: Record = { "npm install": 900, "npm ci": 900, "npm test": 900, "npm run build": 900, "pnpm install": 900, "yarn install": 900, "bun install": 900, "cargo build": 1800, "cargo test": 1800, "docker build": 1800, make: 1800, }; export const DEFAULT_BASH_CONFIG: BashConfig = { enabled: true, defaultTimeoutSec: 300, maxTimeoutSec: 3600, timeoutOverrides: DEFAULT_TIMEOUT_OVERRIDES, allowInteractive: false, hardenEnv: true, closeStdin: true, stallSec: 120, applyToUserBash: true, }; /** * R-CONF-2 diagnostics. Unknown keys, clamped values and malformed files all * produce one of these instead of failing silently. Collected rather than thrown: * a bad config must never prevent the tool from running. */ export interface ConfigDiagnostic { severity: "warning" | "info"; message: string; } /** Every key the `bash` block understands, for the R-CONF-2 unknown-key check. */ const KNOWN_BASH_KEYS = new Set([ "enabled", "defaultTimeoutSec", "maxTimeoutSec", "timeoutOverrides", "allowInteractive", "hardenEnv", "closeStdin", "stallSec", "applyToUserBash", ]); /** * R-CONF-2 ranges. A value outside the range is clamped **with** a diagnostic * rather than ignored, because silently keeping the default when the user asked * for 999999 is indistinguishable from the config not being read at all. * * `min` is a real usable floor, not 1: clamping `defaultTimeoutSec: 0` to one * second would make every command time out instantly, which is worse than the * bad config. Values at or below zero are nonsense rather than out-of-range, so * they fall back to the default (still with a diagnostic) — see `rangedIntOf`. */ const RANGES: Record = { defaultTimeoutSec: { min: 5, max: 86400 }, maxTimeoutSec: { min: 5, max: 86400 }, stallSec: { min: 5, max: 86400 }, }; function readJson(path: string, diagnostics: ConfigDiagnostic[]): Record | undefined { let raw: string; try { raw = readFileSync(path, "utf8"); } catch { // Absent is the normal case and is not a diagnostic. return undefined; } try { const parsed: unknown = JSON.parse(raw); if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { diagnostics.push({ severity: "warning", message: `${path}: top level must be a JSON object; the file was ignored and defaults are in effect.`, }); return undefined; } return parsed as Record; } catch (error) { // R-CONF-2: falling back to defaults is correct (a config error must never // wedge the tool — the Phase 2 digest-cache lesson), but it must be loud. // Silently ignoring the file makes a typo look like the setting had no effect. diagnostics.push({ severity: "warning", message: `${path}: malformed JSON (${error instanceof Error ? error.message : String(error)}). ` + `The file was ignored entirely and pi-agi bash defaults are in effect.`, }); return undefined; } } function boolOf(value: unknown, fallback: boolean): boolean { return typeof value === "boolean" ? value : fallback; } /** * Read a positive-integer key, reporting a wrong type and clamping an * out-of-range value with a diagnostic (R-CONF-2). */ function rangedIntOf( key: string, value: unknown, fallback: number, source: string, diagnostics: ConfigDiagnostic[], ): number { if (value === undefined) return fallback; if (typeof value !== "number" || !Number.isFinite(value)) { diagnostics.push({ severity: "warning", message: `${source}: bash.${key} must be a number, got ${JSON.stringify(value)}; using ${fallback}.`, }); return fallback; } const range = RANGES[key]; const floored = Math.floor(value); // Zero and negatives are not "a bit too low", they are meaningless as a // duration. Falling back to the working default is safer than clamping to the // floor, but it is still reported so the user learns the value did nothing. if (floored <= 0) { diagnostics.push({ severity: "warning", message: `${source}: bash.${key} must be a positive number of seconds, got ${value}; using the default ${fallback}.`, }); return fallback; } if (range === undefined) return floored; if (floored < range.min) { diagnostics.push({ severity: "warning", message: `${source}: bash.${key} of ${value} is below the minimum ${range.min}; clamped to ${range.min}.`, }); return range.min; } if (floored > range.max) { diagnostics.push({ severity: "warning", message: `${source}: bash.${key} of ${value} is above the maximum ${range.max}; clamped to ${range.max}.`, }); return range.max; } return floored; } function envBool(name: string): boolean | undefined { const raw = process.env[name]; if (raw === undefined) return undefined; if (raw === "1" || raw === "true") return true; if (raw === "0" || raw === "false") return false; return undefined; } function envPosInt(name: string): number | undefined { const raw = process.env[name]; if (raw === undefined) return undefined; const n = Number(raw); return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined; } /** * Merge one `bash` block over a base config, ignoring wrong-typed fields. * * `diagnostics` and `source` are optional so the pure-merge call sites and tests * stay unchanged; when supplied, R-CONF-2 unknown-key and clamp diagnostics are * appended. */ export function mergeBashConfig( base: BashConfig, block: Record | undefined, diagnostics: ConfigDiagnostic[] = [], source = "config", ): BashConfig { if (block === undefined) return base; // R-CONF-2: an unknown key is a typo far more often than a future feature, and // a silently ignored typo is the single most confusing config failure. for (const key of Object.keys(block)) { if (!KNOWN_BASH_KEYS.has(key)) { diagnostics.push({ severity: "warning", message: `${source}: unknown key bash.${key} was ignored. Known keys: ${[...KNOWN_BASH_KEYS].sort().join(", ")}.`, }); } } const overrides = block.timeoutOverrides; let timeoutOverrides = base.timeoutOverrides; if (overrides !== null && typeof overrides === "object" && !Array.isArray(overrides)) { // Merged, not replaced: a user adding one slow command should not lose the // built-in table and silently reintroduce 300s timeouts on `cargo build`. const merged: Record = { ...base.timeoutOverrides }; for (const [key, value] of Object.entries(overrides as Record)) { if (typeof value === "number" && Number.isFinite(value) && value > 0) merged[key] = Math.floor(value); else diagnostics.push({ severity: "warning", message: `${source}: bash.timeoutOverrides["${key}"] must be a positive number, got ${JSON.stringify(value)}; entry ignored.`, }); } timeoutOverrides = merged; } else if (overrides !== undefined) { diagnostics.push({ severity: "warning", message: `${source}: bash.timeoutOverrides must be an object; it was ignored.`, }); } return { enabled: boolOf(block.enabled, base.enabled), defaultTimeoutSec: rangedIntOf("defaultTimeoutSec", block.defaultTimeoutSec, base.defaultTimeoutSec, source, diagnostics), maxTimeoutSec: rangedIntOf("maxTimeoutSec", block.maxTimeoutSec, base.maxTimeoutSec, source, diagnostics), timeoutOverrides, allowInteractive: boolOf(block.allowInteractive, base.allowInteractive), hardenEnv: boolOf(block.hardenEnv, base.hardenEnv), closeStdin: boolOf(block.closeStdin, base.closeStdin), stallSec: rangedIntOf("stallSec", block.stallSec, base.stallSec, source, diagnostics), applyToUserBash: boolOf(block.applyToUserBash, base.applyToUserBash), }; } function bashBlockOf(path: string, diagnostics: ConfigDiagnostic[]): Record | undefined { const root = readJson(path, diagnostics); if (root === undefined) return undefined; const block = root.bash; if (block === undefined) return undefined; if (block === null || typeof block !== "object" || Array.isArray(block)) { diagnostics.push({ severity: "warning", message: `${path}: "bash" must be an object; it was ignored.` }); return undefined; } return block as Record; } /** A resolved config plus whatever R-CONF-2 had to say about getting there. */ export interface LoadedBashConfig { config: BashConfig; diagnostics: ConfigDiagnostic[]; } export interface LoadBashConfigOptions { /** * R-CONF-1. Project config is read **only** when this returns true. * * Not a plain boolean: pi's trust state is a live callback on ExtensionContext, * and it must be consulted at read time rather than captured at registration. * * Omitting it means "no trust decision available", which reads as untrusted. * That is the safe direction: BUG-3 was a cloned repo shipping * `.pi/agi/config.json` with `{"bash":{"enabled":false}}` and silently * disabling the entire guard. §17.1 names timeouts as exactly the * code-execution-adjacent setting this gate exists for. */ isProjectTrusted?: () => boolean; } /** * Resolve the effective bash config for a working directory, with diagnostics. * * Called per bash invocation rather than cached, so editing config.json takes * effect without restarting pi. The cost is two small stat-and-read calls * against a command that is about to spawn a process. */ export function loadBashConfigWithDiagnostics(cwd: string, options: LoadBashConfigOptions = {}): LoadedBashConfig { const diagnostics: ConfigDiagnostic[] = []; let config = DEFAULT_BASH_CONFIG; // User scope is always trusted (§17.1) and lives under ~/.pi/agent/agi/, which // is `getAgentDir()`. It was previously read from ~/.pi/agi/ — a path that does // not exist — so user config was silently never loaded at all. const userPath = join(getAgentDir(), "agi", "config.json"); config = mergeBashConfig(config, bashBlockOf(userPath, diagnostics), diagnostics, userPath); // R-CONF-1: project scope requires proven trust. const projectPath = join(cwd, ".pi", "agi", "config.json"); let trusted = false; try { trusted = options.isProjectTrusted?.() === true; } catch { // A throwing trust callback is not a grant. trusted = false; } if (trusted) { config = mergeBashConfig(config, bashBlockOf(projectPath, diagnostics), diagnostics, projectPath); } else { // Only worth a word if a file actually exists and would have changed // something; otherwise every command in every untrusted repo says this. const ignored = bashBlockOf(projectPath, []); if (ignored !== undefined) { diagnostics.push({ severity: "warning", message: `${projectPath} was ignored because this project is not trusted (R-CONF-1). ` + `Project config can set timeouts and disable the bash guard, so it requires project trust. ` + `Trust the project in pi, or move the settings to ${userPath}.`, }); } } // Env last. PI_AGI_BASH_DISABLE mirrors PI_AGI_DISABLE's role (R-CONF-8): a // recovery path that needs no file edit, for the case where the guard itself // is the thing in the way. Env is trusted because only the user or a parent pi // can set it — an untrusted repo cannot. const disabled = envBool("PI_AGI_BASH_DISABLE"); const enabled = envBool("PI_AGI_BASH_ENABLED"); const allowInteractive = envBool("PI_AGI_BASH_ALLOW_INTERACTIVE"); const hardenEnv = envBool("PI_AGI_BASH_HARDEN_ENV"); const closeStdin = envBool("PI_AGI_BASH_CLOSE_STDIN"); const applyToUserBash = envBool("PI_AGI_BASH_APPLY_TO_USER_BASH"); const defaultTimeoutSec = envPosInt("PI_AGI_BASH_DEFAULT_TIMEOUT_SEC"); const maxTimeoutSec = envPosInt("PI_AGI_BASH_MAX_TIMEOUT_SEC"); const stallSec = envPosInt("PI_AGI_BASH_STALL_SEC"); return { config: { enabled: disabled === true ? false : (enabled ?? config.enabled), defaultTimeoutSec: defaultTimeoutSec ?? config.defaultTimeoutSec, maxTimeoutSec: maxTimeoutSec ?? config.maxTimeoutSec, timeoutOverrides: config.timeoutOverrides, allowInteractive: allowInteractive ?? config.allowInteractive, hardenEnv: hardenEnv ?? config.hardenEnv, closeStdin: closeStdin ?? config.closeStdin, stallSec: stallSec ?? config.stallSec, applyToUserBash: applyToUserBash ?? config.applyToUserBash, }, diagnostics, }; } /** Config only, for the call sites that have nowhere to surface a diagnostic. */ export function loadBashConfig(cwd: string, options: LoadBashConfigOptions = {}): BashConfig { return loadBashConfigWithDiagnostics(cwd, options).config; }