From ee4d284597a32b4acc4279b74083d9bf4e32d08b Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:03:41 +0800 Subject: [PATCH] feat(process): introduce evidence-safe capture v4 --- src/application/CommandShimReplay.ts | 2 + src/application/EvidenceBundleFiles.ts | 13 +- src/application/ProcessCli.ts | 16 +- src/application/ProcessEvidence.ts | 19 +- src/application/ProcessHarness.ts | 147 +++++++++- src/application/ProcessOwnership.ts | 41 +++ src/cliProcessCommands.ts | 6 +- src/contracts/investigationExamples.ts | 6 +- src/contracts/processCaptureExample.ts | 56 +++- src/contracts/toolContracts.ts | 5 +- src/domain/changedBehavior.ts | 4 +- src/domain/processCapture.ts | 288 +++++++++++++++++++- src/domain/processComparison.ts | 41 ++- src/domain/reconstructionVerification.ts | 6 +- src/domain/staticRuntimeCorrelation.ts | 4 +- src/identity.ts | 1 + src/server/registerProcessComparisonTool.ts | 30 +- tests/changedBehavior.test.ts | 4 +- tests/integration/serverIntegration.test.ts | 2 +- tests/processCapture.test.ts | 175 +++++++++++- tests/reconstructionVerification.test.ts | 6 +- tests/staticRuntimeCorrelation.test.ts | 6 +- 22 files changed, 793 insertions(+), 85 deletions(-) diff --git a/src/application/CommandShimReplay.ts b/src/application/CommandShimReplay.ts index 7d766da3d..ecd5868ec 100644 --- a/src/application/CommandShimReplay.ts +++ b/src/application/CommandShimReplay.ts @@ -99,6 +99,8 @@ export const startCommandShimReplay = async ( sequence: events.length, at_ms: Math.max(0, Date.now() - started), command: invocation.command, + route_index: + routeIndex !== undefined && routeIndex >= 0 ? routeIndex : null, arguments: invocation.arguments, working_directory: invocation.working_directory, outcome, diff --git a/src/application/EvidenceBundleFiles.ts b/src/application/EvidenceBundleFiles.ts index f6e50449d..d3b4a3886 100644 --- a/src/application/EvidenceBundleFiles.ts +++ b/src/application/EvidenceBundleFiles.ts @@ -11,6 +11,10 @@ import { } from "../domain/evidenceBundle.js"; import { EvidenceFileError, EvidenceIntegrityError } from "../domain/errors.js"; import { err, ok, type Result } from "../domain/result.js"; +import { + LEGACY_PROCESS_CAPTURE_MESSAGE, + parseProcessCapture, +} from "../domain/processCapture.js"; type EvidenceReadFailure = EvidenceFileError | EvidenceIntegrityError; type EvidenceWriteFailure = EvidenceFileError | EvidenceIntegrityError; @@ -42,7 +46,14 @@ export const readEvidenceBundle = async ( const limits = validateJsonLimits(decoded, policy); if (!limits.ok) return limits; try { - return ok(parseEvidenceBundle(decoded)); + const bundle = parseEvidenceBundle(decoded); + for (const record of bundle.records) { + if (record.predicate_type === "rea.process-capture/v3") + throw new TypeError(LEGACY_PROCESS_CAPTURE_MESSAGE); + if (record.predicate_type === "rea.process-capture/v4") + parseProcessCapture(record.normalized_result); + } + return ok(bundle); } catch (cause: unknown) { return err( new EvidenceIntegrityError("Evidence bundle validation failed", { diff --git a/src/application/ProcessCli.ts b/src/application/ProcessCli.ts index d329821a2..75ee57637 100644 --- a/src/application/ProcessCli.ts +++ b/src/application/ProcessCli.ts @@ -5,8 +5,9 @@ import { createEvidence, parseEvidence } from "../domain/evidence.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; import { compareProcessCaptures, + LEGACY_PROCESS_CAPTURE_MESSAGE, parseProcessScenario, - processCaptureSchema, + parseProcessCapture, } from "../domain/processCapture.js"; import { captureProcessScenario } from "./ProcessHarness.js"; import { @@ -38,7 +39,7 @@ export const compareProcessEvidenceFiles = async ( const right = parseCaptureEvidence(await readJson(rightPath)); const comparison = compareProcessCaptures(left.capture, right.capture); return createEvidence(undefined, PROCESS_PROVIDER, { - predicateType: "rea.process-comparison/v2", + predicateType: "rea.process-comparison/v3", operation: "compare_process_captures", parameters: { left_evidence_id: left.id, @@ -58,14 +59,17 @@ const parseCaptureEvidence = (input: unknown) => { const evidence = parseEvidence(input); if ( evidence.operation !== "capture_process_scenario" || - evidence.predicate_type !== "rea.process-capture/v3" || + evidence.predicate_type !== "rea.process-capture/v4" || evidence.provider.id !== PROCESS_PROVIDER.id || evidence.provider.version !== PROCESS_PROVIDER.version - ) - throw new TypeError("Expected REA Process Capture v3 Evidence"); + ) { + if (evidence.predicate_type === "rea.process-capture/v3") + throw new TypeError(LEGACY_PROCESS_CAPTURE_MESSAGE); + throw new TypeError("Expected REA Process Capture v4 Evidence"); + } return { id: evidence.evidence_id, - capture: processCaptureSchema.parse(evidence.normalized_result), + capture: parseProcessCapture(evidence.normalized_result), }; }; diff --git a/src/application/ProcessEvidence.ts b/src/application/ProcessEvidence.ts index 4819caf7f..4ab635ebe 100644 --- a/src/application/ProcessEvidence.ts +++ b/src/application/ProcessEvidence.ts @@ -5,12 +5,13 @@ import type { ProcessCapture, ProcessScenario, } from "../domain/processCapture.js"; +import { parseProcessCapture } from "../domain/processCapture.js"; -/** Provider identity for controlled Process Capture v3 evidence. */ +/** Provider identity for controlled Process Capture v4 evidence. */ export const PROCESS_PROVIDER = { id: "rea-process", name: "REA deterministic process harness", - version: "2", + version: "3", } as const; /** Project one process scenario into secret-free Evidence parameters. */ @@ -26,16 +27,17 @@ const processEvidenceParameters = ( normalization: scenario.normalization, }); -/** Create one canonical observed Process Capture v3 Evidence record. */ +/** Create one canonical observed Process Capture v4 Evidence record. */ export const createProcessCaptureEvidence = ( scenario: ProcessScenario, capture: ProcessCapture, -) => - createEvidence(undefined, PROCESS_PROVIDER, { - predicateType: "rea.process-capture/v3", +) => { + const validatedCapture = parseProcessCapture(capture); + return createEvidence(undefined, PROCESS_PROVIDER, { + predicateType: "rea.process-capture/v4", operation: "capture_process_scenario", parameters: processEvidenceParameters(scenario), - result: jsonValueSchema.parse(capture), + result: jsonValueSchema.parse(validatedCapture), confidence: "observed", authority: "controlled-replay", environment: { @@ -44,5 +46,6 @@ export const createProcessCaptureEvidence = ( architecture: process.arch, isolation: "process", }, - limitations: capture.limitations, + limitations: validatedCapture.limitations, }); +}; diff --git a/src/application/ProcessHarness.ts b/src/application/ProcessHarness.ts index d1a6c84b3..21e37e025 100644 --- a/src/application/ProcessHarness.ts +++ b/src/application/ProcessHarness.ts @@ -1,4 +1,5 @@ -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; +import { createReadStream } from "node:fs"; import { mkdtemp, realpath, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -12,11 +13,21 @@ import type { ProcessScenario, TerminalFrame, } from "../domain/processCapture.js"; -import { authorizeProcessScenario } from "../domain/processCapture.js"; +import { + authorizeProcessScenario, + digestProcessCommitment, + processComparisonContract, + processScenarioCommitment, + validateProcessCapture, +} from "../domain/processCapture.js"; import { err, ok, type Result } from "../domain/result.js"; import { AnalysisError } from "../domain/errors.js"; +import { PRODUCT_IDENTITY } from "../identity.js"; import { startLoopbackReplay, type LoopbackReplay } from "./LoopbackReplay.js"; -import { cleanupOwnedProcessGroup } from "./ProcessOwnership.js"; +import { + cleanupOwnedProcessGroup, + observeOwnedProcessGroup, +} from "./ProcessOwnership.js"; import { startProcessSampler } from "./ProcessSampling.js"; import { snapshotRoots, type SnapshotResult } from "./FilesystemSnapshot.js"; import { @@ -33,6 +44,7 @@ import { normalizeProcessText, redactProtocolEvents, } from "./ProcessNormalization.js"; +import { PROCESS_PROVIDER } from "./ProcessEvidence.js"; const isWithin = (candidate: string, root: string): boolean => candidate === root || @@ -249,19 +261,31 @@ interface CaptureResultOptions { readonly interactions: readonly InteractionEvent[]; readonly checkpoints: readonly FilesystemCheckpoint[]; readonly shimEvents: ProcessCapture["shim_events"]; + readonly settlement: ProcessCapture["settlement"]; + readonly manifest: ProcessCapture["manifest"]; } const buildCaptureResult = (options: CaptureResultOptions): ProcessCapture => ({ - schema_version: 3, + schema_version: 4, + manifest: options.manifest, normalization: options.scenario.normalization, frames: options.frames, - rendered_frames: options.renderedFrames, + rendered_frames: [...options.renderedFrames] + .sort( + (left, right) => + left.at_ms - right.at_ms || left.sequence - right.sequence, + ) + .map((frame, sequence) => ({ ...frame, sequence })), interaction_events: options.interactions, exit: { - code: options.exit.exitCode < 0 ? null : options.exit.exitCode, + code: + options.exit.reason === "exited" && options.exit.exitCode >= 0 + ? options.exit.exitCode + : null, signal: options.exit.signal ?? null, reason: options.exit.reason, }, + settlement: options.settlement, process_samples: normalizeProcessSamples( options.samples, options.scenario, @@ -304,6 +328,78 @@ const buildCaptureResult = (options: CaptureResultOptions): ProcessCapture => ({ }, }); +const hashFile = async (path: string): Promise => { + const hash = createHash("sha256"); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return hash.digest("hex"); +}; + +const createRunManifest = async ( + scenario: ProcessScenario, + startedAt: Date, + completedAt: Date, +): Promise => { + const executableSha256 = await hashFile(scenario.executable); + const scenarioCommitment = processScenarioCommitment( + scenario, + executableSha256, + ); + const comparisonContract = processComparisonContract(scenario); + return { + rea_version: PRODUCT_IDENTITY.packageVersion, + provider_version: PROCESS_PROVIDER.version, + platform: process.platform, + architecture: process.arch, + pty_backend: "node-pty", + started_at: startedAt.toISOString(), + completed_at: completedAt.toISOString(), + scenario: scenarioCommitment, + comparison_contract: comparisonContract, + shim_plan: scenario.command_shims, + replay_plan: scenario.replay, + full_scenario_sha256: digestProcessCommitment(scenarioCommitment), + comparison_contract_sha256: digestProcessCommitment(comparisonContract), + executable_sha256: executableSha256, + normalization_sha256: digestProcessCommitment(scenario.normalization), + shim_plan_sha256: digestProcessCommitment(scenario.command_shims), + replay_plan_sha256: digestProcessCommitment(scenario.replay), + }; +}; + +const observeSettlement = async ( + runId: string, + processGroupIds: readonly number[], + settleMs: number, +): Promise> => { + if (process.platform === "win32") + return { state: "unverifiable", elapsed_ms: 0 }; + const started = Date.now(); + let consecutiveEmpty = 0; + let deadlineReached = false; + while (!deadlineReached) { + const observations = await Promise.all( + [...new Set(processGroupIds)].map((processGroupId) => + observeOwnedProcessGroup({ + runId, + leaderPid: processGroupId, + processGroupId, + }), + ), + ); + if (observations.some(({ state }) => state === "unverifiable")) + return { state: "unverifiable", elapsed_ms: Date.now() - started }; + if (observations.every(({ state }) => state === "empty")) { + consecutiveEmpty += 1; + if (consecutiveEmpty >= 2) + return { state: "quiesced", elapsed_ms: Date.now() - started }; + } else consecutiveEmpty = 0; + deadlineReached = Date.now() - started >= settleMs; + if (deadlineReached) break; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return { state: "alive_at_deadline", elapsed_ms: Date.now() - started }; +}; + const awaitTerminalExit = async ({ terminal, scenario, @@ -523,6 +619,7 @@ const runProcessScenario = async ( let checkpoints: ProcessCheckpoints | undefined; let shimReplay: CommandShimReplay | undefined; let started = 0; + let startedAt = new Date(0); let lastOutput = 0; const timers = new Set(); let capture: ProcessCapture | undefined; @@ -530,11 +627,16 @@ const runProcessScenario = async ( let framesTruncated = (): boolean => false; let stopSampler = async () => ({ partial: false }); let samplingPartial = false; + let settlement: Omit = { + state: "unverifiable", + elapsed_ms: 0, + }; const interactions: InteractionEvent[] = []; const dispatchedEventIndexes = new Set(); try { replay = await startLoopbackReplay(scenario); + startedAt = new Date(); started = Date.now(); lastOutput = started; renderer = new TerminalRenderer({ @@ -601,8 +703,15 @@ const runProcessScenario = async ( checkpoints.trigger("root_exit"); if (exitReason === "cancelled") throw new ProcessCaptureError("process capture was cancelled"); - await new Promise((resolveSettle) => - setTimeout(resolveSettle, scenario.settle_ms), + settlement = await observeSettlement( + runId, + [ + terminal.pid, + ...samples.flatMap(({ process_group_id }) => + process_group_id === null ? [] : [process_group_id], + ), + ], + scenario.settle_ms, ); checkpoints.trigger("settled"); samplingPartial = (await stopSampler()).partial; @@ -610,6 +719,7 @@ const runProcessScenario = async ( const after = await snapshotRoots(scenario, signal); const renderedFrames = await renderer.frames(); const filesystemCheckpoints = await checkpoints.finish(after); + const manifest = await createRunManifest(scenario, startedAt, new Date()); truncated ||= after.truncated || replay.truncated || @@ -649,6 +759,8 @@ const runProcessScenario = async ( terminal?.pid ?? -1, ), })), + settlement: { ...settlement, cleanup_outcome: "not_required" }, + manifest, }); } catch (cause: unknown) { terminal?.kill("SIGKILL"); @@ -668,6 +780,25 @@ const runProcessScenario = async ( process_group_id === null ? [] : [process_group_id], ), }); + if (capture !== undefined) { + capture = { + ...capture, + settlement: { + ...capture.settlement, + cleanup_outcome: + capture.settlement.state === "quiesced" + ? "not_required" + : cleanupFailure === undefined + ? "cleaned" + : "failed", + }, + }; + const issues = validateProcessCapture(capture); + if (issues.length > 0) + executionFailure = new ProcessCaptureError( + `process capture validation failed: ${issues[0]!.path}: ${issues[0]!.message}`, + ); + } return resolveProcessResult(capture, executionFailure, cleanupFailure); }; diff --git a/src/application/ProcessOwnership.ts b/src/application/ProcessOwnership.ts index 85e633d1e..7ce6ac02a 100644 --- a/src/application/ProcessOwnership.ts +++ b/src/application/ProcessOwnership.ts @@ -34,6 +34,12 @@ export type ProcessCleanupResult = | { readonly cleaned: true; readonly signaled: boolean } | { readonly cleaned: false; readonly reason: string }; +/** Token-verified liveness result used by post-root-exit settlement. */ +export type ProcessGroupObservation = + | { readonly state: "empty" } + | { readonly state: "alive" } + | { readonly state: "unverifiable"; readonly reason: string }; + const parseEnvironment = (value: string): Readonly> => Object.fromEntries( value @@ -146,6 +152,41 @@ export const cleanupOwnedProcessGroup = async ( return { cleaned: true, signaled: true }; }; +/** Observe one group without signaling it, failing closed on identity doubt. */ +export const observeOwnedProcessGroup = async ( + ownership: OwnedProcessGroup, + host: ProcessOwnershipHost = systemHost, +): Promise => { + let members: readonly ProcessGroupMember[]; + try { + members = await host.listMembers(ownership.processGroupId); + } catch { + return { + state: "unverifiable", + reason: "process group could not be inspected", + }; + } + if (members.length === 0) return { state: "empty" }; + for (const member of members) { + try { + if ( + (await host.environment(member.pid)).REA_PROCESS_RUN_ID !== + ownership.runId + ) + return { + state: "unverifiable", + reason: "process ownership did not match", + }; + } catch { + return { + state: "unverifiable", + reason: "process ownership could not be revalidated", + }; + } + } + return { state: "alive" }; +}; + const commandMatches = (actual: string, expected: string): boolean => { const normalizedActual = actual.trim(); const normalizedExpected = expected.trim(); diff --git a/src/cliProcessCommands.ts b/src/cliProcessCommands.ts index a7beefcff..2c7696e28 100644 --- a/src/cliProcessCommands.ts +++ b/src/cliProcessCommands.ts @@ -7,13 +7,13 @@ import { import { logCliCommand } from "./cliLogging.js"; import type { Logger } from "./logger.js"; -/** Register Process Capture v3 one-shot commands through shared application services. */ +/** Register Process Capture v4 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", + description: "Capture one approved Process Capture v4 JSON scenario", args: z.object({ scenario: z.string().describe("Scenario JSON path") }), run: ({ args }) => logCliCommand(logger, "capture-process", () => @@ -21,7 +21,7 @@ export const registerProcessCommands = ( ), }); cli.command("compare-process-captures", { - description: "Compare two Process Capture v3 Evidence JSON files", + description: "Compare two Process Capture v4 Evidence JSON files", args: z.object({ left: z.string().describe("Left capture Evidence JSON path"), right: z.string().describe("Right capture Evidence JSON path"), diff --git a/src/contracts/investigationExamples.ts b/src/contracts/investigationExamples.ts index 0c4da92cf..6b74affc9 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: "2", + version: "3", } as const; const capture = processCaptureSchema.parse(EMPTY_PROCESS_CAPTURE_EXAMPLE); const captureEvidence = (scenario: string) => createEvidence(undefined, PROCESS_PROVIDER, { - predicateType: "rea.process-capture/v3", + predicateType: "rea.process-capture/v4", 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/v2", + predicateType: "rea.process-comparison/v3", 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 6bc8420ac..4572fda7d 100644 --- a/src/contracts/processCaptureExample.ts +++ b/src/contracts/processCaptureExample.ts @@ -1,21 +1,61 @@ import type { JsonValue } from "../domain/jsonValue.js"; +import { digestProcessCommitment } from "../domain/processCapture.js"; + +const normalization = { + paths: true, + pids: true, + ports: true, + time_bucket_ms: 10, + patterns: [], +}; +const scenario = { executable_sha256: "0".repeat(64) }; +const comparisonContract = {}; +const shimPlan: JsonValue[] = []; +const replayPlan = {}; /** Minimal valid process capture used only in public contract examples. */ export const EMPTY_PROCESS_CAPTURE_EXAMPLE = { - schema_version: 3, - normalization: { - paths: true, - pids: true, - ports: true, - time_bucket_ms: 10, - patterns: [], + schema_version: 4, + manifest: { + rea_version: "1.1.0", + provider_version: "3", + platform: "fixture", + architecture: "fixture", + pty_backend: "node-pty", + started_at: "2026-01-01T00:00:00.000Z", + completed_at: "2026-01-01T00:00:00.001Z", + scenario, + comparison_contract: comparisonContract, + shim_plan: shimPlan, + replay_plan: replayPlan, + full_scenario_sha256: digestProcessCommitment(scenario), + comparison_contract_sha256: digestProcessCommitment(comparisonContract), + executable_sha256: "0".repeat(64), + normalization_sha256: digestProcessCommitment(normalization), + shim_plan_sha256: digestProcessCommitment(shimPlan), + replay_plan_sha256: digestProcessCommitment(replayPlan), }, + normalization, frames: [], rendered_frames: [], interaction_events: [], exit: { code: 0, signal: null, reason: "exited" }, + settlement: { + state: "quiesced", + elapsed_ms: 50, + cleanup_outcome: "not_required", + }, process_samples: [], - filesystem_checkpoints: [], + filesystem_checkpoints: [ + { name: "before", at_ms: 0, files: [], effects: [], truncated: false }, + { + name: "after_settlement", + at_ms: 50, + files: [], + effects: [], + truncated: false, + }, + ], shim_events: [], protocol_events: [], files_before: [], diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts index a990ec056..b2c4f0e9f 100644 --- a/src/contracts/toolContracts.ts +++ b/src/contracts/toolContracts.ts @@ -432,17 +432,18 @@ 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 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.", + "Run one bounded process under a PTY using operator-approved executable and working roots. Produces Process Capture v4; legacy v3 captures cannot be upgraded and must be recaptured with this tool. 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 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.", + "Compare two compatible Process Capture v4 observations across terminal, interaction, exit, settlement, process, filesystem, command-shim, HTTP, and WebSocket evidence, returning the first bounded divergence. Process Capture v3 is unsupported and must be recaptured with capture_process_scenario. 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, right_evidence_id: z.string().regex(/^ev_[a-f0-9]{64}$/u), right: processCaptureSchema, + max_capture_age_ms: z.number().int().nonnegative().optional(), unknown_registry_approved: z .literal(true) .optional() diff --git a/src/domain/changedBehavior.ts b/src/domain/changedBehavior.ts index 09170ee35..b64e56cba 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/v2", + predicate: "rea.process-comparison/v3", id: "rea-process", name: "REA deterministic process harness", - version: "2", + version: "3", }, compare_artifacts: { predicate: "rea.artifact-comparison/v1", diff --git a/src/domain/processCapture.ts b/src/domain/processCapture.ts index d172c331a..eb0becbc9 100644 --- a/src/domain/processCapture.ts +++ b/src/domain/processCapture.ts @@ -1,4 +1,6 @@ +import { createHash } from "node:crypto"; import { resolve } from "node:path"; +import canonicalize from "canonicalize"; import { z } from "zod"; const positiveBudget = z.number().int().positive(); @@ -36,7 +38,57 @@ const outputChunkSchema = z.object({ data: z.string().max(1_000_000), }); -/** Exact boundary schema for bounded dynamic process scenarios. */ +/** Actionable refusal shared by CLI, MCP, and Evidence import boundaries. */ +export const LEGACY_PROCESS_CAPTURE_MESSAGE = + "Process Capture v3 is unsupported. Re-run the scenario with this REA version using capture_process_scenario to produce Process Capture v4; captures cannot be upgraded because v4 requires new manifest and settlement evidence."; + +const canonicalSha256 = (value: unknown): string => { + const serialized = canonicalize(value); + if (serialized === undefined) + throw new TypeError("Process capture commitment is not canonical JSON"); + return createHash("sha256").update(serialized).digest("hex"); +}; + +/** Build the secret-safe scenario projection committed by Process Capture v4. */ +export const processScenarioCommitment = ( + scenario: ProcessScenario, + executableSha256?: string, +): Readonly> => ({ + ...scenario, + environment: Object.fromEntries( + Object.entries(scenario.environment).map(([name, value]) => [ + name, + scenario.secret_aliases.includes(name) ? "" : value, + ]), + ), + unknown_registry_approved: scenario.unknown_registry_approved === true, + executable_sha256: executableSha256 ?? null, +}); + +/** Project observation settings shared by authority and reconstruction. */ +export const processComparisonContract = ( + scenario: ProcessScenario, +): Readonly> => ({ + working_directory: scenario.working_directory, + inherit_environment: scenario.inherit_environment, + secret_aliases: scenario.secret_aliases, + network_access: scenario.network_access, + filesystem_roots: scenario.filesystem_roots, + terminal: scenario.terminal, + checkpoints: scenario.checkpoints, + events: scenario.events, + timeout_ms: scenario.timeout_ms, + idle_timeout_ms: scenario.idle_timeout_ms, + settle_ms: scenario.settle_ms, + limits: scenario.limits, + normalization: scenario.normalization, + command_shims: scenario.command_shims, + replay: scenario.replay, +}); + +/** Compute a canonical SHA-256 commitment independent of object key order. */ +export const digestProcessCommitment = canonicalSha256; + /** * Boundary schema for one explicitly approved, bounded process experiment. * Defaults are part of the evidence contract and must remain deterministic. @@ -313,7 +365,6 @@ export const processScenarioSchema = z }); }); -/** A parsed, bounded dynamic process observation scenario. */ /** Parsed instructions and resource bounds for one process experiment. */ export type ProcessScenario = z.infer; @@ -321,7 +372,6 @@ export type ProcessScenario = z.infer; export const parseProcessScenario = (input: unknown): ProcessScenario => processScenarioSchema.parse(input); -/** Operator-owned policy for process capture. */ /** Operator-owned ceiling applied in addition to per-scenario approval. */ export interface ProcessExecutionPolicy { readonly enabled: boolean; @@ -331,7 +381,6 @@ export interface ProcessExecutionPolicy { readonly allowExternalNetwork: boolean; } -/** A safe, caller-visible process-policy decision. */ /** Explicit authorization result; denial reasons are safe to show callers. */ export type ProcessPolicyDecision = | { readonly allowed: true } @@ -395,7 +444,6 @@ export const authorizeProcessScenario = ( return { allowed: true }; }; -/** One bounded terminal observation. */ /** Normalized raw PTY chunk, preserving transport-level output differences. */ export interface TerminalFrame { readonly sequence: number; @@ -403,7 +451,6 @@ export interface TerminalFrame { readonly data: string; } -/** One rendered terminal state after xterm has parsed a PTY chunk or resize. */ /** Serialized terminal state after interpreting control and resize sequences. */ export interface RenderedTerminalFrame { readonly sequence: number; @@ -417,7 +464,6 @@ export interface RenderedTerminalFrame { readonly serialized_state: string; } -/** One attempted scripted interaction with the PTY. */ /** Scheduled input, resize, or signal with its observed dispatch outcome. */ export interface InteractionEvent { readonly sequence: number; @@ -455,7 +501,6 @@ export interface ProcessSample { readonly session_id: number | null; } -/** A named filesystem observation captured during a process lifecycle. */ /** * Named filesystem state whose effects are relative to the prior checkpoint. */ @@ -467,12 +512,12 @@ export interface FilesystemCheckpoint { readonly truncated: boolean; } -/** One invocation observed by the declarative command-shim replay adapter. */ /** Recorded deterministic dependency invocation and route-match outcome. */ export interface ShimEvent { readonly sequence: number; readonly at_ms: number; readonly command: string; + readonly route_index: number | null; readonly arguments: readonly string[]; readonly working_directory: string; readonly outcome: "matched" | "unmatched" | "exhausted"; @@ -494,15 +539,33 @@ export interface ProtocolEvent { | "disconnected"; } -/** A bounded process observation with explicit incompleteness metadata. */ /** - * Process Capture v3 observation set. + * Process Capture v4 observation set. * * `truncated` and `residual_unknowns` are semantic evidence: consumers must not * infer equivalence from matching bounded observations when either is present. */ export interface ProcessCapture { - readonly schema_version: 3; + readonly schema_version: 4; + readonly manifest: { + readonly rea_version: string; + readonly provider_version: string; + readonly platform: string; + readonly architecture: string; + readonly pty_backend: "node-pty"; + readonly started_at: string; + readonly completed_at: string; + readonly scenario: Readonly>; + readonly comparison_contract: Readonly>; + readonly shim_plan: readonly unknown[]; + readonly replay_plan: Readonly>; + readonly full_scenario_sha256: string; + readonly comparison_contract_sha256: string; + readonly executable_sha256: string; + readonly normalization_sha256: string; + readonly shim_plan_sha256: string; + readonly replay_plan_sha256: string; + }; readonly normalization: z.infer; readonly frames: readonly TerminalFrame[]; readonly rendered_frames: readonly RenderedTerminalFrame[]; @@ -512,6 +575,11 @@ export interface ProcessCapture { readonly signal: number | null; readonly reason: "exited" | "timeout" | "idle_timeout"; }; + readonly settlement: { + readonly state: "quiesced" | "alive_at_deadline" | "unverifiable"; + readonly elapsed_ms: number; + readonly cleanup_outcome: "not_required" | "cleaned" | "failed"; + }; readonly process_samples: readonly ProcessSample[]; readonly filesystem_checkpoints: readonly FilesystemCheckpoint[]; readonly shim_events: readonly ShimEvent[]; @@ -558,7 +626,28 @@ const fileEffectSchema = z.object({ /** Exact serialized shape of a bounded process capture. */ export const processCaptureSchema: z.ZodType = z.object({ - schema_version: z.literal(3), + schema_version: z.literal(4, { + error: LEGACY_PROCESS_CAPTURE_MESSAGE, + }), + manifest: z.object({ + rea_version: z.string().min(1), + provider_version: z.string().min(1), + platform: z.string().min(1), + architecture: z.string().min(1), + pty_backend: z.literal("node-pty"), + started_at: z.iso.datetime(), + completed_at: z.iso.datetime(), + scenario: z.record(z.string(), z.unknown()), + comparison_contract: z.record(z.string(), z.unknown()), + shim_plan: z.array(z.unknown()), + replay_plan: z.record(z.string(), z.unknown()), + full_scenario_sha256: z.string().regex(/^[a-f0-9]{64}$/u), + comparison_contract_sha256: z.string().regex(/^[a-f0-9]{64}$/u), + executable_sha256: z.string().regex(/^[a-f0-9]{64}$/u), + normalization_sha256: z.string().regex(/^[a-f0-9]{64}$/u), + shim_plan_sha256: z.string().regex(/^[a-f0-9]{64}$/u), + replay_plan_sha256: z.string().regex(/^[a-f0-9]{64}$/u), + }), normalization: normalizationSchema, frames: z.array( z.object({ @@ -595,6 +684,11 @@ export const processCaptureSchema: z.ZodType = z.object({ signal: z.number().int().nullable(), reason: z.enum(["exited", "timeout", "idle_timeout"]), }), + settlement: z.object({ + state: z.enum(["quiesced", "alive_at_deadline", "unverifiable"]), + elapsed_ms: z.number().int().nonnegative(), + cleanup_outcome: z.enum(["not_required", "cleaned", "failed"]), + }), process_samples: z.array( z.object({ at_ms: z.number().int().nonnegative(), @@ -619,6 +713,7 @@ export const processCaptureSchema: z.ZodType = z.object({ sequence: z.number().int().nonnegative(), at_ms: z.number().int().nonnegative(), command: z.string(), + route_index: z.number().int().nonnegative().nullable(), arguments: z.array(z.string()), working_directory: z.string(), outcome: z.enum(["matched", "unmatched", "exhausted"]), @@ -666,6 +761,173 @@ export const processCaptureSchema: z.ZodType = z.object({ }), }); +/** One pure semantic validation failure in an otherwise shaped v4 capture. */ +export interface ProcessCaptureValidationIssue { + readonly path: string; + readonly message: string; +} + +const orderedBy = ( + values: readonly Value[], + project: (value: Value) => number, +): boolean => + values.every( + (value, index) => + index === 0 || project(value) >= project(values[index - 1]!), + ); + +const orderedTimestamps = ( + values: readonly { readonly at_ms: number }[], +): boolean => orderedBy(values, ({ at_ms }) => at_ms); + +/** Recompute v4 commitments and cross-field invariants without side effects. */ +export const validateProcessCapture = ( + capture: ProcessCapture, +): readonly ProcessCaptureValidationIssue[] => { + const issues: ProcessCaptureValidationIssue[] = []; + const require = (condition: boolean, path: string, message: string): void => { + if (!condition) issues.push({ path, message }); + }; + const { manifest } = capture; + require(manifest.scenario.executable_sha256 === + manifest.executable_sha256, "manifest.executable_sha256", "executable commitment does not match the scenario projection"); + const commitments = [ + ["full_scenario_sha256", manifest.scenario], + ["comparison_contract_sha256", manifest.comparison_contract], + ["normalization_sha256", capture.normalization], + ["shim_plan_sha256", manifest.shim_plan], + ["replay_plan_sha256", manifest.replay_plan], + ] as const; + for (const [field, value] of commitments) + require(manifest[field] === + canonicalSha256( + value, + ), `manifest.${field}`, "commitment does not match its canonical value"); + require(Date.parse(manifest.started_at) <= + Date.parse( + manifest.completed_at, + ), "manifest.completed_at", "completion precedes start"); + const sequences = [ + ["frames", capture.frames], + ["rendered_frames", capture.rendered_frames], + ["interaction_events", capture.interaction_events], + ["shim_events", capture.shim_events], + ["protocol_events", capture.protocol_events], + ] as const; + for (const [name, values] of sequences) { + require(values.every( + ({ sequence }, index) => sequence === index, + ), name, "sequence values must be contiguous from zero"); + } + for (const [name, values] of [ + ["frames", capture.frames], + ["rendered_frames", capture.rendered_frames], + ["process_samples", capture.process_samples], + ["filesystem_checkpoints", capture.filesystem_checkpoints], + ["shim_events", capture.shim_events], + ["protocol_events", capture.protocol_events], + ] as const) + require(orderedTimestamps(values), name, "timestamps must be ordered"); + const checkpointNames = capture.filesystem_checkpoints.map( + ({ name }) => name, + ); + require(new Set(checkpointNames).size === + checkpointNames.length, "filesystem_checkpoints", "checkpoint names must be unique"); + require(checkpointNames[0] === + "before", "filesystem_checkpoints", "first checkpoint must be before"); + require(checkpointNames.at(-1) === + "after_settlement", "filesystem_checkpoints", "last checkpoint must be after_settlement"); + require(!capture.filesystem_checkpoints.some(({ truncated }) => truncated) || + capture.truncated, "truncated", "checkpoint truncation must propagate to the capture"); + require(capture.exit.reason === "exited" || + capture.exit.code === + null, "exit", "deadline termination cannot declare a normal exit code"); + require(capture.settlement.state !== "quiesced" || + capture.settlement.cleanup_outcome === + "not_required", "settlement.cleanup_outcome", "quiesced settlement cannot require cleanup"); + require(capture.settlement.state === "quiesced" || + capture.settlement.cleanup_outcome !== + "not_required", "settlement.cleanup_outcome", "non-quiesced settlement must report cleanup"); + const shimPlans = manifest.shim_plan.flatMap((shim) => + typeof shim === "object" && + shim !== null && + "name" in shim && + "routes" in shim && + typeof shim.name === "string" && + Array.isArray(shim.routes) + ? shim.routes.map((route, routeIndex) => ({ + command: shim.name as string, + route, + routeIndex, + })) + : [], + ); + for (const event of capture.shim_events) { + const declaredRoute = shimPlans.find( + ({ command, routeIndex }) => + command === event.command && routeIndex === event.route_index, + ); + require(event.outcome === "unmatched" + ? event.route_index === null + : declaredRoute !== + undefined, "shim_events", "shim event has no declared route"); + } + for (const plan of shimPlans) { + const maximum = + typeof plan.route === "object" && + plan.route !== null && + "max_calls" in plan.route && + typeof plan.route.max_calls === "number" + ? plan.route.max_calls + : 0; + require(capture.shim_events.filter( + (event) => + event.command === plan.command && + event.route_index === plan.routeIndex && + event.outcome === "matched", + ).length <= + maximum, "shim_events", "matched shim events exceed the declared route max_calls"); + } + const replayHttp = + "http" in manifest.replay_plan && Array.isArray(manifest.replay_plan.http) + ? manifest.replay_plan.http + : []; + for (const event of capture.protocol_events) + if ( + event.protocol === "http" && + event.direction === "request" && + event.outcome === "matched" + ) + require(replayHttp.some( + (route) => + typeof route === "object" && + route !== null && + "method" in route && + route.method === event.method && + "path" in route && + route.path === event.path, + ), "protocol_events", "matched HTTP event has no declared replay route"); + return issues; +}; + +/** Parse unknown input as v4 and reject invalid commitments or semantics. */ +export const parseProcessCapture = (input: unknown): ProcessCapture => { + if ( + typeof input === "object" && + input !== null && + "schema_version" in input && + input.schema_version === 3 + ) + throw new TypeError(LEGACY_PROCESS_CAPTURE_MESSAGE); + const capture = processCaptureSchema.parse(input); + const issues = validateProcessCapture(capture); + if (issues.length > 0) + throw new TypeError( + `Invalid Process Capture v4: ${issues.map(({ path, message }) => `${path}: ${message}`).join("; ")}`, + ); + return capture; +}; + export { compareProcessCaptures, comparisonStatusSchema, diff --git a/src/domain/processComparison.ts b/src/domain/processComparison.ts index e81a5939c..9f25e11a9 100644 --- a/src/domain/processComparison.ts +++ b/src/domain/processComparison.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import type { ProcessCapture } from "./processCapture.js"; +import { validateProcessCapture } from "./processCapture.js"; /** Comparison classification that never equates incomplete evidence. */ export const comparisonStatusSchema = z.enum([ @@ -198,7 +199,7 @@ const chooseFirstDivergence = ( /** Compare bounded captures without equating missing or incompatible evidence. */ /** - * Compare two Process Capture v3 observations without treating absence as proof. + * Compare two Process Capture v4 observations without treating absence as proof. * * Observed differences outrank residual unknowns. A capture may therefore be * definitively changed in one dimension while completeness remains unknown. @@ -206,7 +207,36 @@ const chooseFirstDivergence = ( export const compareProcessCaptures = ( left: ProcessCapture, right: ProcessCapture, + options: { + readonly maxCaptureAgeMs?: number; + readonly now?: () => number; + } = {}, ): ProcessCaptureComparison => { + const invalid = [ + ...validateProcessCapture(left), + ...validateProcessCapture(right), + ]; + if (invalid.length > 0) + throw new TypeError(`Invalid Process Capture v4: ${invalid[0]!.path}`); + if ( + left.schema_version !== right.schema_version || + left.manifest.comparison_contract_sha256 !== + right.manifest.comparison_contract_sha256 + ) + throw new TypeError( + "Process captures have incompatible comparison contracts", + ); + if (options.maxCaptureAgeMs !== undefined) { + const maxCaptureAgeMs = options.maxCaptureAgeMs; + const now = (options.now ?? Date.now)(); + if ( + [left, right].some( + ({ manifest }) => + now - Date.parse(manifest.completed_at) > maxCaptureAgeMs, + ) + ) + throw new TypeError("Process capture exceeds max_capture_age_ms"); + } if (left.truncated || right.truncated) { return { status: "truncated", @@ -247,7 +277,8 @@ export const compareProcessCaptures = ( const exit = hasUnknown(left, "exit") || hasUnknown(right, "exit") ? "unknown" - : JSON.stringify(left.exit) === JSON.stringify(right.exit) + : JSON.stringify({ exit: left.exit, settlement: left.settlement }) === + JSON.stringify({ exit: right.exit, settlement: right.settlement }) ? "unchanged" : "changed"; const filesystem = classify( @@ -279,7 +310,11 @@ export const compareProcessCaptures = ( left.interaction_events, right.interaction_events, ), - firstCollectionDivergence("exit", [left.exit], [right.exit]), + firstCollectionDivergence( + "exit", + [{ ...left.exit, settlement: left.settlement }], + [{ ...right.exit, settlement: right.settlement }], + ), firstCollectionDivergence( "filesystem", filesystemObservations(left), diff --git a/src/domain/reconstructionVerification.ts b/src/domain/reconstructionVerification.ts index c6d8a98b8..c9090b74b 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/v2", + predicate: "rea.process-comparison/v3", 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/v3"] + ? ["capture_process_scenario", "rea.process-capture/v4"] : 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 !== (claim.kind === "behavioral" ? "2" : "1") || + evidence.provider.version !== (claim.kind === "behavioral" ? "3" : "1") || evidence.confidence !== "derived" || evidence.authority !== "analyst-inference" || evidence.subject !== null diff --git a/src/domain/staticRuntimeCorrelation.ts b/src/domain/staticRuntimeCorrelation.ts index 59d716536..eaea50856 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/v2", + "rea.process-comparison/v3", "rea-process", "REA deterministic process harness", ], @@ -403,7 +403,7 @@ const assertComparisonIdentity = (evidence: Evidence): void => { evidence.provider.id !== expected[1] || evidence.provider.name !== expected[2] || evidence.provider.version !== - (evidence.operation === "compare_process_captures" ? "2" : "1") || + (evidence.operation === "compare_process_captures" ? "3" : "1") || evidence.confidence !== "derived" || evidence.authority !== "analyst-inference" || evidence.subject !== null || diff --git a/src/identity.ts b/src/identity.ts index dc9889101..474cdc35d 100644 --- a/src/identity.ts +++ b/src/identity.ts @@ -6,6 +6,7 @@ export const PRODUCT_IDENTITY = { displayName: "REA", packageName: "rea-agents", + packageVersion: "1.1.0", cliBinary: "rea", mcpCommand: "npx -y rea-agents mcp", mcpServerKey: "rea", diff --git a/src/server/registerProcessComparisonTool.ts b/src/server/registerProcessComparisonTool.ts index 5089d9841..49c8c2cc9 100644 --- a/src/server/registerProcessComparisonTool.ts +++ b/src/server/registerProcessComparisonTool.ts @@ -11,6 +11,7 @@ import type { RecordUnknownInput } from "../domain/residualUnknown.js"; import { compareProcessCaptures, processCaptureSchema, + validateProcessCapture, } from "../domain/processCapture.js"; import { recordDerivedEvidence } from "./recordDerivedEvidence.js"; import { err } from "../domain/result.js"; @@ -23,6 +24,7 @@ export const registerProcessComparisonTool = ( server: McpServer, session: BinarySessionPort, contract: (typeof SESSION_TOOL_CONTRACTS)[6], + now: () => number = Date.now, ): void => { server.registerTool( contract.name, @@ -34,14 +36,34 @@ export const registerProcessComparisonTool = ( left: processCaptureSchema, right_evidence_id: z.string(), right: processCaptureSchema, + max_capture_age_ms: z.number().int().nonnegative().optional(), unknown_registry_approved: z.literal(true).optional(), }) .parse(input); const validated = validateCaptureSources(session, parsed); if (!validated.ok) return toCallToolResult(validated, contract); - const comparison = compareProcessCaptures(parsed.left, parsed.right); + let comparison: ReturnType; + try { + comparison = compareProcessCaptures(parsed.left, parsed.right, { + ...(parsed.max_capture_age_ms === undefined + ? {} + : { maxCaptureAgeMs: parsed.max_capture_age_ms }), + now, + }); + } catch (cause: unknown) { + return toCallToolResult( + err( + new EvidenceIntegrityError( + cause instanceof Error + ? cause.message + : "Invalid Process Capture v4", + ), + ), + contract, + ); + } const evidence = createEvidence(undefined, PROCESS_PROVIDER, { - predicateType: "rea.process-comparison/v2", + predicateType: "rea.process-comparison/v3", operation: contract.name, parameters: { left_evidence_id: parsed.left_evidence_id, @@ -85,13 +107,15 @@ const validateCaptureSources = ( if ( evidence === undefined || evidence.operation !== "capture_process_scenario" || - evidence.predicate_type !== "rea.process-capture/v3" || + evidence.predicate_type !== "rea.process-capture/v4" || evidence.provider.id !== PROCESS_PROVIDER.id || evidence.provider.name !== PROCESS_PROVIDER.name || evidence.provider.version !== PROCESS_PROVIDER.version || evidence.confidence !== "observed" || evidence.authority !== "controlled-replay" || !result.success || + (result.success && validateProcessCapture(result.data).length > 0) || + validateProcessCapture(capture).length > 0 || canonicalJson(result.data) !== canonicalJson(capture) ) return err( diff --git a/tests/changedBehavior.test.ts b/tests/changedBehavior.test.ts index 6929cbfd7..57f6ebb40 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: operation === "compare_process_captures" ? "2" : "1", + version: operation === "compare_process_captures" ? "3" : "1", }, { predicateType: process - ? "rea.process-comparison/v2" + ? "rea.process-comparison/v3" : "rea.artifact-comparison/v1", operation, parameters: {}, diff --git a/tests/integration/serverIntegration.test.ts b/tests/integration/serverIntegration.test.ts index a6ebb8054..45cf5153e 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/v3", + predicateType: "rea.process-capture/v4", operation: "capture_process_scenario", parameters: {}, result: jsonValueSchema.parse(capture), diff --git a/tests/processCapture.test.ts b/tests/processCapture.test.ts index e3edd43bf..7c8804810 100644 --- a/tests/processCapture.test.ts +++ b/tests/processCapture.test.ts @@ -16,7 +16,12 @@ import { snapshotRoots } from "../src/application/FilesystemSnapshot.js"; import { authorizeProcessScenario, compareProcessCaptures, + digestProcessCommitment, + LEGACY_PROCESS_CAPTURE_MESSAGE, + parseProcessCapture, parseProcessScenario, + processCaptureSchema, + type ProcessCapture, type ProcessExecutionPolicy, } from "../src/domain/processCapture.js"; @@ -25,6 +30,75 @@ const processFixture = fileURLToPath( ); const execFileAsync = promisify(execFile); +const emptyCapture = (): ProcessCapture => { + const normalization = { + paths: true, + pids: true, + ports: true, + time_bucket_ms: 10, + patterns: [], + }; + const scenario = { executable_sha256: "0".repeat(64) }; + const comparisonContract = {}; + const shimPlan: readonly unknown[] = []; + const replayPlan = {}; + return { + schema_version: 4, + manifest: { + rea_version: "test", + provider_version: "3", + platform: process.platform, + architecture: process.arch, + pty_backend: "node-pty", + started_at: "2026-01-01T00:00:00.000Z", + completed_at: "2026-01-01T00:00:00.001Z", + scenario, + comparison_contract: comparisonContract, + shim_plan: shimPlan, + replay_plan: replayPlan, + full_scenario_sha256: digestProcessCommitment(scenario), + comparison_contract_sha256: digestProcessCommitment(comparisonContract), + executable_sha256: "0".repeat(64), + normalization_sha256: digestProcessCommitment(normalization), + shim_plan_sha256: digestProcessCommitment(shimPlan), + replay_plan_sha256: digestProcessCommitment(replayPlan), + }, + normalization, + frames: [], + rendered_frames: [], + interaction_events: [], + exit: { code: 0, signal: null, reason: "exited" }, + settlement: { + state: "quiesced", + elapsed_ms: 50, + cleanup_outcome: "not_required", + }, + process_samples: [], + filesystem_checkpoints: [ + { name: "before", at_ms: 0, files: [], effects: [], truncated: false }, + { + name: "after_settlement", + at_ms: 50, + files: [], + effects: [], + truncated: false, + }, + ], + shim_events: [], + protocol_events: [], + files_before: [], + files_after: [], + filesystem_effects: [], + truncated: false, + limitations: [], + residual_unknowns: [], + cleanup: { + owned_process_group: "verified", + temporary_root: "removed", + }, + }; +}; + describe("process capture domain", () => { const base = { approved: true as const, @@ -107,7 +181,9 @@ describe("process capture domain", () => { it("never considers truncated captures equivalent", () => { const capture = { - schema_version: 3 as const, + schema_version: 4 as const, + manifest: emptyCapture().manifest, + settlement: emptyCapture().settlement, normalization: { paths: true, pids: true, @@ -120,7 +196,7 @@ describe("process capture domain", () => { interaction_events: [], exit: { code: 0, signal: null, reason: "exited" as const }, process_samples: [], - filesystem_checkpoints: [], + filesystem_checkpoints: emptyCapture().filesystem_checkpoints, shim_events: [], protocol_events: [], files_before: [], @@ -137,9 +213,72 @@ describe("process capture domain", () => { expect(compareProcessCaptures(capture, capture).status).toBe("truncated"); }); + it("rejects altered v4 commitments and accepts canonical key reordering", () => { + const capture = emptyCapture(); + expect(parseProcessCapture(capture)).toEqual(capture); + expect(digestProcessCommitment({ second: 2, first: 1 })).toBe( + digestProcessCommitment({ first: 1, second: 2 }), + ); + expect(() => + parseProcessCapture({ + ...capture, + manifest: { + ...capture.manifest, + normalization_sha256: "f".repeat(64), + }, + }), + ).toThrow("normalization_sha256"); + expect(() => + parseProcessCapture({ + ...capture, + manifest: { + ...capture.manifest, + executable_sha256: "f".repeat(64), + }, + }), + ).toThrow("executable_sha256"); + }); + + it("tells agents and users to recapture unsupported v3 evidence", () => { + const legacy = { ...emptyCapture(), schema_version: 3 }; + expect(() => parseProcessCapture(legacy)).toThrow( + LEGACY_PROCESS_CAPTURE_MESSAGE, + ); + const parsed = processCaptureSchema.safeParse(legacy); + expect(parsed.success).toBe(false); + if (parsed.success) throw new Error("expected legacy capture rejection"); + expect(parsed.error.issues[0]?.message).toBe( + LEGACY_PROCESS_CAPTURE_MESSAGE, + ); + }); + + it("requires compatible contracts and enforces capture age through a clock seam", () => { + const capture = emptyCapture(); + expect(() => + compareProcessCaptures(capture, { + ...capture, + manifest: { + ...capture.manifest, + comparison_contract: { changed: true }, + comparison_contract_sha256: digestProcessCommitment({ + changed: true, + }), + }, + }), + ).toThrow("incompatible comparison contracts"); + expect(() => + compareProcessCaptures(capture, capture, { + maxCaptureAgeMs: 1, + now: () => Date.parse("2026-01-01T00:00:01.000Z"), + }), + ).toThrow("max_capture_age_ms"); + }); + it("classifies missing observations as unknown and one-sided evidence as added", () => { const base = { - schema_version: 3 as const, + schema_version: 4 as const, + manifest: emptyCapture().manifest, + settlement: emptyCapture().settlement, normalization: { paths: true, pids: true, @@ -152,7 +291,7 @@ describe("process capture domain", () => { interaction_events: [], exit: { code: 0, signal: null, reason: "exited" as const }, process_samples: [], - filesystem_checkpoints: [], + filesystem_checkpoints: emptyCapture().filesystem_checkpoints, shim_events: [], protocol_events: [], files_before: [], @@ -196,7 +335,9 @@ describe("process capture domain", () => { it("compares raw terminal chunks even when rendered states agree", () => { const capture = { - schema_version: 3 as const, + schema_version: 4 as const, + manifest: emptyCapture().manifest, + settlement: emptyCapture().settlement, normalization: { paths: true, pids: true, @@ -209,7 +350,7 @@ describe("process capture domain", () => { interaction_events: [], exit: { code: 0, signal: null, reason: "exited" as const }, process_samples: [], - filesystem_checkpoints: [], + filesystem_checkpoints: emptyCapture().filesystem_checkpoints, shim_events: [], protocol_events: [], files_before: [], @@ -237,7 +378,9 @@ describe("process capture domain", () => { it("keeps filesystem evidence unknown when stable snapshots match", () => { const capture = { - schema_version: 3 as const, + schema_version: 4 as const, + manifest: emptyCapture().manifest, + settlement: emptyCapture().settlement, normalization: { paths: true, pids: true, @@ -250,7 +393,7 @@ describe("process capture domain", () => { interaction_events: [], exit: { code: 0, signal: null, reason: "exited" as const }, process_samples: [], - filesystem_checkpoints: [], + filesystem_checkpoints: emptyCapture().filesystem_checkpoints, shim_events: [], protocol_events: [], files_before: [], @@ -275,6 +418,7 @@ describe("process capture domain", () => { const transient = compareProcessCaptures(complete, { ...complete, filesystem_checkpoints: [ + { name: "before", at_ms: 0, files: [], effects: [], truncated: false }, { name: "during_run", at_ms: 10, @@ -282,6 +426,13 @@ describe("process capture domain", () => { effects: [], truncated: false, }, + { + name: "after_settlement", + at_ms: 50, + files: [], + effects: [], + truncated: false, + }, ], }); expect(transient.filesystem).toBe("changed"); @@ -290,7 +441,9 @@ describe("process capture domain", () => { it("detects changes in normalized process sample metadata", () => { const capture = { - schema_version: 3 as const, + schema_version: 4 as const, + manifest: emptyCapture().manifest, + settlement: emptyCapture().settlement, normalization: { paths: true, pids: true, @@ -312,7 +465,7 @@ describe("process capture domain", () => { command: "worker", }, ], - filesystem_checkpoints: [], + filesystem_checkpoints: emptyCapture().filesystem_checkpoints, shim_events: [], protocol_events: [], files_before: [], @@ -618,7 +771,7 @@ describe("process capture adapter", () => { ); expect(result.ok).toBe(true); if (!result.ok) throw result.error; - expect(result.value.schema_version).toBe(3); + expect(result.value.schema_version).toBe(4); expect(result.value.rendered_frames.at(-1)?.lines.join("\n")).toContain( "probe:codex 1.2.3", ); diff --git a/tests/reconstructionVerification.test.ts b/tests/reconstructionVerification.test.ts index e3d85dfcf..f362116f6 100644 --- a/tests/reconstructionVerification.test.ts +++ b/tests/reconstructionVerification.test.ts @@ -33,7 +33,7 @@ const source = ( { predicateType: authority === "controlled-replay" - ? "rea.process-capture/v3" + ? "rea.process-capture/v4" : "rea.analysis/v2", operation: authority === "controlled-replay" @@ -89,10 +89,10 @@ const processComparison = ( { id: "rea-process", name: "REA deterministic process harness", - version: "2", + version: "3", }, { - predicateType: "rea.process-comparison/v2", + predicateType: "rea.process-comparison/v3", operation: "compare_process_captures", parameters: { left_evidence_id: left.evidence_id, diff --git a/tests/staticRuntimeCorrelation.test.ts b/tests/staticRuntimeCorrelation.test.ts index ee8c727c1..35640663d 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/v3", + predicateType: "rea.process-capture/v4", operation: "capture_process_scenario", parameters: {}, result: { digit }, @@ -68,10 +68,10 @@ const processComparison = ( { id: "rea-process", name: "REA deterministic process harness", - version: "2", + version: "3", }, { - predicateType: "rea.process-comparison/v2", + predicateType: "rea.process-comparison/v3", operation: "compare_process_captures", parameters: { left_evidence_id: leftCapture.evidence_id,