import { captureExternal, type ExternalBaseline } from "./proactive-external.ts"; import type { PolicyRootTable } from "../lib/policy-roots.ts"; import { safeSourceRead } from "../lib/safe-source-read.js"; import { execFileSync, fork } from "node:child_process"; import { createHash } from "node:crypto"; import { lstatSync, realpathSync } from "node:fs"; import { isAbsolute, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { isCaptureEnabled, PROACTIVE_LIMITS } from "./proactive-config.ts"; import { checkScope } from "./scope-gate.js"; import type { ProactiveConfig, SourceExcerpt, TaskContext, TurnSnapshot, TurnUnit } from "./proactive-types.ts"; const hash = (value: Buffer | string) => createHash("sha256").update(value).digest("hex"); const forbidden = /^(?:\.env(?:\..*)?|\.git|\.pi|\.ssh|\.aws|\.npmrc|\.netrc|node_modules|vendor|dist|build|coverage|\.next|\.cache|artifacts|sessions?|transcripts?|credentials?(?:\..*)?|secrets?(?:\..*)?|id_(?:rsa|ed25519)|.*\.(?:pem|key|p12|pfx|sqlite|db|lock))$/i; function allowed(root: string, path: string): string { if (!path || isAbsolute(path) || path.includes("\\") || path.includes("\0") || path.split("/").some(s => !s || s === "." || s === ".." || forbidden.test(s))) throw new Error("forbidden_path"); const rootReal = realpathSync(root), full = resolve(rootReal, path); const rel = relative(rootReal, full); if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("escape"); let cursor = rootReal; for (const part of path.split("/")) { cursor = join(cursor, part); try { if (lstatSync(cursor).isSymbolicLink()) throw new Error("symlink"); } catch (e) { if ((e as NodeJS.ErrnoException).code === "ENOENT") break; throw e; } } return full; } function remaining(start: number, now: () => number): number { const ms = PROACTIVE_LIMITS.captureMs - (now() - start); if (ms <= 0) throw new Error("capture_timeout"); return Math.min(300, Math.max(1, Math.floor(ms))); } function git(root: string, args: string[], start: number, now: () => number, maxBuffer = 1024 * 1024): Buffer { remaining(start, now); // The enclosing process-group deadline owns cancellation; this is only a backup. const bytes = execFileSync("git", ["-C", root, ...args], { timeout: PROACTIVE_LIMITS.captureMs + 200, maxBuffer, stdio: ["ignore", "pipe", "pipe"] }); remaining(start, now); return bytes; } function status(root: string, start: number, now: () => number): Set { const rows = git(root, ["status", "--porcelain", "-z", "--untracked-files=all"], start, now).toString("utf8").split("\0"); const paths = new Set(); for (let i = 0; i < rows.length; i++) { const row = rows[i]; if (!row) continue; paths.add(row.slice(3)); if (/[RC]/.test(row.slice(0, 2)) && rows[i + 1]) paths.add(rows[++i]); } return paths; } function read(root: string, path: string, start: number, now: () => number, afterFirstRead?: () => void): Buffer | null { const full = allowed(root, path); try { return safeSourceRead(root, full, PROACTIVE_LIMITS.maxFileBytes, () => remaining(start, now), afterFirstRead); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; const reason = (error as Error).message; throw new Error(({ source_changed: "unstable_snapshot", state_too_large: "oversized_or_nonfile", source_denied: "binary_or_invalid_utf8" } as Record)[reason] ?? reason); } } function excerpt(bytes: Buffer, secrets: readonly string[]): SourceExcerpt { let text = bytes.toString("utf8"); for (const secret of secrets) if (secret) text = text.replaceAll(secret, "[REDACTED]"); const source = bytes.toString("utf8"); return Object.freeze({ hash: hash(text), offset: 0, endOffset: bytes.length, startLine: 1, endLine: Math.max(1, source.split("\n").length - (source.endsWith("\n") ? 1 : 0)), text, truncated: false }); } export interface TurnBaseline { readonly root: string; readonly head: string; readonly dirty: ReadonlyMap; readonly initialPaths: ReadonlySet; readonly config: ProactiveConfig; readonly turnId: string; readonly context: TaskContext; readonly gaps: readonly string[]; readonly startedAt: number; readonly external?: ExternalBaseline; readonly independentWorkspace?: boolean; } const code = (e: unknown): string => { const message = (e as Error)?.message; return typeof message === "string" && ["capture_timeout", "forbidden_path", "escape", "symlink", "oversized_or_nonfile", "unstable_snapshot", "binary_or_invalid_utf8", "missing_baseline", "oversized_baseline"].includes(message) ? message : "read_unavailable"; }; /** Worker-only synchronous core. Never call on the agent's event loop. */ export function beginTurnCore(input: { root: string; config: ProactiveConfig; turnId: string; context: TaskContext; knownTargets?: readonly string[]; now?: () => number; policyRoots?: PolicyRootTable }): TurnBaseline | null { if (!isCaptureEnabled(input.config)) return null; const now = input.now ?? Date.now, startedAt = now(); let head = ""; let initialPaths = new Set(); const dirty = new Map(), gaps: string[] = []; if (!input.policyRoots && input.config.include.some(path => path.startsWith("../"))) gaps.push("external_evidence_unavailable"); try { head = git(input.root, ["rev-parse", "HEAD"], startedAt, now).toString("utf8").trim(); initialPaths = status(input.root, startedAt, now); } catch (e) { head = ""; gaps.push((e as Error).message === "capture_timeout" ? "capture_timeout" : "git_unavailable"); } let retained = 0, count = 0; for (const path of [...new Set([...initialPaths, ...(input.knownTargets ?? [])])].sort()) { if (now() - startedAt >= PROACTIVE_LIMITS.captureMs) { gaps.push("capture_timeout"); break; } if (!checkScope([path], input.config.include).inScope.length) continue; if (!head) break; if (count >= PROACTIVE_LIMITS.maxUnits || retained >= PROACTIVE_LIMITS.maxRetainedBytes) { gaps.push("baseline_limit"); break; } try { const bytes = read(input.root, path, startedAt, now); if (retained + (bytes?.length ?? 0) > PROACTIVE_LIMITS.maxRetainedBytes) { gaps.push("baseline_limit"); break; } dirty.set(path, bytes); retained += bytes?.length ?? 0; count++; } catch (e) { gaps.push(code(e)); } } const external = input.policyRoots ? captureExternal({roots:input.policyRoots,include:input.config.include,maxFiles:PROACTIVE_LIMITS.maxUnits-count,maxBytes:PROACTIVE_LIMITS.maxRetainedBytes-retained,check:()=>{remaining(startedAt,now);},includeWorkspace:!head,knownPaths:input.knownTargets}) : undefined; if (external) gaps.push(...external.gaps); // A non-Git parent is supported only when every local include has independent history. // Missing/unsupported scopes keep the original Git gap; no synthetic clean baseline. const independentWorkspace = !head && !!external && input.config.include.some(path => !path.startsWith("../")) && input.config.include.filter(path => !path.startsWith("../")).every(path => [...external.gitHeads.keys()].some(scope => path === scope || path.startsWith(scope + "/"))); if (independentWorkspace) { const index = gaps.indexOf("git_unavailable"); if (index >= 0) gaps.splice(index, 1); } return { independentWorkspace, root: input.root, head, dirty, initialPaths, config: input.config, turnId: input.turnId, context: structuredClone(input.context), gaps, startedAt, ...(external ? {external} : {}) }; } export function finishTurnCore(baseline: TurnBaseline | null, input: { assistantText?: string; knownTargets?: readonly string[]; secrets?: readonly string[]; overlap?: boolean; now?: () => number; afterFirstRead?: () => void } = {}): TurnSnapshot | null { if (!baseline) return null; const now = input.now ?? Date.now, started = now(); const gaps = [...baseline.gaps]; let current: Set; try { current = baseline.independentWorkspace ? new Set() : status(baseline.root, started, now); if (!baseline.independentWorkspace && git(baseline.root, ["rev-parse", "HEAD"], started, now).toString("utf8").trim() !== baseline.head) gaps.push("head_changed"); } catch (e) { current = new Set(); gaps.push((e as Error).message === "capture_timeout" ? "capture_timeout" : "git_unavailable"); } if (input.overlap) gaps.push("concurrent_writer_attribution_uncertain"); const paths = [...new Set([...baseline.initialPaths, ...current, ...baseline.dirty.keys(), ...(input.knownTargets ?? [])])].sort(); const units: TurnUnit[] = []; const observedPaths = new Set(paths); let retained = 0, omitted = 0; const secrets = input.secrets ?? []; for (const path of baseline.independentWorkspace ? [] : paths) { if (!checkScope([path], baseline.config.include).inScope.length) continue; if (now() - started >= PROACTIVE_LIMITS.captureMs) { gaps.push("capture_timeout"); omitted++; continue; } if (units.length >= PROACTIVE_LIMITS.maxUnits) { omitted++; continue; } try { // Deny both lexical and symlink/realpath escapes before path-specific Git reads. allowed(baseline.root, path); let before = baseline.dirty.get(path); if (!baseline.dirty.has(path)) { if (!baseline.head || baseline.initialPaths.has(path) || (input.knownTargets ?? []).includes(path)) throw new Error("missing_baseline"); // A clean tracked path can use HEAD; only a path absent from HEAD is a genuine addition. const tracked = git(baseline.root, ["ls-tree", "-z", "--name-only", baseline.head, "--", path], started, now).length > 0; before = tracked ? git(baseline.root, ["show", `${baseline.head}:${path}`], started, now, PROACTIVE_LIMITS.maxFileBytes + 1) : null; if (before && before.length > PROACTIVE_LIMITS.maxFileBytes) throw new Error("oversized_baseline"); } const after = read(baseline.root, path, started, now, input.afterFirstRead); remaining(started, now); if (before && (before.includes(0) || !new TextDecoder("utf-8", { fatal: true }).decode(before))) { if (before.length) throw new Error("binary_or_invalid_utf8"); } if (before !== null && after !== null && before?.equals(after)) continue; if (before == null && after == null) continue; const prior = before == null ? undefined : excerpt(before, secrets); const next = after == null ? undefined : excerpt(after, secrets); const size = Buffer.byteLength(prior?.text ?? "") + Buffer.byteLength(next?.text ?? ""); remaining(started, now); if (retained + size > PROACTIVE_LIMITS.maxRetainedBytes) { omitted++; continue; } const unit: TurnUnit = Object.freeze({ id: hash(`${baseline.turnId}:${path}`), path, kind: before == null ? "added" : after == null ? "deleted" : "modified", before: prior, after: next, attribution: "uncertain" }); units.push(unit); retained += size; } catch (e) { gaps.push(code(e)); omitted++; } } if (baseline.external) { const before = baseline.external; const after = captureExternal({roots:before.roots,include:baseline.config.include,maxFiles:PROACTIVE_LIMITS.maxUnits-units.length,maxBytes:PROACTIVE_LIMITS.maxRetainedBytes-retained,check:()=>{remaining(started,now);},includeWorkspace:!baseline.head,knownPaths:[...before.files.keys(),...before.headFiles.keys(),...(input.knownTargets ?? [])]}); gaps.push(...after.gaps); const headsChanged = JSON.stringify([...before.gitHeads]) !== JSON.stringify([...after.gitHeads]); if (headsChanged) gaps.push("external_head_changed"); const unavailable = (g: string) => !["external_history_unavailable","external_prior_edits_not_reviewed"].includes(g); const incomplete = headsChanged || before.gaps.some(unavailable) || after.gaps.some(unavailable); const externalPaths = [...new Set([...before.files.keys(),...after.files.keys(),...before.headFiles.keys(),...after.headFiles.keys()])].sort(); for (const path of externalPaths) { observedPaths.add(path); const independentlyTracked = [...before.gitHeads.keys()].some(scope => { const normalized = scope.endsWith("/**") ? scope.slice(0,-3) : scope; return path===normalized || path.startsWith(normalized+"/"); }); const priorBytes = independentlyTracked ? before.headFiles.get(path) ?? (baseline.independentWorkspace ? after.headFiles.get(path) : undefined) : before.files.get(path), nextBytes = after.files.get(path); if (priorBytes === undefined && nextBytes === undefined) continue; if (priorBytes?.equals(nextBytes ?? Buffer.alloc(0)) && nextBytes !== undefined) continue; if (incomplete) { gaps.push("external_evidence_unavailable"); omitted++; continue; } const prior = priorBytes === undefined ? undefined : excerpt(priorBytes,secrets); const next = nextBytes === undefined ? undefined : excerpt(nextBytes,secrets); const size = Buffer.byteLength(prior?.text ?? "") + Buffer.byteLength(next?.text ?? ""); if (units.length >= PROACTIVE_LIMITS.maxUnits || retained+size > PROACTIVE_LIMITS.maxRetainedBytes) { omitted++; continue; } units.push(Object.freeze({id:hash(`${baseline.turnId}:${path}`),path,kind:priorBytes===undefined ? "added" : nextBytes===undefined ? "deleted" : "modified",before:prior,after:next,attribution:"uncertain",sourceRootId:before.identities.get(path) ?? after.identities.get(path)})); retained += size; } } if (omitted) gaps.push(`omitted_paths:${omitted}`); if (baseline.config.remoteContext === "selected-excerpts" && input.assistantText) { const text = Buffer.from(input.assistantText); const redacted = text.length <= PROACTIVE_LIMITS.maxFileBytes ? excerpt(text, secrets) : null; const size = Buffer.byteLength(redacted?.text ?? ""); if (units.length >= PROACTIVE_LIMITS.maxUnits || !redacted || retained + size > PROACTIVE_LIMITS.maxRetainedBytes) gaps.push("assistant_text_omitted"); else { units.push(Object.freeze({ id: hash(`${baseline.turnId}:assistant`), path: "assistant", kind: "text", after: redacted, attribution: "observed_only" })); retained += size; } } const statusValue = gaps.some(g => g.includes("unstable_snapshot")) ? "unstable_snapshot" : gaps.length ? "partial" : "complete"; const snapshotId = hash(JSON.stringify([baseline.turnId, baseline.head, baseline.context, units.map(u => [u.id, u.before?.hash, u.after?.hash]), gaps])); const context = structuredClone(baseline.context); Object.freeze(context.task); if (context.plan) Object.freeze(context.plan); for (const rule of context.rules) Object.freeze(rule); Object.freeze(context.rules); Object.freeze(context.exceptions); Object.freeze(context); return Object.freeze({ snapshotId, turnId: baseline.turnId, head: baseline.head, context, planStatus: baseline.context.plan ? "bound" : "task_only", status: statusValue, gaps: Object.freeze(gaps), units: Object.freeze(units), observedPaths: observedPaths.size, coverage: Object.freeze({ retainedUnits: units.length, omittedPaths: omitted, retainedBytes: retained }) }); } /** Immutable in-memory readback: never opens the source path. */ export function readSnapshotUnit(snapshot: TurnSnapshot, snapshotId: string, unitId: string, contentHash: string): string | null { if (snapshot.snapshotId !== snapshotId) return null; const unit = snapshot.units.find(u => u.id === unitId); const excerpt = [unit?.before, unit?.after].find(e => e?.hash === contentHash); return excerpt?.text ?? null; } /** Each phase runs in a disposable process group. The parent fences IPC after deadline * and kills the entire group (including any Git descendants) before settling. */ function capture(phase: "begin" | "finish", payload: unknown): Promise { return new Promise((resolve, reject) => { const child = fork(fileURLToPath(new URL("./proactive-snapshot-worker.mjs", import.meta.url)), [], { detached: true, stdio: ["ignore", "ignore", "ignore", "ipc"], serialization: "advanced", execArgv: [], }); let settled = false; const killGroup = () => { if (child.pid) { try { process.kill(-child.pid, "SIGKILL"); } catch { /* exited */ } try { child.kill("SIGKILL"); } catch { /* exited */ } } }; const settle = (error?: Error, result?: T) => { if (settled) return; settled = true; clearTimeout(timer); killGroup(); if (error) reject(error); else resolve(result as T); }; const timer = setTimeout(() => settle(new Error("capture_timeout")), PROACTIVE_LIMITS.captureMs); child.on("message", (message: { ok?: boolean; result?: T; code?: string }) => { if (!message?.ok) settle(new Error(message?.code === "capture_timeout" ? "capture_timeout" : "capture_unavailable")); else settle(undefined, message.result); }); child.on("error", () => settle(new Error("capture_unavailable"))); // Let a queued IPC result win if the process exits immediately after sending. child.on("exit", () => setImmediate(() => settle(new Error("capture_unavailable")))); child.send({ phase, payload }, error => { if (error) settle(new Error("capture_unavailable")); }); }); } export function beginTurn(input: Parameters[0]): Promise { if (!isCaptureEnabled(input.config)) return Promise.resolve(null); const { now: _now, ...payload } = input; return capture("begin", payload).then(baseline => { if (baseline) { Object.freeze(baseline.context.task); if (baseline.context.plan) Object.freeze(baseline.context.plan); for (const rule of baseline.context.rules) Object.freeze(rule); Object.freeze(baseline.context.rules); Object.freeze(baseline.context.exceptions); Object.freeze(baseline.context); } return baseline; }); } export function finishTurn(baseline: TurnBaseline | null, input: Parameters[1] = {}): Promise { if (!baseline) return Promise.resolve(null); const { now: _now, afterFirstRead: _hook, assistantText, ...options } = input; return capture("finish", { baseline, input: baseline.config.remoteContext === "selected-excerpts" ? { ...options, assistantText } : options }).then(snapshot => { // IPC cloning loses Object.freeze; reapply it before exposing private evidence. for (const unit of snapshot.units) { if (unit.before) Object.freeze(unit.before); if (unit.after) Object.freeze(unit.after); Object.freeze(unit); } Object.freeze(snapshot.units); Object.freeze(snapshot.gaps); Object.freeze(snapshot.coverage); Object.freeze(snapshot.context.task); if (snapshot.context.plan) Object.freeze(snapshot.context.plan); for (const rule of snapshot.context.rules) Object.freeze(rule); Object.freeze(snapshot.context.rules); Object.freeze(snapshot.context.exceptions); Object.freeze(snapshot.context); return Object.freeze(snapshot); }); }