/** * Security and validation helpers for pi-subagent. * * Centralizes all trust-boundary checks so that internal callers (tool handler, * service/event path, parallel executor, chain executor) cannot bypass them. */ import * as fs from "node:fs"; import * as path from "node:path"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- /** * Pi's built-in tools. Agents whose effective tool set is a subset of these * run in the cheap lean loader (no extensions loaded). Any other tool name * (web_search, serena_*, munin_*, …) requires loading the parent's extensions. */ export const BUILTIN_TOOLS = [ "read", "grep", "find", "ls", "bash", "edit", "write", ] as const; /** Backwards-compatible alias. */ export const ALLOWED_CHILD_TOOLS = BUILTIN_TOOLS; /** * Tools never available to child agents, regardless of inheritance. * `subagent` is the only delegation primitive today; if another extension ever * adds a delegation/spawn tool, it must be added here too (recursion guard). */ export const DENIED_CHILD_TOOLS = new Set(["subagent"]); // ponytail: keep in sync with pi-plan/extensions/lib/plan-tools.ts READ_ONLY_TOOLS // and pi-review/extensions/index.ts SAFE_REVIEW_TOOLS (additions must be mirrored). // Used by sandbox: read-only agents to inherit the full read/research toolset // (serena/web/munin/fff), not just the 4 built-ins — otherwise read-only research // subagents (scout/planner/reviewer) can't use the tools plan mode tells them to prefer. export const READ_ONLY_TOOLS: readonly string[] = [ // Built-in reads "read", "grep", "find", "ls", // FFF tools "ffgrep", "fffind", "resolve_file", "fff_multi_grep", "related_files", // Windows tools (read-only: detect, audit, path-convert, classify, doctor) "windows_shell_detect", "windows_audit_log", "windows_path_to_windows", "windows_path_to_wsl", "windows_path_to_gitbash", "windows_path_quote", "windows_safety_classify", "windows_doctor", "windows_tool_discover", "windows_wsl_list_distros", // Web tools "web_search", "web_extract", "web_map", "web_crawl", "web_screenshot", "web_pdf", "web_status", // Serena read-only "serena_status", "serena_list_tools", "serena_get_current_config", "serena_check_onboarding_performed", "serena_get_symbols_overview", "serena_find_symbol", "serena_find_declaration", "serena_find_implementations", "serena_find_referencing_symbols", "serena_search_for_pattern", "serena_get_diagnostics_for_file", // Munin read-only "munin_search", "munin_get", "munin_list", "munin_recent", "munin_capabilities", // pi-evolve read-only (reads the in-memory trajectory buffer; evolve_save is a mutation and stays out) "evolve_reflect", // pi-ux: ux_audit is pure computation over a CSS string — no fs/network side effects. "ux_audit", // A2A read-only discovery/polling; a2a_call/a2a_orchestrate can trigger remote // work and stay behind confirmation. "a2a_peers", "a2a_list", "a2a_discover", "a2a_status", "a2a_history", // Accordion context folding — reads/restores the agent's own context, no repo mutation. "unfold", "recall", ]; export const MUTATION_TOOLS: readonly string[] = ["edit", "write"]; export const EXECUTION_TOOLS: readonly string[] = ["bash"]; /** * Generic warning sink for configuration problems. Stderr so it is visible * with or without a TUI attached. Warnings are also buffered so the extension * host can surface them as TUI notifications — the interactive TUI swallows * module-load stderr, so module-load warnings alone are invisible in-TUI. */ const warnings: string[] = []; function warn(message: string): void { process.stderr.write(`[pi-subagent] ${message}\n`); warnings.push(message); } /** * Flush env-var config warnings collected at module load. Called once from * the /subagent tool handler so the warnings surface as TUI notifications * (module-load stderr is not visible inside the interactive TUI). */ export function flushWarnings(): string[] { return warnings.splice(0, warnings.length); } /** * Read a numeric value from an env var, with validation: * - undefined/empty -> defaultValue (no warning) * - not a finite number (NaN) -> defaultValue + warning * - below `min` -> `min` + warning * - above `max` -> `max` + warning * - otherwise -> the parsed value (no warning) */ export function getNumericEnvVar( envVar: string, defaultValue: number, min: number, max: number, ): number { const raw = process.env[envVar]; if (raw === undefined || raw === "") return defaultValue; const value = Number(raw); if (!Number.isFinite(value)) { warn(`ENV VAR: ${envVar}="${raw}" is not a number; using default ${defaultValue}.`); return defaultValue; } if (value < min) { warn(`ENV VAR: ${envVar}=${value} is below the minimum of ${min}; using ${min}.`); return min; } if (value > max) { warn(`ENV VAR: ${envVar}=${value} is above the maximum of ${max}; using ${max}.`); return max; } return value; } /** * Absolute maximum timeout. Any requested value above this cap is rejected * at validation time rather than silently clamped. */ export const MAX_TIMEOUT_MS = 60 * 60 * 1_000; // 60 minutes /** * Timeout caps, in minutes, shared by getNumericEnvVar for both env vars. * No timeout may be shorter than 1 minute; the inactivity window may not * exceed the 60-minute absolute maximum. */ const MIN_TIMEOUT_MINS = 1; const MAX_TIMEOUT_MINS = MAX_TIMEOUT_MS / 60_000; /** * Default inactivity timeout. Real SDK lifecycle activity resets this window; * the runner enforces a fixed absolute cap (HARD_TIMEOUT_MS). * * Reads PI_SUBAGENT_INACTIVITY_TIMEOUT_MINS (default 3 min, range 1–60) and * PI_SUBAGENT_HARD_TIMEOUT_MINS (default 20 min, range 1–60) from env via * getNumericEnvVar, which warns on invalid/out-of-range values. The hard cap * is additionally clamped up to the inactivity window: a lifetime cap smaller * than the idle window is nonsensical. */ const INACTIVITY_TIMEOUT_MINS = getNumericEnvVar("PI_SUBAGENT_INACTIVITY_TIMEOUT_MINS", 3, MIN_TIMEOUT_MINS, MAX_TIMEOUT_MINS); let hardTimeoutMins = getNumericEnvVar("PI_SUBAGENT_HARD_TIMEOUT_MINS", 20, MIN_TIMEOUT_MINS, MAX_TIMEOUT_MINS); if (hardTimeoutMins < INACTIVITY_TIMEOUT_MINS) { warn( `PI_SUBAGENT_HARD_TIMEOUT_MINS (${hardTimeoutMins}) is below the inactivity window (${INACTIVITY_TIMEOUT_MINS}); raising hard cap to ${INACTIVITY_TIMEOUT_MINS}.`, ); hardTimeoutMins = INACTIVITY_TIMEOUT_MINS; } export const DEFAULT_TIMEOUT_MS = INACTIVITY_TIMEOUT_MINS * 60 * 1_000; export const HARD_TIMEOUT_MS = hardTimeoutMins * 60 * 1_000; // --------------------------------------------------------------------------- // Canonical result status // --------------------------------------------------------------------------- export type SubagentStatus = "success" | "partial" | "error" | "aborted" | "timeout"; /** Pi SDK stop reasons that indicate successful completion. */ const SUCCESS_REASONS = new Set(["stop", "end_turn", "completed"]); /** Pi SDK stop reasons that indicate a partial / truncated response. */ const PARTIAL_REASONS = new Set(["length", "max_tokens", "context_limit"]); /** Pi SDK stop reasons that indicate a provider or tool error. */ const ERROR_REASONS = new Set([ "error", "tool_error", "authentication_error", "provider_error", "content_filter", ]); /** * Map a raw stop reason string to a canonical SubagentStatus. * * `isAborted` and `isTimeout` take precedence because the Pi SDK may emit * "aborted" or "error" for both cases. Callers should pass the flags derived * from their own abort controllers. */ export function classifyStopReason( reason: string | undefined, isAborted: boolean, isTimeout: boolean, ): SubagentStatus { if (isAborted) return "aborted"; if (isTimeout) return "timeout"; if (!reason) return "success"; if (SUCCESS_REASONS.has(reason)) return "success"; if (PARTIAL_REASONS.has(reason)) return "partial"; if (ERROR_REASONS.has(reason)) return "error"; // Unknown stop reason — classify conservatively. return "error"; } // --------------------------------------------------------------------------- // Safe working-directory resolver // --------------------------------------------------------------------------- export interface SafeCwdOptions { /** The workspace root (parent session's cwd). Must be an absolute path. */ workspaceRoot: string; /** The child's requested working directory, if any. */ childCwd?: string; /** * Trusted user setting that allows child cwd outside the workspace. * Must never come from the tool-calling model. */ allowExternalCwd?: boolean; } export interface SafeCwdResult { path: string; error?: string; } /** * Resolve a child working directory that is guaranteed to be inside the * workspace root. * * Rules: * - No childCwd → return workspaceRoot. * - Relative paths are resolved relative to workspaceRoot. * - Absolute paths outside workspaceRoot are rejected unless allowExternalCwd. * - `..` traversal that escapes workspaceRoot is rejected. * - Symlinks are resolved via realpath and checked against workspaceRoot. * - Non-existent directories are rejected. * - Paths that are files instead of directories are rejected. */ export function resolveSafeCwd(options: SafeCwdOptions): SafeCwdResult { const { workspaceRoot, childCwd, allowExternalCwd } = options; // Normalise workspace root to an absolute canonical path. const resolvedRoot = resolveCanonical(workspaceRoot); if (!resolvedRoot) { return { path: "", error: `Workspace root does not exist: ${workspaceRoot}` }; } if (!isDirectorySync(resolvedRoot)) { return { path: "", error: `Workspace root is not a directory: ${workspaceRoot}` }; } // No child cwd → use workspace root. if (!childCwd) { return { path: resolvedRoot }; } // Resolve the child path. const absPath = path.resolve(resolvedRoot, childCwd); // If the resolved path escapes the workspace via `..`, the resolved path // will differ from the canonical workspace root prefix. We check by // resolving the canonical absolute path. const canonicalPath = resolveCanonical(absPath); if (!canonicalPath) { return { path: "", error: `Child working directory does not exist: ${childCwd}` }; } if (!isDirectorySync(canonicalPath)) { return { path: "", error: `Child working directory is a file, not a directory: ${childCwd}` }; } // Check if the child path is inside the workspace. if (!isPathInside(canonicalPath, resolvedRoot)) { if (allowExternalCwd) { return { path: canonicalPath }; } return { path: "", error: `Child working directory "${childCwd}" is outside the workspace root "${workspaceRoot}". ` + `Paths outside the workspace are rejected by default.`, }; } return { path: canonicalPath }; } /** * Check if `child` is inside `parent` or equal to it, after resolving both * to canonical paths. Rejects `..` traversal that escapes `parent`. */ function isPathInside(child: string, parent: string): boolean { // Both must be absolute. if (!path.isAbsolute(child) || !path.isAbsolute(parent)) return false; const relative = path.relative(parent, child); // relative must not start with ".." and must not be an absolute path. return !relative.startsWith("..") && !path.isAbsolute(relative); } /** * Resolve a path to its canonical real path, or return null if it doesn't exist. */ function resolveCanonical(p: string): string | null { try { return fs.realpathSync(p); } catch { return null; } } /** * Synchronously check if a path is a directory. */ function isDirectorySync(p: string): boolean { try { return fs.statSync(p).isDirectory(); } catch { return false; } } // --------------------------------------------------------------------------- // Tool allowlist validation // --------------------------------------------------------------------------- export interface ValidateToolsOptions { /** Tool names from the agent definition or service override. */ tools: string[]; /** When true, only read-only tools are permitted. Mutation/execution tools are rejected. */ readOnly?: boolean; /** * Parent session's registered tool names. When provided, a tool is accepted if * it is a built-in OR in this set (and not in DENIED_CHILD_TOOLS) — this is * how children inherit extension tools (web, serena, munin, …). When omitted, * only BUILTIN_TOOLS are accepted (lean/legacy mode). * Callers must pass a stable snapshot and not mutate it afterwards. */ availableTools?: readonly string[]; } export interface ValidateToolsResult { /** Deduplicated and validated tool names. */ tools: string[]; /** Validation errors, if any. Empty array means valid. */ errors: string[]; } /** * Validate agent tool names against the allowlist. * * Always strips or rejects `subagent` to prevent recursive delegation. * Deduplicates tool names. * When `readOnly` is true, only READ_ONLY_TOOLS are permitted. */ /** * Whether a tool set contains any extension tool (non-built-in). When true the * runner loads the parent's extensions into the child; when false it uses the * lean empty-loader stub, saving system-prompt tokens. Recon agents with an * explicit built-in-only `tools` line stay cheap automatically. */ export function needsExtensions(tools: readonly string[]): boolean { const builtins = new Set(BUILTIN_TOOLS); return tools.some((t) => !builtins.has(t)); } export function validateAgentTools(options: ValidateToolsOptions): ValidateToolsResult { const { readOnly = false, availableTools } = options; const extensionTools = availableTools ? new Set(availableTools) : undefined; const seen = new Set(); const tools: string[] = []; const errors: string[] = []; for (const raw of options.tools) { const tool = raw.trim(); if (!tool) continue; // Silently strip denied tools (e.g. "subagent") regardless of casing. They // are never available to children — whether explicitly listed in an agent's // `tools:` line or inherited via parentToolNames. This matches Claude Code, // which removes denied tools from subagents "even when listed in the tools // field". No error: inheritance must not crash on a tool the child can't have. if (DENIED_CHILD_TOOLS.has(tool.toLowerCase())) { continue; } // Check against allowlist: built-ins, plus inherited extension tools when availableTools given. const isBuiltin = BUILTIN_TOOLS.includes(tool as any); const isExtension = extensionTools?.has(tool) ?? false; if (!isBuiltin && !isExtension) { const allowed = extensionTools ? "parent tools" : BUILTIN_TOOLS.join(", "); errors.push(`Unknown tool "${tool}". Allowed tools: ${allowed}.`); continue; } // Check read-only constraint if (readOnly && !READ_ONLY_TOOLS.includes(tool)) { errors.push( `Tool "${tool}" is not allowed in read-only mode. Read-only tools: ${READ_ONLY_TOOLS.join(", ")}.`, ); continue; } // Deduplicate if (seen.has(tool)) continue; seen.add(tool); tools.push(tool); } return { tools, errors }; } // --------------------------------------------------------------------------- // Timeout validation // --------------------------------------------------------------------------- export interface NormalizeTimeoutOptions { /** Requested timeout value from tool params, if any. */ requested?: number; /** Global default when no timeout is specified. */ defaultValue?: number; /** Absolute maximum allowed value. */ maxValue?: number; } export interface NormalizeTimeoutResult { /** The validated timeout in milliseconds, or undefined if none was set and no default applies. */ timeoutMs: number | undefined; /** Error message if the value is invalid. */ error?: string; } /** * Validate and normalise a timeout value. * * Rules: * - Must be a finite integer if provided. * - Must be positive (> 0). * - Must not exceed maxValue. * - If no value is provided, defaultValue is used. * - Returns undefined only when no value and no default are configured. */ export function normalizeTimeout(options: NormalizeTimeoutOptions): NormalizeTimeoutResult { const { requested, defaultValue = DEFAULT_TIMEOUT_MS, maxValue = MAX_TIMEOUT_MS } = options; if (requested === undefined || requested === null) { // No explicit timeout — apply default. return { timeoutMs: defaultValue }; } if (typeof requested !== "number" || !Number.isFinite(requested)) { return { timeoutMs: undefined, error: "Timeout must be a finite number." }; } if (!Number.isInteger(requested)) { return { timeoutMs: undefined, error: "Timeout must be an integer (milliseconds)." }; } if (requested <= 0) { return { timeoutMs: undefined, error: "Timeout must be a positive integer." }; } if (requested > maxValue) { return { timeoutMs: undefined, error: `Timeout ${requested}ms exceeds maximum allowed ${maxValue}ms (${maxValue / 60_000} minutes).`, }; } return { timeoutMs: requested }; } // --------------------------------------------------------------------------- // Child timeout resolution (shared by tool path and service path) // --------------------------------------------------------------------------- export interface ResolveChildTimeoutsOptions { /** Per-call timeout in ms (tool `timeout` param / service `timeout` option). */ requested?: number; /** Agent frontmatter `timeout:` in minutes. */ agentTimeoutMins?: number; /** Caller-wide timeout in ms, used when nothing more specific is set. */ globalTimeout?: number; } export interface ResolveChildTimeoutsResult { /** Idle (inactivity) timeout in ms for the child run. */ timeoutMs?: number; /** Hard lifetime cap in ms — never shorter than the idle window. */ hardTimeoutMs?: number; /** Error message if the value is invalid. */ error?: string; } /** * Resolve a child run's effective timeouts — the single resolver shared by the * tool path (index.ts) and the service path (service.ts runNamedAgent). * * Precedence: per-call timeout > agent frontmatter `timeout:` > global/caller * timeout. The hard lifetime cap must never be shorter than the idle window * (same invariant as the env-var clamp above) — an agent with `timeout: 45` * under a 20-min default cap would otherwise be hard-killed mid-stream while * visibly producing deltas. */ export function resolveChildTimeouts(options: ResolveChildTimeoutsOptions): ResolveChildTimeoutsResult { const agentMs = options.agentTimeoutMins ? options.agentTimeoutMins * 60_000 : undefined; const result = normalizeTimeout({ requested: options.requested ?? agentMs ?? options.globalTimeout }); if (result.error) return { error: result.error }; return { timeoutMs: result.timeoutMs, hardTimeoutMs: Math.max(HARD_TIMEOUT_MS, result.timeoutMs ?? 0) }; } // --------------------------------------------------------------------------- // Abort signal composition // --------------------------------------------------------------------------- /** * Combine multiple abort signals into one, with correct listener cleanup. * * Works as a polyfill for AbortSignal.any() with proper cleanup semantics: * - If any input signal is already aborted, the returned signal aborts immediately. * - All event listeners are removed after the first abort or when `cleanup()` is called. * * Returns an object with the combined signal and a cleanup function. * Callers MUST call `cleanup()` in a `finally` block. */ export function createCombinedAbortSignal( signals: (AbortSignal | undefined | null | false)[], ): { signal: AbortSignal; cleanup: () => void } { const valid = signals.filter(Boolean) as AbortSignal[]; if (valid.length === 0) { const controller = new AbortController(); return { signal: controller.signal, cleanup: () => {} }; } if (valid.length === 1) { return { signal: valid[0], cleanup: () => {} }; } // Check if any is already aborted. for (const sig of valid) { if (sig.aborted) { const controller = new AbortController(); controller.abort(sig.reason); return { signal: controller.signal, cleanup: () => {} }; } } // Use native AbortSignal.any when available. if (typeof (AbortSignal as any).any === "function") { const combined = (AbortSignal as any).any(valid); return { signal: combined, cleanup: () => {} }; } // Manual fallback: create a controller and forward all signals. const controller = new AbortController(); const listeners: Array<() => void> = []; for (const sig of valid) { const handler = () => { controller.abort(sig.reason); }; sig.addEventListener("abort", handler, { once: true }); listeners.push(() => sig.removeEventListener("abort", handler)); } const cleanup = () => { for (const remove of listeners) { try { remove(); } catch { // Best-effort cleanup. } } }; return { signal: controller.signal, cleanup }; } // --------------------------------------------------------------------------- // Execution request validation // --------------------------------------------------------------------------- export const MAX_PARALLEL_TASKS = 8; export const MAX_CONCURRENCY = 4; export const MAX_CHAIN_LENGTH = 50; export const PER_TASK_OUTPUT_CAP = 50 * 1024; // 50 KB export const MAX_INSTRUCTIONS_LENGTH = 16 * 1024; // 16 KB export interface ValidateExecutionRequestOptions { agentName?: string; task?: string; tasks?: unknown[]; chain?: unknown[]; timeout?: number; } export interface ValidationError { field: string; message: string; } /** * Generic validation for execution requests. * Enforces limits that TypeBox schemas may not cover, and defends against * internal callers that bypass the public tool schema. */ export function validateExecutionRequest( options: ValidateExecutionRequestOptions, ): ValidationError[] { const errors: ValidationError[] = []; // Timeout (single/parallel/chain share one requested timeout) if (options.timeout !== undefined) { const t = normalizeTimeout({ requested: options.timeout }); if (t.error) { errors.push({ field: "timeout", message: t.error }); } } // Agent name if (options.agentName !== undefined) { if (typeof options.agentName !== "string" || options.agentName.trim().length === 0) { errors.push({ field: "agent", message: "Agent name must be a non-empty string." }); } } // Task if (options.task !== undefined) { if (typeof options.task !== "string") { errors.push({ field: "task", message: "Task must be a string." }); } } // Parallel tasks if (options.tasks !== undefined) { if (!Array.isArray(options.tasks)) { errors.push({ field: "tasks", message: "Tasks must be an array." }); } else { if (options.tasks.length > MAX_PARALLEL_TASKS) { errors.push({ field: "tasks", message: `Too many parallel tasks (${options.tasks.length}). Maximum is ${MAX_PARALLEL_TASKS}.`, }); } for (let i = 0; i < options.tasks.length; i++) { const t = options.tasks[i] as Record; if (!t || typeof t.agent !== "string" || !t.agent.trim()) { errors.push({ field: `tasks[${i}].agent`, message: "Agent name must be a non-empty string." }); } if (typeof t.task !== "string") { errors.push({ field: `tasks[${i}].task`, message: "Task must be a string." }); } } } } // Chain if (options.chain !== undefined) { if (!Array.isArray(options.chain)) { errors.push({ field: "chain", message: "Chain must be an array." }); } else { if (options.chain.length > MAX_CHAIN_LENGTH) { errors.push({ field: "chain", message: `Too many chain steps (${options.chain.length}). Maximum is ${MAX_CHAIN_LENGTH}.`, }); } for (let i = 0; i < options.chain.length; i++) { const s = options.chain[i] as Record; if (!s || typeof s.agent !== "string" || !s.agent.trim()) { errors.push({ field: `chain[${i}].agent`, message: "Agent name must be a non-empty string." }); } if (typeof s.task !== "string") { errors.push({ field: `chain[${i}].task`, message: "Task must be a string." }); } } } } return errors; } /** * Truncate parallel task output to the per-task cap. */ export function truncateParallelOutput(output: string): string { const byteLength = Buffer.byteLength(output, "utf8"); if (byteLength <= PER_TASK_OUTPUT_CAP) return output; let truncated = output.slice(0, PER_TASK_OUTPUT_CAP); while (Buffer.byteLength(truncated, "utf8") > PER_TASK_OUTPUT_CAP) { truncated = truncated.slice(0, -1); } return `${truncated}\n\n[Output truncated: ${byteLength - Buffer.byteLength(truncated, "utf8")} bytes omitted.]`; } // --------------------------------------------------------------------------- // Rate-limit error detection // --------------------------------------------------------------------------- const RATE_LIMIT_PATTERNS = [ /\b429\b/, /\b529\b/, /rate[\s_]limit/i, /ratelimit/i, /too[\s_]many[\s_]requests/i, /quota[\s_]exhausted/i, /quota[\s_]exceeded/i, /exceeded[\s_](?:your[\s_])?(?:current[\s_])?quota/i, /insufficient_quota/i, /resource[\s_]exhausted/i, /capacity[\s_]exceeded/i, /usage[\s_]limit/i, /overloaded/i, // Credential-cooldown errors (e.g. "All credentials for model X are // cooling down") are capacity signals too: the model may be unavailable // NOW but the next fallback candidate may not be in cooldown. /credential[\s_]cooldown|cooldown[\s_]window|cooling[\s_]down/i, ]; /** * Check if an error message indicates a rate-limit / quota-exhaustion condition. * * Used by the sub-agent runtime to trigger automatic model fallback when the * primary model candidate hits a 429 or similar server-side capacity error. */ export function isRateLimitError(message: string): boolean { return RATE_LIMIT_PATTERNS.some(p => p.test(message)); } /** * Whether a sub-agent result should trigger model fallback: a rate-limit / * credential-cooldown error, or an IDLE timeout (a stalled stream with zero * events for the whole idle window — a known router/provider failure mode). * The HARD lifetime cap is terminal: a task that ran 20 min is genuinely * huge, not a stall, and retrying it on another model doubles the cost. */ export function isRetryableModelResult(result: { errorMessage?: string; stopReason?: string; status?: string; }): boolean { if (result.errorMessage && isRateLimitError(result.errorMessage)) return true; return ( result.stopReason === "timeout" && result.status === "timeout" && !String(result.errorMessage ?? "").includes("Hard timeout") ); }