import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import type { AgentConfig } from "../../agents/agent-config.ts"; import { getCrewEnvBool } from "../../config/env-vars.ts"; import { atomicWriteFile } from "../../state/atomic-write.ts"; import { hasRunStateLayout, packageRoot, userPiRoot } from "../../utils/paths.ts"; import { PI_NO_MCP_FLOOR, piVersionAtLeast } from "../child-pi/pi-version.ts"; // U4 (pi 1.0.4 adoption): pi's CLI documents 7 thinking levels (docs/cli.md:70); // `max` completes the surface — frontmatter `thinking: max` previously fell // back to Pi's default via isValidThinkingLevel. const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"]; // FIX (2026-07-02): use packageRoot() instead of import.meta.url-relative path. // The previous path.resolve(path.dirname(fileURLToPath(import.meta.url)), // "..", "prompt", "prompt-runtime.ts") was correct in source but BROKEN // in the bundle: esbuild's __esm helper doesn't preserve per-module // import.meta.url, so the resolve lands at /prompt/... (missing // `src/`) instead of /src/prompt/... packageRoot() walks up to // find pi-crew's package.json and works correctly from both src/ and // dist/ entry points. const PROMPT_RUNTIME_EXTENSION_PATH = path.join(packageRoot(), "src", "prompt", "prompt-runtime.ts"); // D8 spec v0.7 — nested mở (default 4 depth levels: child→child→child→child); // cap giữ để chống runaway recursion từ config lỗi hoặc intentional abuse. const DEFAULT_MAX_CREW_DEPTH = 4; // Track every temp dir created in this process so we can clean them up // even if the parent is killed before child-pi.ts cleanup runs. // Prevents accumulation of /tmp/pi-crew-* dirs from crashed/killed tests. const createdTempDirs = new Set(); /** * Resolve the temp-dir base path. * Uses pi-crew's own user-root (`~/.pi/agent/pi-crew/tmp/`) so the temp * files live alongside other pi-crew state and never pollute the shared * /tmp directory. Uses `userPiRoot()` so the path stays consistent with * the rest of pi-crew (respects PI_TEAMS_HOME / PI_CODING_AGENT_DIR). */ export function getPiTempBase(): string { return path.join(userPiRoot(), "tmp"); } export interface BuildPiWorkerArgsInput { task: string; agent: AgentConfig; model?: string; sessionEnabled?: boolean; /** W2 (session-file recovery): deterministic pi session id for this worker * (`--session-id`, SDK ≥ 0.76.0 — below pi-crew's tested host floor 0.99.2, * so emitted unconditionally when set; no capability gate). Same id across * retries appends to one session file (SDK §R2.2) — cross-attempt resume. */ sessionId?: string; /** W2 (session-file recovery): directory for this worker's session JSONL * (`--session-dir`, SDK ≥ 0.30.0). Run-scoped per-worker dir, e.g. * `/sessions//` — lives and dies with run artifacts. */ sessionDir?: string; /** R3-15 (Pi 1.0.0 deep-learn r3): worker task id for the human-facing * session display name — emits `--name crew-` (cli.md:100, `-n/--name` * "Sets the session display name"). Cosmetic only: W2's deterministic * --session-id stays the machine-readable correlation key. Emitted inside * the sessionEnabled block (a name labels a persisted session; under * --no-session nothing persists). NOT stripped for surface TUI panes — a * display name is not headless-only, it labels the pane's session too * (child-pi.ts stripHeadlessOnlyFlags stays untouched). */ taskId?: string; maxDepth?: number; skillPaths?: string[]; env?: NodeJS.ProcessEnv; /** Role for tool restrictions (uses role-tools.ts config) */ role?: string; /** Per-role thinking override (teamRole.thinking). Takes precedence over agent.thinking. */ thinkingOverride?: string; /** R3-1 (Pi 1.0.0 deep-learn round 3): run-static worker header (Protocol * block, mailbox contract, workspace structure, runtime context) riding the * `--append-system-prompt` channel instead of the user-message concatenation. * Compaction summarizes the user-message span (compaction.md:150-160) but the * system prompt survives intact — the parts that must NEVER be lost move here. * Task text + dependency context stay in the user message (they SHOULD be * summarizable). Final append order: builtin → agent.systemPrompt (if any, * honoring its declared replace/append mode) → this header. Probe-verified * 2026-10-04 (/tmp/pi-r3impl): the append channel and AGENTS.md discovery * (project_context section) coexist in one system message. */ systemPromptAppend?: string; /** R3-19/D5 (hermetic worker spawns): pass `--no-extensions` on the worker * argv. Resolved via resolveHermeticWorkers — env * PI_CREW_HERMETIC_WORKERS beats this beats default TRUE. Explicit `-e * prompt-runtime` (pushed unconditionally below), agent `extensions:` * declarations, and `--skill` flags are unaffected (cli.md:186-191: * explicit -e and --skill survive --no-extensions/--no-skills). */ hermeticWorkers?: boolean; /** U9 (pi 1.0.4 version floor): the HOST pi binary's version as probed by * `pi --version` (probePiVersion — single-flight memoized per process, * injectable exec seam, never rejects), or null when unknown (probe * failed/timed out/unparseable). PURE INPUT — the builder NEVER spawns: * the async spawn path (runChildPi) awaits the memoized probe upstream and * threads the resolved string|null down through here. Drives the * version-gated flags registered in child-pi/pi-version.ts (`--no-mcp` at * the 1.0.4 floor, parity spawns only); unknown/below-floor conservatively * emits NOTHING — a pre-floor host's strict parser rejects unknown flags. */ piVersion?: string | null; } export interface BuildPiWorkerArgsResult { args: string[]; env: Record; tempDir?: string; } function isValidThinkingLevel(value: string | undefined): value is string { return value !== undefined && THINKING_LEVELS.includes(value); } export function applyThinkingSuffix(model: string | undefined, thinking: string | undefined): string | undefined { if (!model || !thinking || thinking === "off") return model; const colonIdx = model.lastIndexOf(":"); if (colonIdx !== -1 && isValidThinkingLevel(model.substring(colonIdx + 1))) return model; // Invalid config values fall back to Pi's default thinking behavior. if (!isValidThinkingLevel(thinking)) return model; return `${model}:${thinking}`; } export function currentCrewDepth(env: NodeJS.ProcessEnv = process.env): number { const raw = env.PI_CREW_DEPTH ?? env.PI_TEAMS_DEPTH ?? "0"; const parsed = Number(raw); return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0; } export function resolveCrewMaxDepth(inputMaxDepth?: number, env: NodeJS.ProcessEnv = process.env): number { const raw = env.PI_CREW_MAX_DEPTH ?? env.PI_TEAMS_MAX_DEPTH; const envDepth = raw !== undefined ? Number(raw) : NaN; if (Number.isInteger(envDepth) && envDepth >= 1 && envDepth <= 10) return envDepth; if (Number.isInteger(envDepth) && envDepth > 10) { console.warn(`PI_CREW_MAX_DEPTH=${envDepth} exceeds cap of 10, clamping to 10. Set 10 or lower to avoid this warning.`); return 10; } if (Number.isInteger(inputMaxDepth) && inputMaxDepth !== undefined && inputMaxDepth >= 1 && inputMaxDepth <= 10) return inputMaxDepth; if (Number.isInteger(inputMaxDepth) && inputMaxDepth !== undefined && inputMaxDepth > 10) { console.warn(`maxDepth=${inputMaxDepth} exceeds cap of 10, clamping to 10. Set 10 or lower to avoid this warning.`); return 10; } return DEFAULT_MAX_CREW_DEPTH; } /** * R3-19/D5 (hermetic worker spawns): resolution order mirrors * resolveSessionRecoveryEnabled — env PI_CREW_HERMETIC_WORKERS beats the * explicit config/runtime value beats default TRUE. */ export function resolveHermeticWorkers(explicit?: boolean): boolean { const env = getCrewEnvBool("PI_CREW_HERMETIC_WORKERS"); if (env !== undefined) return env; return explicit ?? true; } export function checkCrewDepth( inputMaxDepth?: number, env: NodeJS.ProcessEnv = process.env, ): { blocked: boolean; depth: number; maxDepth: number } { const depth = currentCrewDepth(env); const maxDepth = resolveCrewMaxDepth(inputMaxDepth, env); return { depth, maxDepth, blocked: depth >= maxDepth }; } /** * Create a safe temp directory with symlink protection. * 1. mkdtempSync to create the directory * 2. lstatSync to verify it is not a symlink (TOCTOU safety) * 3. realpathSync to resolve the canonical path */ /** * Create a temp dir with symlink-safety checks. Tracked in the * `createdTempDirs` Set for global cleanup. * * Exported (rather than module-private) so unit tests can populate * the tracking Set without going through the public build flow. */ export function createSafeTempDir(base: string, prefix: string): string { // FIX: Walk FULL ancestor chain for symlinks BEFORE creating any directories. // An attacker could plant a symlink at any ancestor of base (e.g., // making /home/bom/.pi -> /tmp/attacker). Walk from root to base // and verify no component is a symlink. Only THEN create base if needed. const absoluteBase = path.resolve(base); const parts = absoluteBase.split(path.sep); let accumulated = ""; if (parts[0] === "") accumulated = "/"; // Unix root for (let i = 1; i < parts.length; i++) { if (parts[i] === "") continue; accumulated = path.join(accumulated, parts[i]); try { const stat = fs.lstatSync(accumulated); if (stat.isSymbolicLink()) { // On macOS, /var → /private/var, /tmp → /private/tmp, /etc → /private/etc // are system symlinks managed by the OS. Allow them. const knownDarwinSymlinks = ["/var", "/tmp", "/etc", "/private/var", "/private/tmp", "/private/etc"]; if (process.platform === "darwin" && knownDarwinSymlinks.includes(accumulated)) continue; throw new Error("Refusing to create temp dir: ancestor is a symlink: " + accumulated); } } catch (e) { if (e instanceof Error && e.message.includes("symlink")) throw e; // Component doesn't exist yet — OK, proceed break; } } // Verify base dir itself is not a symlink before realpathSync. // Issue #1 fix: if baseDir itself is a symlink, realpathSync would // resolve to an attacker-controlled location. try { const baseStat = fs.lstatSync(base); if (baseStat.isSymbolicLink()) throw new Error("Refusing to create temp dir in symlinked base: " + base); } catch (e) { if (e instanceof Error && e.message.includes("symlink")) throw e; // ENOENT: base doesn't exist yet — will be created below. } // Create base dir only AFTER all ancestor symlink checks pass. if (!fs.existsSync(base)) fs.mkdirSync(base, { recursive: true }); // Issue #1 fix: re-validate the FULL ancestor chain immediately after // mkdirSync to close the TOCTOU window between initial validation // (lines 111-122) and directory creation. An attacker could have // deleted a validated ancestor and recreated it as a symlink in that // window. for (let i = 1; i < parts.length; i++) { if (parts[i] === "") continue; accumulated = path.join(accumulated, parts[i]); try { const stat = fs.lstatSync(accumulated); if (stat.isSymbolicLink()) { const knownDarwinSymlinks = ["/var", "/tmp", "/etc", "/private/var", "/private/tmp", "/private/etc"]; if (process.platform === "darwin" && knownDarwinSymlinks.includes(accumulated)) continue; throw new Error("Refusing to create temp dir: ancestor is a symlink (post-mkdir): " + accumulated); } } catch (e) { if (e instanceof Error && e.message.includes("symlink")) throw e; // Component doesn't exist — OK break; } } // Resolve base to canonical path before joining. // Issue #1 fix: if the base dir is deleted between the post-mkdir // validation loop (lines 135-146) and realpathSync, realpathSync throws // ENOENT. Re-validate and retry to handle fast delete+recreate races. let resolvedBase: string; let retries = 3; while (true) { try { resolvedBase = fs.realpathSync(base); break; } catch (e: unknown) { if (--retries <= 0) throw e; // ENOENT: re-validate ancestor chain and retry const code = e instanceof Object && "code" in e ? (e as { code: string }).code : undefined; if (code !== "ENOENT") throw e; } // Re-validate ancestor chain post-mkdir (same logic as lines 135-146) const revalidateParts = absoluteBase.split(path.sep); let revalidateAccumulated = ""; if (revalidateParts[0] === "") revalidateAccumulated = "/"; for (let i = 1; i < revalidateParts.length; i++) { if (revalidateParts[i] === "") continue; revalidateAccumulated = path.join(revalidateAccumulated, revalidateParts[i]); try { const stat = fs.lstatSync(revalidateAccumulated); if (stat.isSymbolicLink()) { const knownDarwinSymlinks = ["/var", "/tmp", "/etc", "/private/var", "/private/tmp", "/private/etc"]; if (process.platform === "darwin" && knownDarwinSymlinks.includes(revalidateAccumulated)) continue; throw new Error("Refusing to create temp dir: ancestor is a symlink (post-mkdir): " + revalidateAccumulated); } } catch (e) { if (e instanceof Error && e.message.includes("symlink")) throw e; // Component doesn't exist — stop re-validating, retry realpathSync break; } } } // Issue #2 fix: verify resolvedBase itself is not a symlink (TOCTOU // between realpathSync and the ancestor walk). If a symlink was // created at the base path after realpathSync returned, catch it. let resolvedBaseStat: fs.Stats; try { resolvedBaseStat = fs.lstatSync(resolvedBase); if (resolvedBaseStat.isSymbolicLink()) throw new Error("Refusing to create temp dir: resolved base is a symlink: " + resolvedBase); } catch (e) { if (e instanceof Error && e.message.includes("symlink")) throw e; // resolvedBase doesn't exist yet — OK } // Verify resolved path has no symlink ancestors. realpathSync follows // symlinks, so if any ancestor is a symlink the resolved path will be // inside the attacker's target. Catch that by walking the resolved path. const resolvedParts = resolvedBase.split(path.sep); let resolvedAccumulated = ""; if (resolvedParts[0] === "") resolvedAccumulated = "/"; // Unix root for (let i = 1; i < resolvedParts.length; i++) { if (resolvedParts[i] === "") continue; resolvedAccumulated = path.join(resolvedAccumulated, resolvedParts[i]); try { const stat = fs.lstatSync(resolvedAccumulated); if (stat.isSymbolicLink()) throw new Error("Refusing to create temp dir: resolved path contains symlink ancestor: " + resolvedAccumulated); } catch (e) { if (e instanceof Error && e.message.includes("symlink")) throw e; // Component doesn't exist — OK break; } } const rawTempDir = fs.mkdtempSync(path.join(resolvedBase, prefix)); try { const stat = fs.lstatSync(rawTempDir); if (stat.isSymbolicLink()) throw new Error("temp dir is a symlink"); } catch (e) { if (e instanceof Error && e.message.includes("symlink")) { fs.rmSync(rawTempDir, { recursive: true, force: true }); throw new Error("Refusing to use symlinked temp directory."); } throw e; } const resolved = fs.realpathSync(rawTempDir); // Track for global cleanup on shutdown / crash createdTempDirs.add(resolved); return resolved; } export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerArgsResult { // NOTE: do NOT add an argv flag like `--crew-subagent` here. Pi uses a strict // option parser and REJECTS unknown flags with a non-zero exit, which would // break every ctx.agent() call. The authoritative sub-agent identity signal // is the PI_CREW_KIND=subagent ENV var (set below) — the zombie scanner and // doctor --zombies read it from /proc//environ. The user's main session // never sets it, so it can never be matched as a sub-agent. const args = ["--mode", "json", "-p"]; if (input.sessionEnabled === false) args.push("--no-session"); // W2 (session-file recovery): deterministic session identity so a worker // that dies without a final assistant event (exitCode null / killed) can // have its last COMPLETE assistant turn tail-recovered from the session // JSONL (child-pi.ts settle path → session-recovery.ts). Both flags are // SDK-era flags older than the tested host floor (fact pack §1: session-id // 0.76.0, session-dir 0.30.0 < 0.99.2) → unconditional add, same as the // existing --no-session/--model emissions. Skipped when sessions are off — // under --no-session pi persists nothing, the flags would be meaningless. if (input.sessionEnabled !== false) { if (input.sessionId) args.push("--session-id", input.sessionId); if (input.sessionDir) args.push("--session-dir", input.sessionDir); // R3-15: human-facing display name — `crew-` makes crew workers // distinguishable in pi's session picker (flag predates nothing: -n/--name // is a long-standing CLI flag, cli.md:100 — no capability gate needed). if (input.taskId) args.push("--name", `crew-${input.taskId}`); } const resolvedModel = input.model ?? input.agent.model; // H1.a: teamRole.thinking (passed as thinkingOverride) takes precedence over agent.thinking. const effectiveThinking = input.thinkingOverride ?? input.agent.thinking; if (resolvedModel) { const modelWithThinking = applyThinkingSuffix(resolvedModel, effectiveThinking); if (modelWithThinking) args.push("--model", modelWithThinking); } // When no model resolved, pass thinking separately so Pi can apply it to the inherited parent model. if (!resolvedModel && effectiveThinking && effectiveThinking !== "off" && isValidThinkingLevel(effectiveThinking)) { args.push("--thinking", effectiveThinking); } // D5 (spec v0.7 §6): default loadout = FULL session (như main session) — // --no-skills/--tools CHỈ xuất hiện khi agent .md khai explicit. R3-19/D5 // reversal (2026-10-04): extension discovery is now hermetic by default // (--no-extensions below, off-switch runtime.hermeticWorkers / env // PI_CREW_HERMETIC_WORKERS) — the ambient package stack cost ~1.37s/spawn // and leaked 8 direct-exposure MCP tools + 5 host tools into every worker // (probe C §R3b.6: 431ms vs 1806ms to first stdout record). // "message" MUST stay in CONTROL_TOOLS: it is registered by prompt-runtime // behind the PI_CREW_MSG_ENABLED dormant-env gate (child-pi-spawn.ts sets it // unconditionally), but pi's --tools allowlist filters the tool SURFACE — a // control tool missing here is invisible to every frontmatter-pinned worker // (battery 2026-09-10: message dead for all builtin roles since f843e14a). const CONTROL_TOOLS = ["ask", "delegate", "message"] as const; if (input.agent.disableTools === true) { args.push("--no-tools"); // capability-locked agents giữ hành vi (goal-judge) } else { // AgentConfig.tools is string[] (parseToolsField normalizes the // frontmatter CSV), but inline/runtime-constructed agents may still // carry the raw CSV string — normalize both. const rawTools: unknown = input.agent.tools; const declared = typeof rawTools === "string" ? rawTools .split(",") .map((t) => t.trim()) .filter(Boolean) : Array.isArray(rawTools) ? rawTools.map((t) => String(t).trim()).filter(Boolean) : []; if (declared.length > 0) { const allow = new Set([...declared, ...CONTROL_TOOLS]); args.push("--tools", [...allow].join(",")); } // declared rỗng → KHÔNG truyền --tools (full default toolset — pattern // buildSubagentToolAllowlist của pi-interactive-subagents index.ts:809-811). // B2 (fix round 1): `disallowedTools:` frontmatter is a // declaration-driven denylist (opt-in like `tools:`) — NOT the // role-based policy D5 removed. // U2-lite (pi 1.0.4 `--tools` semantics drift): on 1.0.4, `--tools` no // longer cuts MCP — ambient MCP servers stay connected on parity // (NON-hermetic) spawns. Fold `mcp__*` into the exclude list to restore // the pre-1.0.4 MCP cut. Kept OUT of hermetic spawns: --no-extensions // already disables builtin:mcp (hermetic spawns are MCP-clean — the // conflict verdict in docs/reviews/pi-1.0.4-upgrade-notes-2026-10-06.md), // and the default loadout pins !--exclude-tools // (test/unit/runtime/model/pi-args-loadout.test.ts). Cross-version-safe: // on pre-1.0.4 hosts the `mcp__*` pattern matches no literal tool name // → benign no-op. ONE flag value only — pi's strict parser rejects a // duplicate --exclude-tools flag, so declared disallowedTools merge // comma-sep ahead of the pattern. Stays inside the else of disableTools: // `--no-tools` already locks the whole tool surface, an extra cut would // be dead weight. Surface TUI panes strip argv flags after build // (child-pi.ts stripHeadlessOnlyFlags), so this cut never reaches them. const disallowed = new Set((input.agent.disallowedTools ?? []).map((t) => t.trim()).filter(Boolean)); const paritySpawn = !resolveHermeticWorkers(input.hermeticWorkers); if (paritySpawn) disallowed.add("mcp__" + "*"); if (disallowed.size > 0) args.push("--exclude-tools", [...disallowed].join(",")); // U9 (version floor, registry PI_NO_MCP_FLOOR in child-pi/pi-version.ts; // flag doc cli.md:198): parity spawn on a host pi >= 1.0.4 ALSO passes // `--no-mcp` — "no servers connect, and there are no MCP tools or /mcp", // strictly stronger than the mcp__* tool-cut above (servers never even // handshake). Below the floor / unknown (null) / malformed piVersion → // NO flag: pi's strict option parser rejects unknown flags with a // nonzero exit, so guessing wrong would break every parity spawn. The // mcp__* fold STAYS alongside — it is the cross-version-safe baseline // (pre-1.0.4 hosts have no --no-mcp to lean on). Hermetic spawns never // reach here (--no-extensions already makes them MCP-clean). Kept inside // the else of disableTools like the fold (--no-tools already locks the // whole surface). Surface TUI panes KEEP the flag — stripHeadlessOnlyFlags // only strips --no-approve/--no-extensions, same treatment as // --exclude-tools: parity semantics must not depend on spawn mode. if (paritySpawn && piVersionAtLeast(input.piVersion, PI_NO_MCP_FLOOR)) args.push("--no-mcp"); } // prompt-runtime extension luôn nạp (hạ tầng phối hợp — không phải cắt xén). args.push("--extension", PROMPT_RUNTIME_EXTENSION_PATH); // SEC-1 (fix round 1): DECLARED extensions from untrusted sources // (project / project-pi / dynamic) are stripped unless the operator opts // in via PI_CREW_TRUST_PROJECT_AGENT_EXTENSIONS=1. Auto-discovery stays // open (D5) — this only filters explicit `extensions:` declarations. const extEnv = input.env ?? process.env; const untrustedSource = input.agent.source === "project" || input.agent.source === "project-pi" || input.agent.source === "dynamic"; let declaredExtensions = input.agent.extensions ?? []; if (untrustedSource && extEnv.PI_CREW_TRUST_PROJECT_AGENT_EXTENSIONS !== "1") { declaredExtensions = []; } // F1 (v0.7.9): excludeExtensions denylist (case-insensitive basename // match) applies to the declared list AFTER the SEC-1 strip. The // prompt-runtime is a pi-crew internal and is never excludable. const excluded = new Set((input.agent.excludeExtensions ?? []).map((name) => path.basename(name).toLowerCase())); declaredExtensions = declaredExtensions.filter((ext) => !excluded.has(path.basename(ext).toLowerCase())); for (const ext of declaredExtensions) args.push("--extension", ext); // R3-19/D5: hermetic spawns — disable discovered/configured/built-in // extensions on worker argv. cli.md:186-187: "Explicit -e paths still // load" — the unconditional prompt-runtime push above and the agent // `extensions:` declarations below survive; --skill flags are a separate // gate. Default ON (resolveHermeticWorkers); surface TUI panes strip the // flag (child-pi.ts stripHeadlessOnlyFlags). if (resolveHermeticWorkers(input.hermeticWorkers)) args.push("--no-extensions"); if (input.agent.inheritSkills === false) args.push("--no-skills"); for (const skillPath of input.skillPaths ?? []) args.push("--skill", skillPath); let tempDir: string | undefined; if (input.agent.systemPrompt) { // Use pi's own config dir instead of /tmp so temp files live alongside // other pi state and don't pollute the shared system temp dir. const tmpBase = getPiTempBase(); tempDir = createSafeTempDir(tmpBase, `pi-crew-${process.pid}-`); const promptPath = path.join(tempDir, `${input.agent.name.replace(/[^\w.-]/g, "_")}.md`); atomicWriteFile(promptPath, input.agent.systemPrompt, { mode: 0o600 }); args.push(input.agent.systemPromptMode === "append" ? "--append-system-prompt" : "--system-prompt", promptPath); } // R3-1: run-static header rides its own --append-system-prompt file. pi // parses --system-prompt and --append-system-prompt independently and the // append flag is repeatable (cli/args.js:71-77; appends join with "\n\n" in // argv order), so pushing it AFTER the agent flag yields the required final // order builtin → agent.systemPrompt → run-static header in BOTH modes // (replace mode: agent file replaces builtin, header appends after it; // append mode: two append files in argv order). if (input.systemPromptAppend?.trim()) { if (!tempDir) { const tmpBase = getPiTempBase(); tempDir = createSafeTempDir(tmpBase, `pi-crew-${process.pid}-`); } const headerPath = path.join(tempDir, "worker-header.md"); atomicWriteFile(headerPath, input.systemPromptAppend, { mode: 0o600 }); args.push("--append-system-prompt", headerPath); } // D1 (R3-3, pinned 2026-10-04): deterministic project-trust for worker // spawns — `--no-approve` (cli alias -na). The command-line override applies // FIRST in pi's trust resolution (security.md "How Pi chooses a trust // decision"), before any extension, saved decision in ~/.pi/agent/trust.json, // or defaultProjectTrust — so worker behavior no longer varies with the // ambient trust store of the machine (P-C probe: /home/bom/source/my_pi and // /tmp were pre-trusted there). Decision rationale vs the D1 criteria: // (a) no ambient-trust dependency ✓ (CLI override wins); // (b) write capability intact ✓ (security.md:33 — project trust does NOT // limit what tool calls can access; only startup resource loading is // gated, so workers still write files in the task cwd); // (c) minimal scope ✓ (declines rather than grants; one command; not // persisted to trust.json). // AGENTS.md/CLAUDE.md context files load REGARDLESS of project trust // (security.md:57) — probe-verified 2026-10-04 (/tmp/pi-r3impl probe2: the // project_context section is present with --no-approve), so the R3-18 // repo-instruction auto-load is preserved. `-a` was rejected: it grants // maximal project trust (loads .pi/mcp.json, .pi/extensions… from untrusted // task cwds) — the opposite of minimal scope, and redundant with the D5 // hermetic posture. Surface TUI spawns strip this flag (child-pi.ts // trySurfaceBranch) to keep the interactive trust prompt available. args.push("--no-approve"); // G3 (SDD-2 W-B, spill-always): task text NEVER rides argv — argv is // world-readable via /proc//cmdline, leaking task content (and any // secrets embedded in prompts) to every local user/process scanner. // EVERY task, regardless of length, is spilled to an owner-only (0600) // task.md inside pi-crew's temp dir and passed as a `@` inclusion // arg. The pre-G3 short-argv branch (task <= 8000 → `Task: ...` positional) // is removed; the file content is the RAW task text (same contract the // long-task path always had — no `Task: ` prefix). if (!tempDir) { const tmpBase = getPiTempBase(); tempDir = createSafeTempDir(tmpBase, `pi-crew-${process.pid}-`); } const taskPath = path.join(tempDir, "task.md"); atomicWriteFile(taskPath, input.task, { mode: 0o600 }); args.push(`@${taskPath}`); const env = input.env ?? process.env; const parentDepth = currentCrewDepth(env); const maxDepth = resolveCrewMaxDepth(input.maxDepth, env); return { args, env: { // PI_CREW_KIND is the authoritative machine-readable sub-agent marker. It is always // present on a child-pi process and NEVER present on a user's interactive main session. // doctor --zombies uses it to safely list orphaned sub-agents without ever matching a // main session (the lesson from an accidental `kill` of a live main session). PI_CREW_KIND: "subagent", PI_CREW_INHERIT_PROJECT_CONTEXT: input.agent.inheritProjectContext ? "1" : "0", // B1 (fix round 1): match the argv `=== false` semantics — undefined // inheritSkills means INHERIT (D5 default), not "0". PI_CREW_INHERIT_SKILLS: input.agent.inheritSkills === false ? "0" : "1", PI_CREW_DEPTH: String(parentDepth + 1), PI_CREW_MAX_DEPTH: String(maxDepth), PI_CREW_ROLE: input.agent.name, PI_TEAMS_INHERIT_PROJECT_CONTEXT: input.agent.inheritProjectContext ? "1" : "0", PI_TEAMS_INHERIT_SKILLS: input.agent.inheritSkills === false ? "0" : "1", PI_TEAMS_DEPTH: String(parentDepth + 1), PI_TEAMS_MAX_DEPTH: String(maxDepth), PI_TEAMS_ROLE: input.agent.name, // maxTokens cap for background workers — prompt-runtime reads this to cap API output ...(input.agent.maxTokens ? { PI_CREW_MAX_OUTPUT: String(input.agent.maxTokens) } : {}), }, tempDir, }; } export function cleanupTempDir(tempDir: string | undefined): void { if (!tempDir) return; try { // CRITICAL: never rmSync a symlink. fs.rmSync with recursive:true // FOLLOWS symlinks — use lstatSync (does not follow) to verify. let lstat: fs.Stats; try { lstat = fs.lstatSync(tempDir); } catch { // Dir doesn't exist or inaccessible — best effort createdTempDirs.delete(tempDir); return; } if (lstat.isSymbolicLink()) { // Symlinks should not be in createdTempDirs (createSafeTempDir // rejects symlinked base dirs), but guard anyway. createdTempDirs.delete(tempDir); return; } fs.rmSync(tempDir, { recursive: true, force: true }); createdTempDirs.delete(tempDir); } catch { // Best effort. } } /** * Clean up ALL temp dirs created in this process. Called from * crew-cleanup.ts on session_shutdown to prevent accumulation of * /tmp/pi-crew-* dirs when individual cleanupTempDir calls are missed * (e.g. parent process killed before child-pi.ts settles). */ export function cleanupAllTrackedTempDirs(): { cleaned: number; failed: number; } { let cleaned = 0; let failed = 0; // Snapshot to avoid mutation during iteration for (const dir of [...createdTempDirs]) { try { // CRITICAL: never rmSync a symlink. fs.rmSync with recursive:true // FOLLOWS symlinks — use lstatSync to verify first. let lstat: fs.Stats; try { lstat = fs.lstatSync(dir); } catch { // Dir gone or inaccessible — best effort cleanup createdTempDirs.delete(dir); failed++; continue; } if (lstat.isSymbolicLink()) { // Should never happen (createSafeTempDir rejects symlinked // base), but guard anyway to prevent accidental target deletion. createdTempDirs.delete(dir); continue; } fs.rmSync(dir, { recursive: true, force: true }); createdTempDirs.delete(dir); cleaned++; } catch { failed++; } } return { cleaned, failed }; } /** * @internal Test-only: reset the in-memory `createdTempDirs` Set. * Used by unit tests to ensure isolation between cases. Not exported * via the public API surface. */ export function __test_resetTrackedTempDirs(): void { createdTempDirs.clear(); } /** * @internal Test-only: get a snapshot of currently tracked temp dirs. */ export function __test_getTrackedTempDirs(): readonly string[] { return [...createdTempDirs]; } /** * Purge stale entries from createdTempDirs — directories that no longer * exist on disk. This handles the case where a process crashed before * cleanupAllTrackedTempDirs ran, leaving orphaned entries in the Set. * Called at startup as a belt-and-suspenders measure alongside the * periodic cleanupOrphanTempDirs. */ export function purgeStaleTrackedTempDirs(): { removed: number; remaining: number; } { let removed = 0; for (const dir of [...createdTempDirs]) { if (!fs.existsSync(dir)) { createdTempDirs.delete(dir); removed++; } } return { removed, remaining: createdTempDirs.size }; } // Run startup purge to prevent unbounded Set growth from crash loops. purgeStaleTrackedTempDirs(); /** Max age (ms) for orphan temp dirs. Anything older is considered abandoned. */ const ORPHAN_TEMP_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h /** Cap dirs removed per call to avoid main-thread stalls. */ const ORPHAN_TEMP_CLEAN_BATCH_SIZE = 50; /** * Remove orphan temp dirs in `~/.pi/agent/pi-crew/tmp/` older than the age * threshold. This catches dirs left behind by parent processes that were * SIGKILL'd (no graceful shutdown to call cleanupAllTrackedTempDirs). * * Called periodically by register.ts:tempReconcileTimer. * * @param now Current epoch ms (parameter for testability) * @param baseDir Override base dir (for testing). Defaults to * `/tmp/`. */ export function cleanupOrphanTempDirs( now: number = Date.now(), baseDir: string = path.join(userPiRoot(), "tmp"), ): { scanned: number; cleaned: number; failed: number } { let scanned = 0; let cleaned = 0; let failed = 0; try { if (!fs.existsSync(baseDir)) return { scanned: 0, cleaned: 0, failed: 0 }; const entries = fs.readdirSync(baseDir, { withFileTypes: true }); // Only process pi-crew-* dirs to avoid touching unrelated files const candidates = entries .filter((e) => e.isDirectory() && e.name.startsWith("pi-crew-")) .sort((a, b) => a.name.localeCompare(b.name)) .slice(0, ORPHAN_TEMP_CLEAN_BATCH_SIZE); for (const entry of candidates) { scanned++; const dir = path.join(baseDir, entry.name); // CRITICAL: never rmSync a symlink. fs.rmSync with recursive:true // FOLLOWS symlinks — an attacker could plant a symlink to /etc and // wipe the system. Use lstat (does not follow) and skip. let lstat: fs.Stats; try { lstat = fs.lstatSync(dir); } catch { failed++; continue; } if (lstat.isSymbolicLink()) continue; // Skip dirs currently in use by this process. A long-running child // pi (>24h) would otherwise have its prompt/task tmp dir deleted // mid-execution, causing broken-pipe failures when the child // reads the system prompt. if (createdTempDirs.has(dir)) continue; try { // FIX: Perform lstatSync BEFORE statSync mtime check to close TOCTOU window. // An attacker could plant a symlink between the early lstatSync (line 373) and statSync. // If statSync follows the symlink, it reads the target's mtime, not the dir's. // By checking lstatSync first, we skip the mtime check entirely for symlinks. let preRmlstat: fs.Stats | undefined; try { preRmlstat = fs.lstatSync(dir); } catch { failed++; continue; } if (!preRmlstat || preRmlstat.isSymbolicLink()) continue; // Reuse preRmlstat (captured at line 408) — already verified non-symlink at line 415. // Avoid calling lstatSync again to eliminate the TOCTOU window between // preRmlstat lstatSync (line 408) and this mtime check. // NOTE: This is a best-effort approximation. The mtime was captured at line 408, // potentially seconds before the rmSync at line 420. If the directory was modified // after line 408, the age check uses stale data. This could cause premature cleanup // of dirs that were recently modified but appeared old based on cached mtime (±seconds jitter). if (now - preRmlstat.mtimeMs > ORPHAN_TEMP_MAX_AGE_MS) { fs.rmSync(dir, { recursive: true, force: true }); createdTempDirs.delete(dir); cleaned++; } } catch { failed++; } } } catch { /* skip if tmpdir unreadable */ } return { scanned, cleaned, failed }; } /** * Clean up orphan `pi-crew-*` prompt/task temp dirs left in the system * `/tmp/` directory. Before commit 8ba270d these were the primary location * for temp dirs; users who upgraded may have thousands of orphans (the * user's /tmp had 2498 of these). The existing * `reconcileOrphanedTempWorkspaces` only cleans dirs containing * `.crew/state/runs/` (the run-state dirs), so prompt/task orphans are * never touched. * * Strategy: remove `pi-crew-*` dirs in /tmp that DO NOT contain * `.crew/state/runs/` AND are older than the age threshold. The age * threshold protects active processes that might still be writing. * * Bounded to ORPHAN_TEMP_CLEAN_BATCH_SIZE dirs per call. * * @param now Current epoch ms (parameter for testability) * @param tmpDirOverride Override /tmp dir (for testing). Defaults to * `os.tmpdir()`. */ export function cleanupLegacyOrphanTempDirs( now: number = Date.now(), tmpDirOverride: string = os.tmpdir(), ): { scanned: number; cleaned: number; failed: number } { const tmpDir = tmpDirOverride; let scanned = 0; let cleaned = 0; let failed = 0; try { if (!fs.existsSync(tmpDir)) return { scanned: 0, cleaned: 0, failed: 0 }; const entries = fs.readdirSync(tmpDir, { withFileTypes: true }); const candidates = entries .filter((e) => e.isDirectory() && e.name.startsWith("pi-crew-")) .sort((a, b) => a.name.localeCompare(b.name)) .slice(0, ORPHAN_TEMP_CLEAN_BATCH_SIZE); for (const entry of candidates) { scanned++; const dir = path.join(tmpDir, entry.name); // Symlink guard let lstat: fs.Stats; try { lstat = fs.lstatSync(dir); } catch { failed++; continue; } if (lstat.isSymbolicLink()) continue; // Skip dirs containing pi-crew run state — those are handled by // reconcileOrphanedTempWorkspaces which has run-state semantics. // RR-020 Fix 3: recognise BOTH supported layouts — `/.crew/` and // the `.pi`-based `/.pi/teams/`. Only `.crew` used to be checked, // so a temp workspace with live `.pi/teams` run state was deleted as // debris. Symlinked layout dirs still do NOT protect the dir (the // scanner keeps rejecting symlinks instead of trusting them). if (hasRunStateLayout(dir)) continue; // Skip dirs currently tracked by this process (defense in depth: // with 8ba270d the Set should never contain /tmp/ paths, but // future code or external callers might). if (createdTempDirs.has(dir)) continue; try { // FIX: Perform lstatSync BEFORE statSync mtime check to close TOCTOU window. // An attacker could plant a symlink between the early lstatSync (line 457) and statSync. // If statSync follows the symlink, it reads the target's mtime, not the dir's. // By checking lstatSync first, we skip the mtime check entirely for symlinks. let preRmlstat: fs.Stats; try { preRmlstat = fs.lstatSync(dir); } catch { failed++; continue; } if (preRmlstat.isSymbolicLink()) continue; // Reuse preRmlstat (captured at line 494) — already verified non-symlink at line 501. // Avoid calling lstatSync again to eliminate the TOCTOU window between // preRmlstat lstatSync (line 494) and this mtime check. // NOTE: This is a best-effort approximation. The mtime was captured at line 494, // potentially seconds before the rmSync at line 505. If the directory was modified // after line 494, the age check uses stale data. This could cause premature cleanup // of dirs that were recently modified but appeared old based on cached mtime (±seconds jitter). if (now - preRmlstat.mtimeMs > ORPHAN_TEMP_MAX_AGE_MS) { fs.rmSync(dir, { recursive: true, force: true }); cleaned++; } } catch { failed++; } } } catch { /* skip if tmpdir unreadable */ } return { scanned, cleaned, failed }; }