diff --git a/package-lock.json b/package-lock.json index cc9046681..0d8165adb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,8 @@ "@babel/types": "8.0.4", "@electron/asar": "4.2.0", "@modelcontextprotocol/server": "2.0.0-beta.3", + "@xterm/addon-serialize": "0.14.0", + "@xterm/headless": "6.0.0", "@zip.js/zip.js": "2.8.26", "canonicalize": "3.0.0", "diff": "9.0.0", @@ -1883,6 +1885,21 @@ "node": ">=14.6" } }, + "node_modules/@xterm/addon-serialize": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0.tgz", + "integrity": "sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA==", + "license": "MIT" + }, + "node_modules/@xterm/headless": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz", + "integrity": "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/@yomguithereal/helpers": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@yomguithereal/helpers/-/helpers-1.1.1.tgz", diff --git a/package.json b/package.json index 6cc694fdc..f45ceccdd 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "install.sh", "bridge/hopper_bridge.py", "scripts/rea.mjs", + "scripts/prepare-node-pty.mjs", "scripts/rebuild-native.mjs", "skills", "README.md", @@ -59,6 +60,7 @@ "scan:todos": "node scripts/scan-todos.mjs", "start": "node dist/main.js", "pretest": "npm run build", + "postinstall": "node scripts/prepare-node-pty.mjs", "test": "vitest run", "pretest:coverage": "npm run build", "test:coverage": "vitest run --coverage.enabled --coverage.provider=v8", @@ -86,6 +88,8 @@ "@babel/types": "8.0.4", "@electron/asar": "4.2.0", "@modelcontextprotocol/server": "2.0.0-beta.3", + "@xterm/addon-serialize": "0.14.0", + "@xterm/headless": "6.0.0", "@zip.js/zip.js": "2.8.26", "canonicalize": "3.0.0", "diff": "9.0.0", diff --git a/scripts/prepare-node-pty.mjs b/scripts/prepare-node-pty.mjs new file mode 100644 index 000000000..ddd8612ae --- /dev/null +++ b/scripts/prepare-node-pty.mjs @@ -0,0 +1,35 @@ +#!/usr/bin/env node + +import { chmod, lstat } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +if (process.platform !== "win32") { + const require = createRequire(import.meta.url); + const packageRoot = dirname( + require.resolve("node-pty/package.json", { + paths: [dirname(fileURLToPath(import.meta.url))], + }), + ); + const helperPath = join( + packageRoot, + "prebuilds", + `${process.platform}-${process.arch}`, + "spawn-helper", + ); + + try { + const helper = await lstat(helperPath); + if (!helper.isFile() || helper.isSymbolicLink()) { + throw new Error("node-pty spawn helper is not a regular file"); + } + await chmod(helperPath, helper.mode | 0o111); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + // Source builds place their correctly-modeled helper under build/Release. + } else { + throw error; + } + } +} diff --git a/src/application/CommandShimReplay.ts b/src/application/CommandShimReplay.ts new file mode 100644 index 000000000..6aa006057 --- /dev/null +++ b/src/application/CommandShimReplay.ts @@ -0,0 +1,173 @@ +import { createServer, type Server } from "node:http"; +import { chmod, mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import type { ProcessScenario, ShimEvent } from "../domain/processCapture.js"; + +interface Invocation { + readonly command: string; + readonly arguments: readonly string[]; + readonly working_directory: string; +} + +/** Owned command-shim replay resources for one process scenario. */ +export interface CommandShimReplay { + readonly binPath: string; + readonly url: string; + readonly events: readonly ShimEvent[]; + readonly truncated: boolean; + close(): Promise; +} + +const RUNNER = `const command = process.argv[2]; +const response = await fetch(process.env.REA_SHIM_LEDGER_URL, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ command, arguments: process.argv.slice(3), working_directory: process.cwd() }), +}); +if (!response.ok) process.exit(127); +const route = await response.json(); +let elapsed = 0; +for (const output of route.outputs) { + const wait = Math.max(0, output.at_ms - elapsed); + if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait)); + elapsed = output.at_ms; + (output.stream === "stdout" ? process.stdout : process.stderr).write(output.data); +} +if (route.termination.type === "signal") process.kill(process.pid, route.termination.signal); +else process.exit(route.termination.code); +`; + +const shellQuote = (value: string): string => + `'${value.replaceAll("'", `'\\''`)}'`; + +/** Start bounded executable replay wrappers and their private invocation ledger. */ +export const startCommandShimReplay = async ( + scenario: ProcessScenario, + temporaryRoot: string, + started: number, +): Promise => { + const binPath = join(temporaryRoot, "shims"); + await mkdir(binPath); + const runnerPath = join(temporaryRoot, "shim-runner.mjs"); + await writeFile(runnerPath, RUNNER, { mode: 0o600, flag: "wx" }); + for (const shim of scenario.command_shims) { + const path = join(binPath, shim.name); + const wrapper = `#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(runnerPath)} ${shellQuote(shim.name)} "$@"\n`; + await writeFile(path, wrapper, { mode: 0o700, flag: "wx" }); + await chmod(path, 0o700); + } + + const events: ShimEvent[] = []; + let truncated = false; + const calls = new Map(); + const server = createServer((request, response) => { + if (request.method !== "POST" || request.url !== "/invoke") { + response.writeHead(404).end(); + return; + } + readInvocation(request) + .then((invocation) => { + const shim = scenario.command_shims.find( + ({ name }) => name === invocation.command, + ); + const routeIndex = shim?.routes.findIndex( + ({ arguments: expected }) => + JSON.stringify(expected) === JSON.stringify(invocation.arguments), + ); + const key = `${invocation.command}:${String(routeIndex ?? -1)}`; + const used = calls.get(key) ?? 0; + const route = + shim !== undefined && routeIndex !== undefined && routeIndex >= 0 + ? shim.routes[routeIndex] + : undefined; + const outcome = + route === undefined + ? "unmatched" + : used >= route.max_calls + ? "exhausted" + : "matched"; + if (events.length >= scenario.limits.protocol_events) truncated = true; + else + events.push({ + sequence: events.length, + at_ms: Math.max(0, Date.now() - started), + command: invocation.command, + arguments: invocation.arguments, + working_directory: invocation.working_directory, + outcome, + }); + if (outcome !== "matched" || route === undefined) { + response.writeHead(409).end(); + return; + } + calls.set(key, used + 1); + response + .writeHead(200, { "content-type": "application/json" }) + .end(JSON.stringify(route)); + }) + .catch(() => response.writeHead(400).end()); + }); + await listen(server); + const address = server.address(); + if (address === null || typeof address === "string") { + await closeServer(server); + throw new Error("command shim ledger did not bind an IPv4 port"); + } + return { + binPath, + url: `http://127.0.0.1:${String(address.port)}/invoke`, + events, + get truncated() { + return truncated; + }, + close: () => closeServer(server), + }; +}; + +const readInvocation = async ( + request: import("node:http").IncomingMessage, +): Promise => { + const chunks: Buffer[] = []; + let bytes = 0; + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + bytes += buffer.length; + if (bytes > 64 * 1024) throw new Error("shim invocation exceeds limit"); + chunks.push(buffer); + } + const value: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8")); + if ( + typeof value !== "object" || + value === null || + !("command" in value) || + typeof value.command !== "string" || + !("arguments" in value) || + !Array.isArray(value.arguments) || + !value.arguments.every((item) => typeof item === "string") || + !("working_directory" in value) || + typeof value.working_directory !== "string" + ) + throw new Error("invalid shim invocation"); + return { + command: value.command, + arguments: value.arguments, + working_directory: value.working_directory, + }; +}; + +const listen = (server: Server): Promise => + new Promise((resolveListen, rejectListen) => { + server.once("error", rejectListen); + server.listen(0, "127.0.0.1", () => { + server.off("error", rejectListen); + resolveListen(); + }); + }); + +const closeServer = (server: Server): Promise => + new Promise((resolveClose, rejectClose) => + server.close((cause) => + cause === undefined ? resolveClose() : rejectClose(cause), + ), + ); diff --git a/src/application/ProcessCheckpoints.ts b/src/application/ProcessCheckpoints.ts new file mode 100644 index 000000000..ebb11e5eb --- /dev/null +++ b/src/application/ProcessCheckpoints.ts @@ -0,0 +1,147 @@ +import type { + FileState, + FilesystemCheckpoint, + ProcessScenario, +} from "../domain/processCapture.js"; +import { snapshotRoots, type SnapshotResult } from "./FilesystemSnapshot.js"; + +/** Classify path-stable filesystem effects between two bounded states. */ +export const classifyFilesystemEffects = ( + before: readonly FileState[], + after: readonly FileState[], +): FilesystemCheckpoint["effects"] => { + const beforeByPath = new Map(before.map((file) => [file.path, file])); + const afterByPath = new Map(after.map((file) => [file.path, file])); + return [...new Set([...beforeByPath.keys(), ...afterByPath.keys()])] + .sort() + .map((path) => { + const beforeFile = beforeByPath.get(path) ?? null; + const afterFile = afterByPath.get(path) ?? null; + const status = + beforeFile === null + ? "created" + : afterFile === null + ? "deleted" + : JSON.stringify(beforeFile) === JSON.stringify(afterFile) + ? "unchanged" + : "modified"; + return { path, status, before: beforeFile, after: afterFile } as const; + }); +}; + +/** Coordinates named lifecycle filesystem observations without overlapping scans. */ +export class ProcessCheckpoints { + readonly #captures: FilesystemCheckpoint[] = []; + readonly #captured = new Set(); + readonly #terminalCounts = new Map(); + readonly #terminalTails = new Map(); + readonly #timers = new Set(); + #pending: Promise = Promise.resolve(); + + constructor( + private readonly scenario: ProcessScenario, + private readonly started: number, + before: SnapshotResult, + private readonly signal: AbortSignal | undefined, + ) { + this.#captures.push({ + name: "before", + at_ms: 0, + files: before.files, + effects: [], + truncated: before.truncated, + }); + this.#captured.add("before"); + for (const checkpoint of scenario.checkpoints) { + if (checkpoint.trigger.type !== "time") continue; + const timer = setTimeout( + () => this.capture(checkpoint.name), + checkpoint.trigger.at_ms, + ); + this.#timers.add(timer); + } + } + + /** Observe normalized terminal text and fire matching literal checkpoints once. */ + observeTerminal(data: string): void { + for (const checkpoint of this.scenario.checkpoints) { + const trigger = checkpoint.trigger; + if ( + trigger.type !== "terminal_literal" || + this.#captured.has(checkpoint.name) + ) + continue; + const combined = `${this.#terminalTails.get(checkpoint.name) ?? ""}${data}`; + const count = + (this.#terminalCounts.get(checkpoint.name) ?? 0) + + countOccurrences(combined, trigger.value); + this.#terminalCounts.set(checkpoint.name, count); + this.#terminalTails.set( + checkpoint.name, + trigger.value.length === 1 + ? "" + : combined.slice(-(trigger.value.length - 1)), + ); + if (count >= trigger.occurrence) this.capture(checkpoint.name); + } + } + + /** Capture every checkpoint assigned to the specified lifecycle trigger. */ + trigger(type: "root_exit" | "settled"): void { + for (const checkpoint of this.scenario.checkpoints) + if (checkpoint.trigger.type === type) this.capture(checkpoint.name); + } + + /** Queue one idempotent named snapshot. */ + capture(name: string): void { + if (this.#captured.has(name)) return; + this.#captured.add(name); + this.#pending = this.#pending.then(async () => { + const snapshot = await snapshotRoots(this.scenario, this.signal); + const previous = this.#captures.at(-1)?.files ?? []; + this.#captures.push({ + name, + at_ms: Math.max(0, Date.now() - this.started), + files: snapshot.files, + effects: classifyFilesystemEffects(previous, snapshot.files), + truncated: snapshot.truncated, + }); + }); + } + + /** Finish pending snapshots and return them in observation order. */ + async finish( + after: SnapshotResult, + ): Promise { + for (const timer of this.#timers) clearTimeout(timer); + await this.#pending; + const previous = this.#captures.at(-1)?.files ?? []; + this.#captures.push({ + name: "after_settlement", + at_ms: Math.max(0, Date.now() - this.started), + files: after.files, + effects: classifyFilesystemEffects(previous, after.files), + truncated: after.truncated, + }); + return this.#captures; + } + + /** Cancel scheduled captures and await any snapshot already in progress. */ + async dispose(): Promise { + for (const timer of this.#timers) clearTimeout(timer); + this.#timers.clear(); + await this.#pending; + } +} + +const countOccurrences = (text: string, value: string): number => { + let count = 0; + let offset = 0; + while (offset <= text.length - value.length) { + const found = text.indexOf(value, offset); + if (found < 0) break; + count += 1; + offset = found + value.length; + } + return count; +}; diff --git a/src/application/ProcessCli.ts b/src/application/ProcessCli.ts new file mode 100644 index 000000000..d329821a2 --- /dev/null +++ b/src/application/ProcessCli.ts @@ -0,0 +1,77 @@ +import { readFile } from "node:fs/promises"; + +import { parseConfig } from "../config.js"; +import { createEvidence, parseEvidence } from "../domain/evidence.js"; +import { jsonValueSchema } from "../domain/jsonValue.js"; +import { + compareProcessCaptures, + parseProcessScenario, + processCaptureSchema, +} from "../domain/processCapture.js"; +import { captureProcessScenario } from "./ProcessHarness.js"; +import { + PROCESS_PROVIDER, + createProcessCaptureEvidence, +} from "./ProcessEvidence.js"; + +const MAX_INPUT_BYTES = 64 * 1024 * 1024; + +/** Capture one JSON scenario through the same policy and evidence contract as MCP. */ +export const captureProcessScenarioFile = async (path: string) => { + const scenario = parseProcessScenario(await readJson(path)); + const config = parseConfig(process.env); + if (!config.ok) throw config.error; + const captured = await captureProcessScenario( + scenario, + config.value.processExecutionPolicy, + ); + if (!captured.ok) throw captured.error; + return createProcessCaptureEvidence(scenario, captured.value); +}; + +/** Compare two capture Evidence files and emit derived comparison Evidence. */ +export const compareProcessEvidenceFiles = async ( + leftPath: string, + rightPath: string, +) => { + const left = parseCaptureEvidence(await readJson(leftPath)); + const right = parseCaptureEvidence(await readJson(rightPath)); + const comparison = compareProcessCaptures(left.capture, right.capture); + return createEvidence(undefined, PROCESS_PROVIDER, { + predicateType: "rea.process-comparison/v2", + operation: "compare_process_captures", + parameters: { + left_evidence_id: left.id, + right_evidence_id: right.id, + left_normalization: left.capture.normalization, + right_normalization: right.capture.normalization, + }, + result: jsonValueSchema.parse(comparison), + confidence: "derived", + authority: "analyst-inference", + limitations: comparison.limitations, + evidenceLinks: [left.id, right.id], + }); +}; + +const parseCaptureEvidence = (input: unknown) => { + const evidence = parseEvidence(input); + if ( + evidence.operation !== "capture_process_scenario" || + evidence.predicate_type !== "rea.process-capture/v3" || + evidence.provider.id !== PROCESS_PROVIDER.id || + evidence.provider.version !== PROCESS_PROVIDER.version + ) + throw new TypeError("Expected REA Process Capture v3 Evidence"); + return { + id: evidence.evidence_id, + capture: processCaptureSchema.parse(evidence.normalized_result), + }; +}; + +const readJson = async (path: string): Promise => { + const bytes = await readFile(path); + if (bytes.length > MAX_INPUT_BYTES) + throw new TypeError("Process JSON input exceeds byte limit"); + return JSON.parse(bytes.toString("utf8")) as unknown; +}; diff --git a/src/application/ProcessEvidence.ts b/src/application/ProcessEvidence.ts new file mode 100644 index 000000000..4819caf7f --- /dev/null +++ b/src/application/ProcessEvidence.ts @@ -0,0 +1,48 @@ +import type { JsonValue } from "../domain/jsonValue.js"; +import { createEvidence } from "../domain/evidence.js"; +import { jsonValueSchema } from "../domain/jsonValue.js"; +import type { + ProcessCapture, + ProcessScenario, +} from "../domain/processCapture.js"; + +/** Provider identity for controlled Process Capture v3 evidence. */ +export const PROCESS_PROVIDER = { + id: "rea-process", + name: "REA deterministic process harness", + version: "2", +} as const; + +/** Project one process scenario into secret-free Evidence parameters. */ +const processEvidenceParameters = ( + scenario: ProcessScenario, +): Readonly> => ({ + executable_name: scenario.executable.split("/").at(-1) ?? scenario.executable, + argument_count: scenario.arguments.length, + event_count: scenario.events.length, + filesystem_root_count: scenario.filesystem_roots.length, + checkpoint_count: scenario.checkpoints.length, + command_shim_count: scenario.command_shims.length, + normalization: scenario.normalization, +}); + +/** Create one canonical observed Process Capture v3 Evidence record. */ +export const createProcessCaptureEvidence = ( + scenario: ProcessScenario, + capture: ProcessCapture, +) => + createEvidence(undefined, PROCESS_PROVIDER, { + predicateType: "rea.process-capture/v3", + operation: "capture_process_scenario", + parameters: processEvidenceParameters(scenario), + result: jsonValueSchema.parse(capture), + confidence: "observed", + authority: "controlled-replay", + environment: { + id: `${process.platform}-${process.arch}`, + platform: process.platform, + architecture: process.arch, + isolation: "process", + }, + limitations: capture.limitations, + }); diff --git a/src/application/ProcessHarness.ts b/src/application/ProcessHarness.ts index 5ec13a6f4..51ff54df1 100644 --- a/src/application/ProcessHarness.ts +++ b/src/application/ProcessHarness.ts @@ -1,10 +1,11 @@ import { randomUUID } from "node:crypto"; import { mkdtemp, realpath, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { spawn, type IPty } from "node-pty"; import type { - FileState, + FilesystemCheckpoint, + InteractionEvent, ProcessCapture, ProcessExecutionPolicy, ProcessSample, @@ -18,6 +19,15 @@ import { startLoopbackReplay, type LoopbackReplay } from "./LoopbackReplay.js"; import { cleanupOwnedProcessGroup } from "./ProcessOwnership.js"; import { startProcessSampler } from "./ProcessSampling.js"; import { snapshotRoots, type SnapshotResult } from "./FilesystemSnapshot.js"; +import { + classifyFilesystemEffects, + ProcessCheckpoints, +} from "./ProcessCheckpoints.js"; +import { TerminalRenderer } from "./TerminalRenderer.js"; +import { + startCommandShimReplay, + type CommandShimReplay, +} from "./CommandShimReplay.js"; import { normalizeProcessSamples, normalizeProcessText, @@ -104,12 +114,18 @@ export const probeProcessCaptureCapability = } }; +interface EnvironmentOptions { + readonly scenario: ProcessScenario; + readonly home: string; + readonly replay: LoopbackReplay; + readonly shimReplay: CommandShimReplay; + readonly runId: string; +} + const makeEnvironment = ( - scenario: ProcessScenario, - home: string, - replay: LoopbackReplay, - runId: string, + options: EnvironmentOptions, ): Record => { + const { scenario, home, replay, shimReplay, runId } = options; const environment: Record = { ...scenario.environment, HOME: home, @@ -122,36 +138,75 @@ const makeEnvironment = ( } environment.REA_REPLAY_HTTP_URL = replay.httpUrl; environment.REA_REPLAY_WEBSOCKET_URL = replay.websocketUrl; + environment.REA_SHIM_LEDGER_URL = shimReplay.url; + environment.PATH = [shimReplay.binPath, environment.PATH ?? ""] + .filter((part) => part.length > 0) + .join(":"); return environment; }; -const scheduleScenarioEvents = ( - scenario: ProcessScenario, - getTerminal: () => IPty | undefined, - timers: Set, -): void => { - for (const event of scenario.events) { +interface ScenarioEventOptions { + readonly scenario: ProcessScenario; + readonly getTerminal: () => IPty | undefined; + readonly timers: Set; + readonly interactions: InteractionEvent[]; + readonly renderer: TerminalRenderer; + readonly started: number; + readonly dispatchedEventIndexes: Set; +} + +const scheduleScenarioEvents = (options: ScenarioEventOptions): void => { + const { scenario, getTerminal, timers, interactions, renderer, started } = + options; + for (const [eventIndex, event] of scenario.events.entries()) { const timer = setTimeout(() => { + options.dispatchedEventIndexes.add(eventIndex); const terminal = getTerminal(); - if (event.type === "input") terminal?.write(event.data); - else if (event.type === "resize") - terminal?.resize(event.columns, event.rows); - else terminal?.kill(event.signal); + const dispatchedAt = Math.max(0, Date.now() - started); + let outcome: InteractionEvent["outcome"] = "dispatched"; + if (terminal === undefined) outcome = "target_exited"; + else { + try { + if (event.type === "input") terminal.write(event.data); + else if (event.type === "resize") { + terminal.resize(event.columns, event.rows); + renderer.resize(event.columns, event.rows, dispatchedAt); + } else terminal.kill(event.signal); + } catch { + outcome = "failed"; + } + } + interactions.push({ + sequence: interactions.length, + scheduled_at_ms: event.at_ms, + dispatched_at_ms: dispatchedAt, + type: event.type, + data: + event.type === "input" + ? event.sensitive + ? `` + : normalizeProcessText( + event.data, + scenario, + "", + -1, + ) + : event.type === "resize" + ? `${String(event.columns)}x${String(event.rows)}` + : event.signal, + outcome, + }); }, event.at_ms); timers.add(timer); } }; -const startScenarioEvents = ( - scenario: ProcessScenario, - getTerminal: () => IPty | undefined, - timers: Set, -): (() => void) => { - let started = false; +const startScenarioEvents = (options: ScenarioEventOptions): (() => void) => { + let eventsStarted = false; return () => { - if (started) return; - started = true; - scheduleScenarioEvents(scenario, getTerminal, timers); + if (eventsStarted) return; + eventsStarted = true; + scheduleScenarioEvents(options); }; }; @@ -167,6 +222,8 @@ interface TerminalExitOptions { readonly lastOutput: () => number; readonly signal: AbortSignal | undefined; readonly timers: Set; + readonly interactions: InteractionEvent[]; + readonly dispatchedEventIndexes: ReadonlySet; } interface CaptureResultOptions { @@ -184,36 +241,18 @@ interface CaptureResultOptions { readonly scenario: ProcessScenario; readonly rootPid: number; readonly samplingPartial: boolean; + readonly renderedFrames: ProcessCapture["rendered_frames"]; + readonly interactions: readonly InteractionEvent[]; + readonly checkpoints: readonly FilesystemCheckpoint[]; + readonly shimEvents: ProcessCapture["shim_events"]; } -const classifyFilesystemEffects = ( - before: readonly FileState[], - after: readonly FileState[], -): ProcessCapture["filesystem_effects"] => { - const beforeByPath = new Map(before.map((file) => [file.path, file])); - const afterByPath = new Map(after.map((file) => [file.path, file])); - const paths = [ - ...new Set([...beforeByPath.keys(), ...afterByPath.keys()]), - ].sort(); - return paths.map((path) => { - const beforeFile = beforeByPath.get(path) ?? null; - const afterFile = afterByPath.get(path) ?? null; - const status = - beforeFile === null - ? "created" - : afterFile === null - ? "deleted" - : JSON.stringify(beforeFile) === JSON.stringify(afterFile) - ? "unchanged" - : "modified"; - return { path, status, before: beforeFile, after: afterFile }; - }); -}; - const buildCaptureResult = (options: CaptureResultOptions): ProcessCapture => ({ - schema_version: 2, + schema_version: 3, normalization: options.scenario.normalization, frames: options.frames, + rendered_frames: options.renderedFrames, + interaction_events: options.interactions, exit: { code: options.exit.exitCode < 0 ? null : options.exit.exitCode, signal: options.exit.signal ?? null, @@ -224,6 +263,8 @@ const buildCaptureResult = (options: CaptureResultOptions): ProcessCapture => ({ options.scenario, options.rootPid, ), + filesystem_checkpoints: options.checkpoints, + shim_events: options.shimEvents, protocol_events: redactProtocolEvents( options.replay.events, options.scenario, @@ -266,6 +307,8 @@ const awaitTerminalExit = async ({ lastOutput, signal, timers, + interactions, + dispatchedEventIndexes, }: TerminalExitOptions): Promise<{ exitCode: number; signal?: number; @@ -274,6 +317,22 @@ const awaitTerminalExit = async ({ new Promise((resolveExit) => { let reason: "exited" | "timeout" | "idle_timeout" | "cancelled" = "exited"; terminal.onExit((exit) => { + for (const [eventIndex, event] of scenario.events.entries()) { + if (dispatchedEventIndexes.has(eventIndex)) continue; + interactions.push({ + sequence: interactions.length, + scheduled_at_ms: event.at_ms, + dispatched_at_ms: Math.max(0, Date.now() - started), + type: event.type, + data: + event.type === "input" + ? "" + : event.type === "resize" + ? `${String(event.columns)}x${String(event.rows)}` + : event.signal, + outcome: "target_exited", + }); + } for (const timer of timers) { clearTimeout(timer); clearInterval(timer); @@ -300,8 +359,12 @@ const releaseProcessResources = async (options: { readonly timers: ReadonlySet; readonly replay: LoopbackReplay | undefined; readonly terminal: IPty | undefined; + readonly renderer: TerminalRenderer | undefined; + readonly shimReplay: CommandShimReplay | undefined; + readonly checkpoints: ProcessCheckpoints | undefined; readonly runId: string; readonly temporaryRoot: string; + readonly sampledProcessGroupIds: readonly number[]; }): Promise => { for (const timer of options.timers) clearTimeout(timer); let failure: string | undefined; @@ -310,13 +373,34 @@ const releaseProcessResources = async (options: { } catch { failure = "loopback replay cleanup failed"; } + try { + await options.shimReplay?.close(); + } catch { + failure ??= "command shim replay cleanup failed"; + } + try { + await options.renderer?.dispose(); + } catch { + failure ??= "terminal renderer cleanup failed"; + } + try { + await options.checkpoints?.dispose(); + } catch { + failure ??= "filesystem checkpoint cleanup failed"; + } if (options.terminal !== undefined && process.platform !== "win32") { - const cleaned = await cleanupOwnedProcessGroup({ - runId: options.runId, - leaderPid: options.terminal.pid, - processGroupId: options.terminal.pid, - }); - if (!cleaned.cleaned) failure ??= cleaned.reason; + const processGroupIds = new Set([ + options.terminal.pid, + ...options.sampledProcessGroupIds, + ]); + for (const processGroupId of processGroupIds) { + const cleaned = await cleanupOwnedProcessGroup({ + runId: options.runId, + leaderPid: processGroupId, + processGroupId, + }); + if (!cleaned.cleaned) failure ??= cleaned.reason; + } } try { await rm(options.temporaryRoot, { recursive: true, force: true }); @@ -334,6 +418,8 @@ const captureTerminalFrames = (options: { readonly temporaryRoot: string; readonly onOutput: () => void; readonly onFirstOutput: () => void; + readonly renderer: TerminalRenderer; + readonly checkpoints: ProcessCheckpoints; }): (() => boolean) => { let outputBytes = 0; let truncated = false; @@ -349,20 +435,24 @@ const captureTerminalFrames = (options: { return; } outputBytes += bytes; + const atMs = + Math.floor( + (Date.now() - options.started) / + options.scenario.normalization.time_bucket_ms, + ) * options.scenario.normalization.time_bucket_ms; + const normalized = normalizeProcessText( + data, + options.scenario, + options.temporaryRoot, + options.terminal.pid, + ); options.frames.push({ sequence: options.frames.length, - at_ms: - Math.floor( - (Date.now() - options.started) / - options.scenario.normalization.time_bucket_ms, - ) * options.scenario.normalization.time_bucket_ms, - data: normalizeProcessText( - data, - options.scenario, - options.temporaryRoot, - options.terminal.pid, - ), + at_ms: atMs, + data: normalized, }); + options.renderer.write(data, atMs); + options.checkpoints.observeTerminal(normalized); }); return () => truncated; }; @@ -422,6 +512,9 @@ const runProcessScenario = async ( let truncated = before.truncated; let terminal: IPty | undefined; let replay: LoopbackReplay | undefined; + let renderer: TerminalRenderer | undefined; + let checkpoints: ProcessCheckpoints | undefined; + let shimReplay: CommandShimReplay | undefined; let started = 0; let lastOutput = 0; const timers = new Set(); @@ -430,19 +523,45 @@ const runProcessScenario = async ( let framesTruncated = (): boolean => false; let stopSampler = async () => ({ partial: false }); let samplingPartial = false; + const interactions: InteractionEvent[] = []; + const dispatchedEventIndexes = new Set(); try { replay = await startLoopbackReplay(scenario); started = Date.now(); lastOutput = started; + renderer = new TerminalRenderer({ + columns: scenario.terminal.columns, + rows: scenario.terminal.rows, + scrollback: scenario.terminal.scrollback, + maxFrames: scenario.limits.frames, + maxBytes: scenario.limits.output_bytes, + normalize: (value) => + normalizeProcessText( + value, + scenario, + temporaryRoot, + terminal?.pid ?? -1, + ), + }); + checkpoints = new ProcessCheckpoints(scenario, started, before, signal); + shimReplay = await startCommandShimReplay(scenario, temporaryRoot, started); terminal = spawn(scenario.executable, [...scenario.arguments], { cwd: scenario.working_directory, - env: makeEnvironment(scenario, home, replay, runId), - cols: 80, - rows: 24, + env: makeEnvironment({ scenario, home, replay, shimReplay, runId }), + cols: scenario.terminal.columns, + rows: scenario.terminal.rows, name: "xterm-256color", }); - const startEvents = startScenarioEvents(scenario, () => terminal, timers); + const startEvents = startScenarioEvents({ + scenario, + getTerminal: () => terminal, + timers, + interactions, + renderer, + started, + dispatchedEventIndexes, + }); if (!eventsRequireReadiness(scenario)) startEvents(); framesTruncated = captureTerminalFrames({ terminal, @@ -452,6 +571,8 @@ const runProcessScenario = async ( temporaryRoot, onOutput: () => (lastOutput = Date.now()), onFirstOutput: startEvents, + renderer, + checkpoints, }); stopSampler = startProcessSampler( terminal.pid, @@ -466,22 +587,30 @@ const runProcessScenario = async ( lastOutput: () => lastOutput, signal, timers, + interactions, + dispatchedEventIndexes, }); const exitReason = exit.reason; - samplingPartial = (await stopSampler()).partial; + checkpoints.trigger("root_exit"); if (exitReason === "cancelled") throw new ProcessCaptureError("process capture was cancelled"); await new Promise((resolveSettle) => setTimeout(resolveSettle, scenario.settle_ms), ); + checkpoints.trigger("settled"); + samplingPartial = (await stopSampler()).partial; assertNotCancelled(signal); const after = await snapshotRoots(scenario, signal); + const renderedFrames = await renderer.frames(); + const filesystemCheckpoints = await checkpoints.finish(after); truncated ||= after.truncated || replay.truncated || + shimReplay.truncated || framesTruncated() || - samples.length >= scenario.limits.processes; - truncated ||= samplingPartial; + filesystemCheckpoints.some(({ truncated: partial }) => partial) || + samplingPartial; + truncated ||= renderer.truncated(); capture = buildCaptureResult({ frames, exit: { ...exit, reason: exitReason }, @@ -493,6 +622,26 @@ const runProcessScenario = async ( scenario, rootPid: terminal.pid, samplingPartial, + renderedFrames, + interactions, + checkpoints: filesystemCheckpoints, + shimEvents: shimReplay.events.map((event) => ({ + ...event, + arguments: event.arguments.map((argument) => + normalizeProcessText( + argument, + scenario, + temporaryRoot, + terminal?.pid ?? -1, + ), + ), + working_directory: normalizeProcessText( + event.working_directory, + scenario, + temporaryRoot, + terminal?.pid ?? -1, + ), + })), }); } catch (cause: unknown) { terminal?.kill("SIGKILL"); @@ -503,8 +652,14 @@ const runProcessScenario = async ( timers, replay, terminal, + renderer, + shimReplay, + checkpoints, runId, temporaryRoot, + sampledProcessGroupIds: samples.flatMap(({ process_group_id }) => + process_group_id === null ? [] : [process_group_id], + ), }); return resolveProcessResult(capture, executionFailure, cleanupFailure); }; diff --git a/src/application/ProcessNormalization.ts b/src/application/ProcessNormalization.ts index 121edd50c..1fde7dcf6 100644 --- a/src/application/ProcessNormalization.ts +++ b/src/application/ProcessNormalization.ts @@ -46,7 +46,12 @@ export const normalizeProcessSamples = ( ): readonly ProcessSample[] => { const identifiers = [ rootPid, - ...samples.flatMap((sample) => [sample.pid, sample.parent_pid]), + ...samples.flatMap((sample) => [ + sample.pid, + sample.parent_pid, + sample.process_group_id ?? 0, + sample.session_id ?? 0, + ]), ]; const mapping = new Map(); for (const identifier of identifiers) @@ -58,6 +63,12 @@ export const normalizeProcessSamples = ( scenario.normalization.time_bucket_ms, pid: mapping.get(sample.pid) ?? 1, parent_pid: mapping.get(sample.parent_pid) ?? 0, + process_group_id: + sample.process_group_id === null + ? null + : (mapping.get(sample.process_group_id) ?? 0), + session_id: + sample.session_id === null ? null : (mapping.get(sample.session_id) ?? 0), command: normalizeProcessText( sample.command, scenario, diff --git a/src/application/ProcessSampling.ts b/src/application/ProcessSampling.ts index 87f963916..b79f8bf61 100644 --- a/src/application/ProcessSampling.ts +++ b/src/application/ProcessSampling.ts @@ -9,6 +9,8 @@ const PROC_READ_CONCURRENCY = 64; interface ProcessRow { readonly pid: number; readonly parent_pid: number; + readonly process_group_id: number | null; + readonly session_id: number | null; readonly command: string; readonly startTime: string | undefined; } @@ -21,7 +23,6 @@ interface SampleProcessContext { readonly rootPid: number; readonly elapsedMs: number; readonly limit: number; - readonly sampledPids: Set; readonly identities: Map; readonly signal: AbortSignal; } @@ -51,12 +52,18 @@ const parseProcStat = ( .split(/\s+/); const pid = Number(identifier); const parentPid = Number(fields[1]); + const processGroupId = Number(fields[2]); + const sessionId = Number(fields[3]); const startTime = fields[19]; if ( !Number.isSafeInteger(pid) || pid <= 0 || !Number.isSafeInteger(parentPid) || parentPid < 0 || + !Number.isSafeInteger(processGroupId) || + processGroupId <= 0 || + !Number.isSafeInteger(sessionId) || + sessionId <= 0 || startTime === undefined || !/^\d+$/.test(startTime) ) @@ -64,6 +71,8 @@ const parseProcStat = ( return { pid, parent_pid: parentPid, + process_group_id: processGroupId, + session_id: sessionId, command: "", startTime, }; @@ -153,6 +162,8 @@ const inspectProcess = async ( return { pid: before.pid, parent_pid: before.parent_pid, + process_group_id: before.process_group_id, + session_id: before.session_id, startTime: before.startTime, command, children: children ?? [], @@ -162,7 +173,7 @@ const inspectProcess = async ( const sampleLinux = async ( context: SampleProcessContext, ): Promise => { - const { rootPid, limit, signal, sampledPids, identities } = context; + const { rootPid, limit, signal, identities } = context; if (limit <= 0) return []; const rootStat = await readProcessStat(rootPid, signal); @@ -192,10 +203,8 @@ const sampleLinux = async ( for (const node of batchResults) { if (node === undefined) continue; - if (!sampledPids.has(node.pid)) { - if (rows.length >= limit) break; - rows.push(node); - } + if (rows.length >= limit) break; + rows.push(node); if (rows.length >= limit) break; for (const child of node.children) { @@ -217,17 +226,19 @@ const readPsRows = async ( ): Promise => { const { stdout } = await execFileAsync( "ps", - ["-axo", "pid=,ppid=,command="], + ["-axo", "pid=,ppid=,pgid=,sess=,command="], { signal }, ); return stdout .split("\n") - .map((line) => /\s*(\d+)\s+(\d+)\s+(.*)/u.exec(line)) + .map((line) => /\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(.*)/u.exec(line)) .filter((match): match is RegExpExecArray => match !== null) .map((match) => ({ pid: Number(match[1]), parent_pid: Number(match[2]), - command: match[3] ?? "", + process_group_id: Number(match[3]), + session_id: Number(match[4]), + command: match[5] ?? "", startTime: undefined, })) .filter( @@ -243,7 +254,7 @@ const readPsRows = async ( const samplePs = async ( context: SampleProcessContext, ): Promise => { - const { rootPid, limit, signal, sampledPids } = context; + const { rootPid, limit, signal } = context; if (limit <= 0) return []; const rows = await readPsRows(signal); @@ -268,9 +279,7 @@ const samplePs = async ( visited.add(pid); const row = rowByPid.get(pid); - if (row !== undefined && !sampledPids.has(row.pid)) { - result.push(row); - } + if (row !== undefined) result.push(row); if (result.length >= limit) break; for (const child of childrenByParent.get(pid) ?? []) { @@ -291,10 +300,9 @@ const sampleProcesses = async ( ? await sampleLinux(context) : await samplePs(context); - const { elapsedMs, sampledPids, identities } = context; + const { elapsedMs, identities } = context; const samples: ProcessSample[] = []; for (const row of rows) { - if (sampledPids.has(row.pid)) continue; if (row.startTime !== undefined) { const existing = identities.get(row.pid); if (existing !== undefined && existing !== row.startTime) continue; @@ -305,6 +313,8 @@ const sampleProcesses = async ( pid: row.pid, parent_pid: row.parent_pid, command: row.command, + process_group_id: row.process_group_id, + session_id: row.session_id, }); } return samples; @@ -318,30 +328,38 @@ export const startProcessSampler = ( samples: ProcessSample[], ): (() => Promise<{ readonly partial: boolean }>) => { const identities = new Map(); - const sampledPids = new Set(samples.map(({ pid }) => pid)); + const lastObservations = new Map(); let pending: Promise | undefined; let stopped = false; let abortCurrent: (() => void) | undefined; let partial = false; const sample = (): void => { - if (stopped || pending !== undefined || sampledPids.size >= limit) return; + if (stopped || pending !== undefined) return; const controller = new AbortController(); abortCurrent = () => controller.abort(); pending = sampleProcesses({ rootPid, elapsedMs: Date.now() - started, - limit: limit - sampledPids.size, - sampledPids, + limit: limit + 1, identities, signal: controller.signal, }) .then((values) => { for (const value of values) { - if (sampledPids.size >= limit) break; - if (sampledPids.has(value.pid)) continue; - sampledPids.add(value.pid); + const observation = JSON.stringify({ + parent_pid: value.parent_pid, + command: value.command, + process_group_id: value.process_group_id, + session_id: value.session_id, + }); + if (lastObservations.get(value.pid) === observation) continue; + if (samples.length >= limit) { + partial = true; + continue; + } + lastObservations.set(value.pid, observation); samples.push(value); } }) diff --git a/src/application/TerminalRenderer.ts b/src/application/TerminalRenderer.ts new file mode 100644 index 000000000..4737b7d0d --- /dev/null +++ b/src/application/TerminalRenderer.ts @@ -0,0 +1,120 @@ +import { createRequire } from "node:module"; + +import type { RenderedTerminalFrame } from "../domain/processCapture.js"; + +const require = createRequire(import.meta.url); +// SAFETY: both pinned xterm packages publish CommonJS at runtime and matching declarations. +const HeadlessPackage = + require("@xterm/headless") as typeof import("@xterm/headless"); +// SAFETY: the addon package is pinned with the compatible headless xterm release. +const SerializePackage = + require("@xterm/addon-serialize") as typeof import("@xterm/addon-serialize"); + +interface TerminalRendererOptions { + readonly columns: number; + readonly rows: number; + readonly scrollback: number; + readonly maxFrames: number; + readonly maxBytes: number; + readonly normalize: (value: string) => string; +} + +/** Owns one headless terminal and serializes writes into deterministic frames. */ +export class TerminalRenderer { + readonly #terminal: InstanceType; + readonly #serializeAddon = new SerializePackage.SerializeAddon(); + readonly #frames: RenderedTerminalFrame[] = []; + #pending: Promise = Promise.resolve(); + #capturedBytes = 0; + #truncated = false; + + constructor(private readonly options: TerminalRendererOptions) { + this.#terminal = new HeadlessPackage.Terminal({ + allowProposedApi: true, + cols: options.columns, + rows: options.rows, + scrollback: options.scrollback, + }); + this.#terminal.loadAddon(this.#serializeAddon); + } + + /** Queue one PTY chunk and capture state only after xterm has parsed it. */ + write(data: string, atMs: number): void { + this.#pending = this.#pending.then( + () => + new Promise((resolveWrite) => { + this.#terminal.write(data, () => { + this.#capture(atMs); + resolveWrite(); + }); + }), + ); + } + + /** Queue a terminal resize after every preceding write. */ + resize(columns: number, rows: number, atMs: number): void { + this.#pending = this.#pending.then(() => { + this.#terminal.resize(columns, rows); + this.#capture(atMs); + }); + } + + /** Await all queued parsing and return immutable rendered observations. */ + async frames(): Promise { + await this.#pending; + return this.#frames; + } + + /** Whether a rendered observation exceeded its independent capture budget. */ + truncated(): boolean { + return this.#truncated; + } + + /** Release addon and terminal resources after all writes settle. */ + async dispose(): Promise { + await this.#pending; + this.#serializeAddon.dispose(); + this.#terminal.dispose(); + } + + #capture(atMs: number): void { + const buffer = this.#terminal.buffer.active; + const lines: string[] = []; + for (let row = 0; row < this.#terminal.rows; row += 1) { + const line = buffer.getLine(buffer.viewportY + row); + lines.push( + this.options.normalize( + (line?.translateToString(false, 0, this.#terminal.cols) ?? "").padEnd( + this.#terminal.cols, + " ", + ), + ), + ); + } + const serializedState = this.options.normalize( + this.#serializeAddon.serialize(), + ); + const bytes = + Buffer.byteLength(serializedState) + + lines.reduce((total, line) => total + Buffer.byteLength(line), 0); + if ( + this.#frames.length >= this.options.maxFrames || + this.#capturedBytes + bytes > this.options.maxBytes + ) { + this.#truncated = true; + return; + } + this.#capturedBytes += bytes; + this.#frames.push({ + sequence: this.#frames.length, + at_ms: atMs, + columns: this.#terminal.cols, + rows: this.#terminal.rows, + cursor_x: buffer.cursorX, + cursor_y: buffer.cursorY, + active_buffer: buffer.type, + lines, + serialized_state: serializedState, + }); + } +} diff --git a/src/cli.ts b/src/cli.ts index ce07f8903..209ddef1c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -17,6 +17,7 @@ import { logCliCommand } from "./cliLogging.js"; import { parseConfig } from "./config.js"; import { importReferenceSource } from "./application/ReferenceSourceImport.js"; import { registerEvidenceCommands } from "./cliEvidenceCommands.js"; +import { registerProcessCommands } from "./cliProcessCommands.js"; /** * Build the one-shot Incur CLI without starting Hopper at import time. @@ -60,6 +61,7 @@ export const createCli = (): ReturnType => { registerArtifactCommands(cli, logger); registerEvidenceCommands(cli, logger); registerReferenceSourceCommand(cli, logger); + registerProcessCommands(cli, logger); return cli; }; diff --git a/src/cliProcessCommands.ts b/src/cliProcessCommands.ts new file mode 100644 index 000000000..a7beefcff --- /dev/null +++ b/src/cliProcessCommands.ts @@ -0,0 +1,34 @@ +import { Cli, z } from "incur"; + +import { + captureProcessScenarioFile, + compareProcessEvidenceFiles, +} from "./application/ProcessCli.js"; +import { logCliCommand } from "./cliLogging.js"; +import type { Logger } from "./logger.js"; + +/** Register Process Capture v3 one-shot commands through shared application services. */ +export const registerProcessCommands = ( + cli: ReturnType, + logger: Logger, +): void => { + cli.command("capture-process", { + description: "Capture one approved Process Capture v3 JSON scenario", + args: z.object({ scenario: z.string().describe("Scenario JSON path") }), + run: ({ args }) => + logCliCommand(logger, "capture-process", () => + captureProcessScenarioFile(args.scenario), + ), + }); + cli.command("compare-process-captures", { + description: "Compare two Process Capture v3 Evidence JSON files", + args: z.object({ + left: z.string().describe("Left capture Evidence JSON path"), + right: z.string().describe("Right capture Evidence JSON path"), + }), + run: ({ args }) => + logCliCommand(logger, "compare-process-captures", () => + compareProcessEvidenceFiles(args.left, args.right), + ), + }); +}; diff --git a/src/contracts/investigationExamples.ts b/src/contracts/investigationExamples.ts index c7ec1e3f4..0c4da92cf 100644 --- a/src/contracts/investigationExamples.ts +++ b/src/contracts/investigationExamples.ts @@ -45,12 +45,12 @@ export const FUNCTION_COMPARISON_EVIDENCE = createEvidence( const PROCESS_PROVIDER = { id: "rea-process", name: "REA deterministic process harness", - version: "1", + version: "2", } as const; const capture = processCaptureSchema.parse(EMPTY_PROCESS_CAPTURE_EXAMPLE); const captureEvidence = (scenario: string) => createEvidence(undefined, PROCESS_PROVIDER, { - predicateType: "rea.process-capture/v2", + predicateType: "rea.process-capture/v3", operation: "capture_process_scenario", parameters: { scenario }, result: jsonValueSchema.parse(capture), @@ -69,7 +69,7 @@ export const PROCESS_COMPARISON_EVIDENCE = createEvidence( undefined, PROCESS_PROVIDER, { - predicateType: "rea.process-comparison/v1", + predicateType: "rea.process-comparison/v2", operation: "compare_process_captures", parameters: { left_evidence_id: PROCESS_CAPTURE_REFERENCE.evidence_id, diff --git a/src/contracts/processCaptureExample.ts b/src/contracts/processCaptureExample.ts index 2144cfac6..6bc8420ac 100644 --- a/src/contracts/processCaptureExample.ts +++ b/src/contracts/processCaptureExample.ts @@ -2,7 +2,7 @@ import type { JsonValue } from "../domain/jsonValue.js"; /** Minimal valid process capture used only in public contract examples. */ export const EMPTY_PROCESS_CAPTURE_EXAMPLE = { - schema_version: 2, + schema_version: 3, normalization: { paths: true, pids: true, @@ -11,8 +11,12 @@ export const EMPTY_PROCESS_CAPTURE_EXAMPLE = { patterns: [], }, frames: [], + rendered_frames: [], + interaction_events: [], exit: { code: 0, signal: null, reason: "exited" }, process_samples: [], + filesystem_checkpoints: [], + shim_events: [], protocol_events: [], files_before: [], files_after: [], diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts index 36bcc93fb..a990ec056 100644 --- a/src/contracts/toolContracts.ts +++ b/src/contracts/toolContracts.ts @@ -432,12 +432,12 @@ export const SESSION_TOOL_CONTRACTS = [ ), session( "capture_process_scenario", - "Run one bounded process under a PTY using operator-approved executable and working roots. Requires approved: true; unknown_registry_approved: true separately records capture residuals. Captures normalized terminal frames, descendants, filesystem snapshots, and loopback replay. Disabled unless operator policy enables it; not a security sandbox.", + "Run one bounded process under a PTY using operator-approved executable and working roots. Requires approved: true; unknown_registry_approved: true separately records capture residuals. Captures raw and xterm-rendered terminal frames, scripted interactions, lifecycle filesystem checkpoints, process ownership, declarative command shims, and loopback replay. Disabled unless operator policy enables it; not a security sandbox.", processScenarioSchema, ), session( "compare_process_captures", - "Compare two bounded process captures across terminal, exit, sampled process, filesystem, HTTP, and WebSocket evidence. Missing or truncated observations are never treated as equivalent.", + "Compare two Process Capture v3 observations across terminal, interaction, exit, process, filesystem, command-shim, HTTP, and WebSocket evidence, returning the first bounded divergence. Missing or truncated observations are never treated as equivalent.", z.object({ left_evidence_id: z.string().regex(/^ev_[a-f0-9]{64}$/u), left: processCaptureSchema, diff --git a/src/domain/changedBehavior.ts b/src/domain/changedBehavior.ts index 7c7b316d3..09170ee35 100644 --- a/src/domain/changedBehavior.ts +++ b/src/domain/changedBehavior.ts @@ -64,10 +64,10 @@ type RuntimeStatus = z.infer["status"]; const EXPECTED_PROVIDERS = { compare_process_captures: { - predicate: "rea.process-comparison/v1", + predicate: "rea.process-comparison/v2", id: "rea-process", name: "REA deterministic process harness", - version: "1", + version: "2", }, compare_artifacts: { predicate: "rea.artifact-comparison/v1", diff --git a/src/domain/processCapture.ts b/src/domain/processCapture.ts index ed8554b86..771d25cc3 100644 --- a/src/domain/processCapture.ts +++ b/src/domain/processCapture.ts @@ -10,6 +10,7 @@ const reservedEnvironment = new Set([ "REA_PROCESS_RUN_ID", "REA_REPLAY_HTTP_URL", "REA_REPLAY_WEBSOCKET_URL", + "REA_SHIM_LEDGER_URL", ]); const normalizationSchema = z.object({ paths: z.boolean(), @@ -23,6 +24,17 @@ const normalizationSchema = z.object({ }), ), }); +const checkpointNameSchema = z + .string() + .regex(/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/u); +const commandNameSchema = z + .string() + .regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u); +const outputChunkSchema = z.object({ + at_ms: z.number().int().nonnegative().max(300_000), + stream: z.enum(["stdout", "stderr"]), + data: z.string().max(1_000_000), +}); /** Exact boundary schema for bounded dynamic process scenarios. */ export const processScenarioSchema = z @@ -44,6 +56,62 @@ export const processScenarioSchema = z secret_aliases: z.array(environmentName).max(64).default([]), network_access: z.literal("host").default("host"), filesystem_roots: z.array(z.string().startsWith("/")).max(16).default([]), + terminal: z + .object({ + columns: z.number().int().min(1).max(1_000).default(80), + rows: z.number().int().min(1).max(1_000).default(24), + scrollback: z.number().int().min(0).max(10_000).default(1_000), + }) + .default({ columns: 80, rows: 24, scrollback: 1_000 }), + checkpoints: z + .array( + z.object({ + name: checkpointNameSchema, + trigger: z.discriminatedUnion("type", [ + z.object({ + type: z.literal("time"), + at_ms: z.number().int().nonnegative().max(300_000), + }), + z.object({ + type: z.literal("terminal_literal"), + value: z.string().min(1).max(10_000), + occurrence: z.number().int().positive().max(1_000).default(1), + }), + z.object({ type: z.literal("root_exit") }), + z.object({ type: z.literal("settled") }), + ]), + }), + ) + .max(64) + .default([]), + command_shims: z + .array( + z.object({ + name: commandNameSchema, + routes: z + .array( + z.object({ + arguments: z.array(z.string()).max(256), + outputs: z.array(outputChunkSchema).max(1_000).default([]), + termination: z.discriminatedUnion("type", [ + z.object({ + type: z.literal("exit"), + code: z.number().int().min(0).max(255), + }), + z.object({ + type: z.literal("signal"), + signal: z.enum(["SIGINT", "SIGTERM", "SIGKILL"]), + }), + ]), + max_calls: z.number().int().positive().max(100).default(1), + }), + ) + .min(1) + .max(100), + }), + ) + .max(32) + .default([]), events: z .array( z.discriminatedUnion("type", [ @@ -51,6 +119,7 @@ export const processScenarioSchema = z ...timedEventBase, type: z.literal("input"), data: z.string(), + sensitive: z.boolean().default(false), }), z.object({ ...timedEventBase, @@ -216,6 +285,28 @@ export const processScenarioSchema = z }); } } + const checkpointNames = scenario.checkpoints.map(({ name }) => name); + for (const [index, name] of checkpointNames.entries()) { + if (name === "before" || name === "after_settlement") + context.addIssue({ + code: "custom", + message: "checkpoint name is reserved by the capture lifecycle", + path: ["checkpoints", index, "name"], + }); + } + if (new Set(checkpointNames).size !== checkpointNames.length) + context.addIssue({ + code: "custom", + message: "checkpoint names must be unique", + path: ["checkpoints"], + }); + const shimNames = scenario.command_shims.map(({ name }) => name); + if (new Set(shimNames).size !== shimNames.length) + context.addIssue({ + code: "custom", + message: "command shim names must be unique", + path: ["command_shims"], + }); }); /** A parsed, bounded dynamic process observation scenario. */ @@ -304,6 +395,29 @@ export interface TerminalFrame { readonly data: string; } +/** One rendered terminal state after xterm has parsed a PTY chunk or resize. */ +export interface RenderedTerminalFrame { + readonly sequence: number; + readonly at_ms: number; + readonly columns: number; + readonly rows: number; + readonly cursor_x: number; + readonly cursor_y: number; + readonly active_buffer: "normal" | "alternate"; + readonly lines: readonly string[]; + readonly serialized_state: string; +} + +/** One attempted scripted interaction with the PTY. */ +export interface InteractionEvent { + readonly sequence: number; + readonly scheduled_at_ms: number; + readonly dispatched_at_ms: number; + readonly type: "input" | "resize" | "signal"; + readonly data: string; + readonly outcome: "dispatched" | "target_exited" | "failed"; +} + /** One filesystem state used for before/after comparison. */ export interface FileState { readonly path: string; @@ -327,6 +441,27 @@ export interface ProcessSample { readonly pid: number; readonly parent_pid: number; readonly command: string; + readonly process_group_id: number | null; + readonly session_id: number | null; +} + +/** A named filesystem observation captured during a process lifecycle. */ +export interface FilesystemCheckpoint { + readonly name: string; + readonly at_ms: number; + readonly files: readonly FileState[]; + readonly effects: readonly FileEffect[]; + readonly truncated: boolean; +} + +/** One invocation observed by the declarative command-shim replay adapter. */ +export interface ShimEvent { + readonly sequence: number; + readonly at_ms: number; + readonly command: string; + readonly arguments: readonly string[]; + readonly working_directory: string; + readonly outcome: "matched" | "unmatched" | "exhausted"; } /** A bounded loopback replay observation. */ @@ -347,15 +482,19 @@ export interface ProtocolEvent { /** A bounded process observation with explicit incompleteness metadata. */ export interface ProcessCapture { - readonly schema_version: 2; + readonly schema_version: 3; readonly normalization: z.infer; readonly frames: readonly TerminalFrame[]; + readonly rendered_frames: readonly RenderedTerminalFrame[]; + readonly interaction_events: readonly InteractionEvent[]; readonly exit: { readonly code: number | null; readonly signal: number | null; readonly reason: "exited" | "timeout" | "idle_timeout"; }; readonly process_samples: readonly ProcessSample[]; + readonly filesystem_checkpoints: readonly FilesystemCheckpoint[]; + readonly shim_events: readonly ShimEvent[]; readonly protocol_events: readonly ProtocolEvent[]; readonly files_before: readonly FileState[]; readonly files_after: readonly FileState[]; @@ -379,9 +518,27 @@ export interface ProcessCapture { }; } +const fileStateSchema = z.object({ + path: z.string(), + type: z.enum(["file", "directory", "symlink", "other"]), + mode: z.number().int().nonnegative(), + size: z.number().int().nonnegative(), + sha256: z + .string() + .regex(/^[a-f0-9]{64}$/u) + .nullable(), + symlink_target: z.string().nullable(), +}); +const fileEffectSchema = z.object({ + path: z.string(), + status: z.enum(["created", "modified", "deleted", "unchanged"]), + before: fileStateSchema.nullable(), + after: fileStateSchema.nullable(), +}); + /** Exact serialized shape of a bounded process capture. */ export const processCaptureSchema: z.ZodType = z.object({ - schema_version: z.literal(2), + schema_version: z.literal(3), normalization: normalizationSchema, frames: z.array( z.object({ @@ -390,6 +547,29 @@ export const processCaptureSchema: z.ZodType = z.object({ data: z.string(), }), ), + rendered_frames: z.array( + z.object({ + sequence: z.number().int().nonnegative(), + at_ms: z.number().int().nonnegative(), + columns: z.number().int().positive(), + rows: z.number().int().positive(), + cursor_x: z.number().int().nonnegative(), + cursor_y: z.number().int().nonnegative(), + active_buffer: z.enum(["normal", "alternate"]), + lines: z.array(z.string()), + serialized_state: z.string(), + }), + ), + interaction_events: z.array( + z.object({ + sequence: z.number().int().nonnegative(), + scheduled_at_ms: z.number().int().nonnegative(), + dispatched_at_ms: z.number().int().nonnegative(), + type: z.enum(["input", "resize", "signal"]), + data: z.string(), + outcome: z.enum(["dispatched", "target_exited", "failed"]), + }), + ), exit: z.object({ code: z.number().int().nullable(), signal: z.number().int().nullable(), @@ -401,6 +581,27 @@ export const processCaptureSchema: z.ZodType = z.object({ pid: z.number().int().positive(), parent_pid: z.number().int().nonnegative(), command: z.string(), + process_group_id: z.number().int().positive().nullable(), + session_id: z.number().int().nonnegative().nullable(), + }), + ), + filesystem_checkpoints: z.array( + z.object({ + name: z.string(), + at_ms: z.number().int().nonnegative(), + files: z.array(fileStateSchema), + effects: z.array(fileEffectSchema), + truncated: z.boolean(), + }), + ), + shim_events: z.array( + z.object({ + sequence: z.number().int().nonnegative(), + at_ms: z.number().int().nonnegative(), + command: z.string(), + arguments: z.array(z.string()), + working_directory: z.string(), + outcome: z.enum(["matched", "unmatched", "exhausted"]), }), ), protocol_events: z.array( @@ -420,64 +621,9 @@ export const processCaptureSchema: z.ZodType = z.object({ ]), }), ), - files_before: z.array( - z.object({ - path: z.string(), - type: z.enum(["file", "directory", "symlink", "other"]), - mode: z.number().int().nonnegative(), - size: z.number().int().nonnegative(), - sha256: z - .string() - .regex(/^[a-f0-9]{64}$/u) - .nullable(), - symlink_target: z.string().nullable(), - }), - ), - files_after: z.array( - z.object({ - path: z.string(), - type: z.enum(["file", "directory", "symlink", "other"]), - mode: z.number().int().nonnegative(), - size: z.number().int().nonnegative(), - sha256: z - .string() - .regex(/^[a-f0-9]{64}$/u) - .nullable(), - symlink_target: z.string().nullable(), - }), - ), - filesystem_effects: z.array( - z.object({ - path: z.string(), - status: z.enum(["created", "modified", "deleted", "unchanged"]), - before: z - .object({ - path: z.string(), - type: z.enum(["file", "directory", "symlink", "other"]), - mode: z.number().int().nonnegative(), - size: z.number().int().nonnegative(), - sha256: z - .string() - .regex(/^[a-f0-9]{64}$/u) - .nullable(), - symlink_target: z.string().nullable(), - }) - .nullable(), - after: z - .object({ - path: z.string(), - type: z.enum(["file", "directory", "symlink", "other"]), - mode: z.number().int().nonnegative(), - size: z.number().int().nonnegative(), - sha256: z - .string() - .regex(/^[a-f0-9]{64}$/u) - .nullable(), - symlink_target: z.string().nullable(), - }) - .nullable(), - }), - ), + files_before: z.array(fileStateSchema), + files_after: z.array(fileStateSchema), + filesystem_effects: z.array(fileEffectSchema), truncated: z.boolean(), limitations: z.array(z.string()), residual_unknowns: z.array( diff --git a/src/domain/processComparison.ts b/src/domain/processComparison.ts index e7b541a68..e28eb7a6f 100644 --- a/src/domain/processComparison.ts +++ b/src/domain/processComparison.ts @@ -18,19 +18,44 @@ export const processCaptureComparisonSchema = z .object({ status: comparisonStatusSchema, terminal: comparisonStatusSchema, + interaction: comparisonStatusSchema, exit: comparisonStatusSchema, filesystem: comparisonStatusSchema, protocol: comparisonStatusSchema, process: comparisonStatusSchema, + shim: comparisonStatusSchema, + first_divergence: z.discriminatedUnion("status", [ + z.object({ status: z.literal("none") }), + z.object({ status: z.literal("unknown"), reason: z.string() }), + z.object({ + status: z.literal("found"), + dimension: z.enum([ + "terminal", + "interaction", + "exit", + "filesystem", + "protocol", + "process", + "shim", + ]), + index: z.number().int().nonnegative(), + left_at_ms: z.number().int().nonnegative().nullable(), + right_at_ms: z.number().int().nonnegative().nullable(), + left: z.unknown().nullable(), + right: z.unknown().nullable(), + }), + ]), limitations: z.array(z.string()), }) .superRefine((comparison, context) => { const dimensions = [ comparison.terminal, + comparison.interaction, comparison.exit, comparison.filesystem, comparison.protocol, comparison.process, + comparison.shim, ]; const expected = dimensions.includes("truncated") ? "truncated" @@ -50,12 +75,30 @@ export const processCaptureComparisonSchema = z }); type ProcessCaptureComparison = z.infer; -const stableFiles = (capture: ProcessCapture): string => - JSON.stringify( - [...capture.files_after].sort((left, right) => - left.path.localeCompare(right.path), - ), - ); +const terminalObservations = (capture: ProcessCapture): readonly unknown[] => + [ + ...capture.frames.map((frame) => ({ kind: "raw" as const, ...frame })), + ...capture.rendered_frames.map((frame) => ({ + kind: "rendered" as const, + ...frame, + })), + ].sort((left, right) => { + const time = left.at_ms - right.at_ms; + if (time !== 0) return time; + if (left.kind !== right.kind) return left.kind === "raw" ? -1 : 1; + return left.sequence - right.sequence; + }); + +const filesystemObservations = ( + capture: ProcessCapture, +): readonly unknown[] => [ + ...capture.filesystem_checkpoints, + { + name: "final", + files: capture.files_after, + effects: capture.filesystem_effects, + }, +]; const classifyCollection = ( left: readonly unknown[], @@ -84,6 +127,75 @@ const comparisonStatus = ( return dimensions.includes("unknown") ? "unknown" : "unchanged"; }; +type DivergenceDimension = Exclude< + ProcessCaptureComparison["first_divergence"], + { readonly status: "none" | "unknown" } +>["dimension"]; + +const eventTime = (value: unknown): number | null => { + if ( + typeof value === "object" && + value !== null && + "at_ms" in value && + typeof value.at_ms === "number" && + Number.isSafeInteger(value.at_ms) && + value.at_ms >= 0 + ) + return value.at_ms; + return null; +}; + +const firstCollectionDivergence = ( + dimension: DivergenceDimension, + left: readonly unknown[], + right: readonly unknown[], +): Extract< + ProcessCaptureComparison["first_divergence"], + { readonly status: "found" } +> | null => { + const length = Math.max(left.length, right.length); + for (let index = 0; index < length; index += 1) { + const leftValue = left[index] ?? null; + const rightValue = right[index] ?? null; + if (JSON.stringify(leftValue) === JSON.stringify(rightValue)) continue; + return { + status: "found", + dimension, + index, + left_at_ms: eventTime(leftValue), + right_at_ms: eventTime(rightValue), + left: leftValue, + right: rightValue, + }; + } + return null; +}; + +const chooseFirstDivergence = ( + candidates: ReadonlyArray | null>, +): ProcessCaptureComparison["first_divergence"] => { + const found = candidates.filter( + (candidate): candidate is NonNullable => + candidate !== null, + ); + return ( + found.sort((left, right) => { + const leftTime = Math.min( + left.left_at_ms ?? Number.MAX_SAFE_INTEGER, + left.right_at_ms ?? Number.MAX_SAFE_INTEGER, + ); + const rightTime = Math.min( + right.left_at_ms ?? Number.MAX_SAFE_INTEGER, + right.right_at_ms ?? Number.MAX_SAFE_INTEGER, + ); + return leftTime - rightTime; + })[0] ?? { status: "none" } + ); +}; + /** Compare bounded captures without equating missing or incompatible evidence. */ export const compareProcessCaptures = ( left: ProcessCapture, @@ -93,10 +205,16 @@ export const compareProcessCaptures = ( return { status: "truncated", terminal: "truncated", + interaction: "truncated", exit: "truncated", filesystem: "truncated", protocol: "truncated", process: "truncated", + shim: "truncated", + first_divergence: { + status: "unknown", + reason: "At least one capture is truncated.", + }, limitations: ["At least one capture is truncated."], }; } @@ -112,8 +230,13 @@ export const compareProcessCaptures = ( : classifyCollection(leftValues, rightValues); const terminal = classify( "terminal", - left.frames.map(({ data }) => data), - right.frames.map(({ data }) => data), + terminalObservations(left), + terminalObservations(right), + ); + const interaction = classify( + "terminal", + left.interaction_events, + right.interaction_events, ); const exit = hasUnknown(left, "exit") || hasUnknown(right, "exit") @@ -121,17 +244,11 @@ export const compareProcessCaptures = ( : JSON.stringify(left.exit) === JSON.stringify(right.exit) ? "unchanged" : "changed"; - const filesystem = - !hasUnknown(left, "filesystem") && - !hasUnknown(right, "filesystem") && - sameNormalization && - stableFiles(left) === stableFiles(right) - ? "unchanged" - : classify( - "filesystem", - left.filesystem_effects, - right.filesystem_effects, - ); + const filesystem = classify( + "filesystem", + filesystemObservations(left), + filesystemObservations(right), + ); const protocol = classify( "protocol", left.protocol_events, @@ -142,13 +259,68 @@ export const compareProcessCaptures = ( left.process_samples, right.process_samples, ); + const shim = sameNormalization + ? classifyCollection(left.shim_events, right.shim_events) + : "unknown"; + const observedFirstDivergence = chooseFirstDivergence([ + firstCollectionDivergence( + "terminal", + terminalObservations(left), + terminalObservations(right), + ), + firstCollectionDivergence( + "interaction", + left.interaction_events, + right.interaction_events, + ), + firstCollectionDivergence("exit", [left.exit], [right.exit]), + firstCollectionDivergence( + "filesystem", + filesystemObservations(left), + filesystemObservations(right), + ), + firstCollectionDivergence( + "protocol", + left.protocol_events, + right.protocol_events, + ), + firstCollectionDivergence( + "process", + left.process_samples, + right.process_samples, + ), + firstCollectionDivergence("shim", left.shim_events, right.shim_events), + ]); + const firstDivergence = + observedFirstDivergence.status === "found" + ? observedFirstDivergence + : !sameNormalization || + left.residual_unknowns.length > 0 || + right.residual_unknowns.length > 0 + ? ({ + status: "unknown", + reason: + "Residual unknowns prevent proving that no divergence occurred.", + } as const) + : observedFirstDivergence; return { - status: comparisonStatus([terminal, exit, filesystem, protocol, process]), + status: comparisonStatus([ + terminal, + interaction, + exit, + filesystem, + protocol, + process, + shim, + ]), terminal, + interaction, exit, filesystem, protocol, process, + shim, + first_divergence: firstDivergence, limitations: [ ...left.limitations, ...right.limitations, diff --git a/src/domain/reconstructionVerification.ts b/src/domain/reconstructionVerification.ts index 83c523697..c6d8a98b8 100644 --- a/src/domain/reconstructionVerification.ts +++ b/src/domain/reconstructionVerification.ts @@ -44,7 +44,7 @@ const MAX_CANONICAL_BYTES = 32 * 1024 * 1024; const providers = { behavioral: { operation: "compare_process_captures", - predicate: "rea.process-comparison/v1", + predicate: "rea.process-comparison/v2", id: "rea-process", name: "REA deterministic process harness", }, @@ -199,7 +199,7 @@ const validateSourceKinds = ( ): void => { const expected = claim.kind === "behavioral" - ? ["capture_process_scenario", "rea.process-capture/v2"] + ? ["capture_process_scenario", "rea.process-capture/v3"] : claim.kind === "structural-function" ? ["analyze_function", "rea.analysis/v2"] : ["inventory_artifact", "rea.analysis/v2"]; @@ -230,7 +230,7 @@ const validateComparisonIdentity = (claim: Claim, evidence: Evidence): void => { evidence.predicate_type !== expected.predicate || evidence.provider.id !== expected.id || evidence.provider.name !== expected.name || - evidence.provider.version !== "1" || + evidence.provider.version !== (claim.kind === "behavioral" ? "2" : "1") || evidence.confidence !== "derived" || evidence.authority !== "analyst-inference" || evidence.subject !== null diff --git a/src/domain/staticRuntimeCorrelation.ts b/src/domain/staticRuntimeCorrelation.ts index 70f1157df..59d716536 100644 --- a/src/domain/staticRuntimeCorrelation.ts +++ b/src/domain/staticRuntimeCorrelation.ts @@ -158,7 +158,7 @@ const EXPECTED = { "REA function comparison", ], compare_process_captures: [ - "rea.process-comparison/v1", + "rea.process-comparison/v2", "rea-process", "REA deterministic process harness", ], @@ -402,7 +402,8 @@ const assertComparisonIdentity = (evidence: Evidence): void => { evidence.predicate_type !== expected[0] || evidence.provider.id !== expected[1] || evidence.provider.name !== expected[2] || - evidence.provider.version !== "1" || + evidence.provider.version !== + (evidence.operation === "compare_process_captures" ? "2" : "1") || evidence.confidence !== "derived" || evidence.authority !== "analyst-inference" || evidence.subject !== null || diff --git a/src/server/registerProcessComparisonTool.ts b/src/server/registerProcessComparisonTool.ts index c7a45ec49..5089d9841 100644 --- a/src/server/registerProcessComparisonTool.ts +++ b/src/server/registerProcessComparisonTool.ts @@ -41,7 +41,7 @@ export const registerProcessComparisonTool = ( if (!validated.ok) return toCallToolResult(validated, contract); const comparison = compareProcessCaptures(parsed.left, parsed.right); const evidence = createEvidence(undefined, PROCESS_PROVIDER, { - predicateType: "rea.process-comparison/v1", + predicateType: "rea.process-comparison/v2", operation: contract.name, parameters: { left_evidence_id: parsed.left_evidence_id, @@ -85,7 +85,7 @@ const validateCaptureSources = ( if ( evidence === undefined || evidence.operation !== "capture_process_scenario" || - evidence.predicate_type !== "rea.process-capture/v2" || + evidence.predicate_type !== "rea.process-capture/v3" || evidence.provider.id !== PROCESS_PROVIDER.id || evidence.provider.name !== PROCESS_PROVIDER.name || evidence.provider.version !== PROCESS_PROVIDER.version || diff --git a/src/server/registerSessionTools.ts b/src/server/registerSessionTools.ts index 7eeb02dc9..b1dee6841 100644 --- a/src/server/registerSessionTools.ts +++ b/src/server/registerSessionTools.ts @@ -14,8 +14,7 @@ import type { } from "../domain/processCapture.js"; import { processScenarioSchema } from "../domain/processCapture.js"; import { captureProcessScenario } from "../application/ProcessHarness.js"; -import { createEvidence, type Evidence } from "../domain/evidence.js"; -import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; +import type { Evidence } from "../domain/evidence.js"; import { recordUnknownInputSchema, updateUnknownInputSchema, @@ -29,8 +28,8 @@ import { UnknownRegistryError, type AnalysisError } from "../domain/errors.js"; import { DENY_EVIDENCE_FILE_POLICY, DENY_PROCESS_POLICY, - PROCESS_PROVIDER, } from "./sessionToolPolicies.js"; +import { createProcessCaptureEvidence } from "../application/ProcessEvidence.js"; import { registerProcessComparisonTool } from "./registerProcessComparisonTool.js"; import { registerArtifactComparisonTool } from "./registerArtifactComparisonTool.js"; import { registerFunctionComparisonTool } from "./registerFunctionComparisonTool.js"; @@ -76,16 +75,6 @@ const recordProcessResidualUnknowns = ( return ok(null); }; -const processEvidenceParameters = ( - scenario: ProcessScenario, -): Readonly> => ({ - executable_name: scenario.executable.split("/").at(-1) ?? scenario.executable, - argument_count: scenario.arguments.length, - event_count: scenario.events.length, - filesystem_root_count: scenario.filesystem_roots.length, - normalization: scenario.normalization, -}); - interface ProcessToolRegistration { readonly server: McpServer; readonly session: BinarySessionPort; @@ -122,21 +111,7 @@ const registerProcessTools = ({ ), ); if (!captured.ok) return toCallToolResult(captured, captureContract); - const evidence = createEvidence(undefined, PROCESS_PROVIDER, { - predicateType: "rea.process-capture/v2", - operation: captureContract.name, - parameters: processEvidenceParameters(scenario), - result: jsonValueSchema.parse(captured.value), - confidence: "observed", - authority: "controlled-replay", - environment: { - id: `${process.platform}-${process.arch}`, - platform: process.platform, - architecture: process.arch, - isolation: "process", - }, - limitations: captured.value.limitations, - }); + const evidence = createProcessCaptureEvidence(scenario, captured.value); const recorded = session.recordEvidence(evidence); if (!recorded.ok) return toCallToolResult(recorded, captureContract); const unknowns = recordProcessResidualUnknowns( diff --git a/src/server/sessionToolPolicies.ts b/src/server/sessionToolPolicies.ts index 72a9164bd..a3f25b0ad 100644 --- a/src/server/sessionToolPolicies.ts +++ b/src/server/sessionToolPolicies.ts @@ -19,12 +19,7 @@ export const DENY_EVIDENCE_FILE_POLICY: EvidenceFilePolicy = { maxNodes: 1_000_000, }; -/** Provider identity for controlled process-capture evidence. */ -export const PROCESS_PROVIDER = { - id: "rea-process", - name: "REA deterministic process harness", - version: "1", -} as const; +export { PROCESS_PROVIDER } from "../application/ProcessEvidence.js"; /** Provider identity for deterministic cross-artifact comparisons. */ export const ARTIFACT_COMPARISON_PROVIDER = { diff --git a/tests/changedBehavior.test.ts b/tests/changedBehavior.test.ts index 41f227bed..6929cbfd7 100644 --- a/tests/changedBehavior.test.ts +++ b/tests/changedBehavior.test.ts @@ -33,11 +33,11 @@ const comparison = ( name: process ? "REA deterministic process harness" : "REA artifact comparison", - version: "1", + version: operation === "compare_process_captures" ? "2" : "1", }, { predicateType: process - ? "rea.process-comparison/v1" + ? "rea.process-comparison/v2" : "rea.artifact-comparison/v1", operation, parameters: {}, @@ -58,10 +58,26 @@ const processResult = ( ): JsonValue => ({ status: overrides.status ?? "unchanged", terminal: overrides.terminal ?? "unchanged", + interaction: "unchanged", exit: overrides.exit ?? "unchanged", filesystem: "unchanged", protocol: "unchanged", process: "unchanged", + shim: "unchanged", + first_divergence: + overrides.status === "changed" + ? { + status: "found", + dimension: "terminal", + index: 0, + left_at_ms: 0, + right_at_ms: 0, + left: "left", + right: "right", + } + : overrides.status === "truncated" || overrides.status === "unknown" + ? { status: "unknown", reason: "Incomplete fixture." } + : { status: "none" }, limitations: [], }); diff --git a/tests/integration/serverIntegration.test.ts b/tests/integration/serverIntegration.test.ts index 73e410640..a6ebb8054 100644 --- a/tests/integration/serverIntegration.test.ts +++ b/tests/integration/serverIntegration.test.ts @@ -283,7 +283,7 @@ describe("full MCP integration with multi-tool sequences", () => { }); const captureEvidence = (capture: typeof left) => createEvidence(undefined, PROCESS_PROVIDER, { - predicateType: "rea.process-capture/v2", + predicateType: "rea.process-capture/v3", operation: "capture_process_scenario", parameters: {}, result: jsonValueSchema.parse(capture), diff --git a/tests/processCapture.test.ts b/tests/processCapture.test.ts index b00106b38..9ce90a44f 100644 --- a/tests/processCapture.test.ts +++ b/tests/processCapture.test.ts @@ -107,7 +107,7 @@ describe("process capture domain", () => { it("never considers truncated captures equivalent", () => { const capture = { - schema_version: 2 as const, + schema_version: 3 as const, normalization: { paths: true, pids: true, @@ -116,8 +116,12 @@ describe("process capture domain", () => { patterns: [], }, frames: [], + rendered_frames: [], + interaction_events: [], exit: { code: 0, signal: null, reason: "exited" as const }, process_samples: [], + filesystem_checkpoints: [], + shim_events: [], protocol_events: [], files_before: [], files_after: [], @@ -135,7 +139,7 @@ describe("process capture domain", () => { it("classifies missing observations as unknown and one-sided evidence as added", () => { const base = { - schema_version: 2 as const, + schema_version: 3 as const, normalization: { paths: true, pids: true, @@ -144,8 +148,12 @@ describe("process capture domain", () => { patterns: [], }, frames: [], + rendered_frames: [], + interaction_events: [], exit: { code: 0, signal: null, reason: "exited" as const }, process_samples: [], + filesystem_checkpoints: [], + shim_events: [], protocol_events: [], files_before: [], files_after: [], @@ -161,6 +169,19 @@ describe("process capture domain", () => { const added = compareProcessCaptures(base, { ...base, frames: [{ sequence: 0, at_ms: 0, data: "new" }], + rendered_frames: [ + { + sequence: 0, + at_ms: 0, + columns: 3, + rows: 1, + cursor_x: 3, + cursor_y: 0, + active_buffer: "normal", + lines: ["new"], + serialized_state: "new", + }, + ], }); expect(added.terminal).toBe("added"); expect(added.status).toBe("changed"); @@ -169,12 +190,54 @@ describe("process capture domain", () => { base, ); expect(unknown.process).toBe("unknown"); + expect(unknown.shim).toBe("unchanged"); expect(unknown.status).toBe("unknown"); }); + it("compares raw terminal chunks even when rendered states agree", () => { + const capture = { + schema_version: 3 as const, + normalization: { + paths: true, + pids: true, + ports: true, + time_bucket_ms: 10, + patterns: [], + }, + frames: [{ sequence: 0, at_ms: 0, data: "bar" }], + rendered_frames: [], + interaction_events: [], + exit: { code: 0, signal: null, reason: "exited" as const }, + process_samples: [], + filesystem_checkpoints: [], + shim_events: [], + protocol_events: [], + files_before: [], + files_after: [], + filesystem_effects: [], + truncated: false, + limitations: [], + residual_unknowns: [], + cleanup: { + owned_process_group: "verified" as const, + temporary_root: "removed" as const, + }, + }; + + const comparison = compareProcessCaptures(capture, { + ...capture, + frames: [{ sequence: 0, at_ms: 0, data: "foo\rbar" }], + }); + expect(comparison.terminal).toBe("changed"); + expect(comparison.first_divergence).toMatchObject({ + status: "found", + dimension: "terminal", + }); + }); + it("keeps filesystem evidence unknown when stable snapshots match", () => { const capture = { - schema_version: 2 as const, + schema_version: 3 as const, normalization: { paths: true, pids: true, @@ -183,8 +246,12 @@ describe("process capture domain", () => { patterns: [], }, frames: [], + rendered_frames: [], + interaction_events: [], exit: { code: 0, signal: null, reason: "exited" as const }, process_samples: [], + filesystem_checkpoints: [], + shim_events: [], protocol_events: [], files_before: [], files_after: [], @@ -203,11 +270,27 @@ describe("process capture domain", () => { const comparison = compareProcessCaptures(capture, capture); expect(comparison.filesystem).toBe("unknown"); expect(comparison.status).toBe("unknown"); + + const complete = { ...capture, residual_unknowns: [] }; + const transient = compareProcessCaptures(complete, { + ...complete, + filesystem_checkpoints: [ + { + name: "during_run", + at_ms: 10, + files: [], + effects: [], + truncated: false, + }, + ], + }); + expect(transient.filesystem).toBe("changed"); + expect(transient.status).toBe("changed"); }); it("detects changes in normalized process sample metadata", () => { const capture = { - schema_version: 2 as const, + schema_version: 3 as const, normalization: { paths: true, pids: true, @@ -216,10 +299,21 @@ describe("process capture domain", () => { patterns: [], }, frames: [], + rendered_frames: [], + interaction_events: [], exit: { code: 0, signal: null, reason: "exited" as const }, process_samples: [ - { at_ms: 10, pid: 1, parent_pid: 0, command: "worker" }, + { + at_ms: 10, + pid: 1, + parent_pid: 0, + process_group_id: 1, + session_id: 1, + command: "worker", + }, ], + filesystem_checkpoints: [], + shim_events: [], protocol_events: [], files_before: [], files_after: [], @@ -235,7 +329,14 @@ describe("process capture domain", () => { const changed = { ...capture, process_samples: [ - { at_ms: 20, pid: 1, parent_pid: 2, command: "worker" }, + { + at_ms: 20, + pid: 1, + parent_pid: 2, + process_group_id: 1, + session_id: 1, + command: "worker", + }, ], }; @@ -451,6 +552,96 @@ describe("process capture adapter", () => { } }); + it("renders terminal state, records shim invocations, and captures literal checkpoints", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-v3-test-")); + const script = join(root, "scenario.mjs"); + await writeFile( + script, + [ + 'import { spawnSync } from "node:child_process";', + 'const result = spawnSync("codex", ["--version"], { encoding: "utf8" });', + 'const node = spawnSync("node", ["--version"], { encoding: "utf8" });', + "process.stdout.write(`probe:${result.stdout}`);", + "process.stdout.write(`runtime:${node.stdout}`);", + ].join("\n"), + ); + try { + const capability = await probeProcessCaptureCapability(); + if (!capability.available) return; + const result = await captureProcessScenario( + parseProcessScenario({ + approved: true, + executable: process.execPath, + arguments: [script], + working_directory: root, + filesystem_roots: [root], + checkpoints: [ + { + name: "probe_seen", + trigger: { type: "terminal_literal", value: "probe:codex 1.2.3" }, + }, + ], + command_shims: [ + { + name: "codex", + routes: [ + { + arguments: ["--version"], + outputs: [ + { at_ms: 0, stream: "stdout", data: "codex 1.2.3\n" }, + ], + termination: { type: "exit", code: 0 }, + }, + ], + }, + { + name: "node", + routes: [ + { + arguments: ["--version"], + outputs: [ + { at_ms: 0, stream: "stdout", data: "node 9.8.7\n" }, + ], + termination: { type: "exit", code: 0 }, + }, + ], + }, + ], + }), + { + enabled: true, + executableRoots: [dirname(process.execPath)], + workingRoots: [root], + allowedEnvironment: [], + allowExternalNetwork: true, + }, + ); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + expect(result.value.schema_version).toBe(3); + expect(result.value.rendered_frames.at(-1)?.lines.join("\n")).toContain( + "probe:codex 1.2.3", + ); + expect(result.value.shim_events).toEqual([ + expect.objectContaining({ + command: "codex", + arguments: ["--version"], + outcome: "matched", + }), + expect.objectContaining({ + command: "node", + arguments: ["--version"], + outcome: "matched", + }), + ]); + expect( + result.value.filesystem_checkpoints.map(({ name }) => name), + ).toEqual(["before", "probe_seen", "after_settlement"]); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it("does not follow or disclose symlink targets outside declared roots", async () => { const root = await mkdtemp(join(tmpdir(), "rea-symlink-test-")); await symlink("/etc/passwd", join(root, "escape")); @@ -460,13 +651,13 @@ describe("process capture adapter", () => { const result = await captureProcessScenario( parseProcessScenario({ approved: true, - executable: "/bin/true", + executable: "/usr/bin/true", working_directory: root, filesystem_roots: [root], }), { enabled: true, - executableRoots: ["/bin"], + executableRoots: ["/usr/bin"], workingRoots: [root], allowedEnvironment: [], allowExternalNetwork: true, diff --git a/tests/reconstructionVerification.test.ts b/tests/reconstructionVerification.test.ts index 801d07120..e3d85dfcf 100644 --- a/tests/reconstructionVerification.test.ts +++ b/tests/reconstructionVerification.test.ts @@ -33,7 +33,7 @@ const source = ( { predicateType: authority === "controlled-replay" - ? "rea.process-capture/v2" + ? "rea.process-capture/v3" : "rea.analysis/v2", operation: authority === "controlled-replay" @@ -55,10 +55,26 @@ const processResult = ( ): JsonValue => ({ status: terminal === "changed" ? "changed" : terminal, terminal, + interaction: "unchanged", exit: "unchanged", filesystem: "unchanged", protocol: "unchanged", process: "unchanged", + shim: "unchanged", + first_divergence: + terminal === "changed" + ? { + status: "found", + dimension: "terminal", + index: 0, + left_at_ms: 0, + right_at_ms: 0, + left: "left", + right: "right", + } + : terminal === "unknown" + ? { status: "unknown", reason: "Incomplete fixture." } + : { status: "none" }, limitations: [], }); @@ -73,10 +89,10 @@ const processComparison = ( { id: "rea-process", name: "REA deterministic process harness", - version: "1", + version: "2", }, { - predicateType: "rea.process-comparison/v1", + predicateType: "rea.process-comparison/v2", operation: "compare_process_captures", parameters: { left_evidence_id: left.evidence_id, diff --git a/tests/staticRuntimeCorrelation.test.ts b/tests/staticRuntimeCorrelation.test.ts index d4b5016db..ee8c727c1 100644 --- a/tests/staticRuntimeCorrelation.test.ts +++ b/tests/staticRuntimeCorrelation.test.ts @@ -18,7 +18,7 @@ const capture = (digit: string): Evidence => undefined, { id: "fixture", name: "Fixture", version: "1" }, { - predicateType: "rea.process-capture/v2", + predicateType: "rea.process-capture/v3", operation: "capture_process_scenario", parameters: {}, result: { digit }, @@ -41,10 +41,26 @@ const processComparison = ( const result = { status: terminal, terminal, + interaction: terminal === "truncated" ? "truncated" : "unchanged", exit: terminal === "truncated" ? "truncated" : "unchanged", filesystem: terminal === "truncated" ? "truncated" : "unchanged", protocol: terminal === "truncated" ? "truncated" : "unchanged", process: terminal === "truncated" ? "truncated" : "unchanged", + shim: terminal === "truncated" ? "truncated" : "unchanged", + first_divergence: + terminal === "changed" + ? { + status: "found", + dimension: "terminal", + index: 0, + left_at_ms: 0, + right_at_ms: 0, + left: "left", + right: "right", + } + : terminal === "truncated" || terminal === "unknown" + ? { status: "unknown", reason: "Incomplete fixture." } + : { status: "none" }, limitations: terminal === "unknown" ? ["Terminal unavailable."] : [], } satisfies JsonValue; return createEvidence( @@ -52,10 +68,10 @@ const processComparison = ( { id: "rea-process", name: "REA deterministic process harness", - version: "1", + version: "2", }, { - predicateType: "rea.process-comparison/v1", + predicateType: "rea.process-comparison/v2", operation: "compare_process_captures", parameters: { left_evidence_id: leftCapture.evidence_id,