feat(process): add deterministic capture v3

This commit is contained in:
morluto 2026-07-13 17:01:09 +08:00
parent a504342d2b
commit 5a361a6725
29 changed files with 1616 additions and 243 deletions

17
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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;
}
}
}

View File

@ -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<void>;
}
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<CommandShimReplay> => {
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<string, number>();
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<Invocation> => {
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<void> =>
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<void> =>
new Promise((resolveClose, rejectClose) =>
server.close((cause) =>
cause === undefined ? resolveClose() : rejectClose(cause),
),
);

View File

@ -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<string>();
readonly #terminalCounts = new Map<string, number>();
readonly #terminalTails = new Map<string, string>();
readonly #timers = new Set<NodeJS.Timeout>();
#pending: Promise<void> = 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<readonly FilesystemCheckpoint[]> {
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<void> {
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;
};

View File

@ -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<unknown> => {
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;
};

View File

@ -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<Record<string, JsonValue>> => ({
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,
});

View File

@ -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<string, string> => {
const { scenario, home, replay, shimReplay, runId } = options;
const environment: Record<string, string> = {
...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<NodeJS.Timeout>,
): void => {
for (const event of scenario.events) {
interface ScenarioEventOptions {
readonly scenario: ProcessScenario;
readonly getTerminal: () => IPty | undefined;
readonly timers: Set<NodeJS.Timeout>;
readonly interactions: InteractionEvent[];
readonly renderer: TerminalRenderer;
readonly started: number;
readonly dispatchedEventIndexes: Set<number>;
}
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
? `<redacted-input:${String(Buffer.byteLength(event.data))}-bytes>`
: normalizeProcessText(
event.data,
scenario,
"<no-temporary-root>",
-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<NodeJS.Timeout>,
): (() => 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<NodeJS.Timeout>;
readonly interactions: InteractionEvent[];
readonly dispatchedEventIndexes: ReadonlySet<number>;
}
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"
? "<not-dispatched>"
: 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<NodeJS.Timeout>;
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<string | undefined> => {
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<NodeJS.Timeout>();
@ -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<number>();
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);
};

View File

@ -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<number, number>();
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,

View File

@ -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<number>;
readonly identities: Map<number, string>;
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<readonly ProcessRow[]> => {
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<readonly ProcessRow[]> => {
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<readonly ProcessRow[]> => {
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<number, string>();
const sampledPids = new Set<number>(samples.map(({ pid }) => pid));
const lastObservations = new Map<number, string>();
let pending: Promise<void> | 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);
}
})

View File

@ -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<typeof HeadlessPackage.Terminal>;
readonly #serializeAddon = new SerializePackage.SerializeAddon();
readonly #frames: RenderedTerminalFrame[] = [];
#pending: Promise<void> = 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<void>((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<readonly RenderedTerminalFrame[]> {
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<void> {
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,
});
}
}

View File

@ -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<typeof Cli.create> => {
registerArtifactCommands(cli, logger);
registerEvidenceCommands(cli, logger);
registerReferenceSourceCommand(cli, logger);
registerProcessCommands(cli, logger);
return cli;
};

34
src/cliProcessCommands.ts Normal file
View File

@ -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<typeof Cli.create>,
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),
),
});
};

View File

@ -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,

View File

@ -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: [],

View File

@ -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,

View File

@ -64,10 +64,10 @@ type RuntimeStatus = z.infer<typeof processCaptureComparisonSchema>["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",

View File

@ -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<typeof normalizationSchema>;
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<ProcessCapture> = 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<ProcessCapture> = 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<ProcessCapture> = 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<ProcessCapture> = 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(

View File

@ -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<typeof processCaptureComparisonSchema>;
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<Extract<
ProcessCaptureComparison["first_divergence"],
{ readonly status: "found" }
> | null>,
): ProcessCaptureComparison["first_divergence"] => {
const found = candidates.filter(
(candidate): candidate is NonNullable<typeof candidate> =>
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,

View File

@ -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

View File

@ -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 ||

View File

@ -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 ||

View File

@ -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<Record<string, JsonValue>> => ({
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(

View File

@ -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 = {

View File

@ -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: [],
});

View File

@ -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),

View File

@ -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,

View File

@ -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,

View File

@ -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,