feat(process): introduce evidence-safe capture v4
This commit is contained in:
parent
d0e21f9231
commit
ee4d284597
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<string> => {
|
||||
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<ProcessCapture["manifest"]> => {
|
||||
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<Omit<ProcessCapture["settlement"], "cleanup_outcome">> => {
|
||||
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<void>((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<NodeJS.Timeout>();
|
||||
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<ProcessCapture["settlement"], "cleanup_outcome"> = {
|
||||
state: "unverifiable",
|
||||
elapsed_ms: 0,
|
||||
};
|
||||
const interactions: InteractionEvent[] = [];
|
||||
const dispatchedEventIndexes = new Set<number>();
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Record<string, string>> =>
|
||||
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<ProcessGroupObservation> => {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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<typeof Cli.create>,
|
||||
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"),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -64,10 +64,10 @@ type RuntimeStatus = z.infer<typeof processCaptureComparisonSchema>["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",
|
||||
|
|
|
|||
|
|
@ -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<Record<string, unknown>> => ({
|
||||
...scenario,
|
||||
environment: Object.fromEntries(
|
||||
Object.entries(scenario.environment).map(([name, value]) => [
|
||||
name,
|
||||
scenario.secret_aliases.includes(name) ? "<redacted-secret>" : 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<Record<string, unknown>> => ({
|
||||
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<typeof processScenarioSchema>;
|
||||
|
||||
|
|
@ -321,7 +372,6 @@ export type ProcessScenario = z.infer<typeof processScenarioSchema>;
|
|||
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<Record<string, unknown>>;
|
||||
readonly comparison_contract: Readonly<Record<string, unknown>>;
|
||||
readonly shim_plan: readonly unknown[];
|
||||
readonly replay_plan: Readonly<Record<string, unknown>>;
|
||||
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<typeof normalizationSchema>;
|
||||
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<ProcessCapture> = 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<ProcessCapture> = 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<ProcessCapture> = 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<ProcessCapture> = z.object({
|
|||
}),
|
||||
});
|
||||
|
||||
/** One pure semantic validation failure in an otherwise shaped v4 capture. */
|
||||
export interface ProcessCaptureValidationIssue {
|
||||
readonly path: string;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
const orderedBy = <Value>(
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<typeof compareProcessCaptures>;
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue