fix(contracts): preserve local inputs and comparison uncertainty

This commit is contained in:
morluto
2026-10-09 06:47:48 +08:00
parent 7a7460f04f
commit 70e8a8f860
35 changed files with 460 additions and 386 deletions
@@ -4,13 +4,14 @@ import type { ToolContract } from "../toolContracts.js";
import { managedOutputSchemas } from "../toolOutputSchemas.js";
import { toolContractMetadata } from "../toolEffects.js";
import { requireOutputSchema } from "../toolOutputSchemaPrimitives.js";
import { isAbsoluteLocalPath } from "../../domain/localPath.js";
import {
isAbsoluteLocalPath,
localPathStringSchema,
} from "../../domain/localPath.js";
/** Exact caller boundary for execution-free PE/CLI triage and identity. */
const managedTargetInputSchema = z.strictObject({
path: z
.string()
.min(1)
path: localPathStringSchema
.refine(isAbsoluteLocalPath, {
message:
"path must be an absolute local filesystem path (for example /tmp/Example.dll or C:\\Example.dll)",
+4 -2
View File
@@ -8,6 +8,7 @@ import {
import type { ToolContract } from "../toolContracts.js";
import { nativeOutputSchemas } from "../toolOutputSchemas.js";
import { jsonValueSchema } from "../../domain/jsonValue.js";
import { localPathStringSchema } from "../../domain/localPath.js";
import { toolContractMetadata } from "../toolEffects.js";
import { requireOutputSchema } from "../toolOutputSchemaPrimitives.js";
@@ -69,7 +70,8 @@ export const swiftSymbolsSchema = z
z
.string()
.min(1)
.regex(/^[^\n]*$/u, "Each Swift symbol must be one line."),
.regex(/^[^\n]*$/u, "Each Swift symbol must be one line.")
.regex(/^[^\0]*$/u, "Swift symbols cannot contain NUL."),
)
.min(1);
@@ -104,7 +106,7 @@ export const NATIVE_TOOL_CONTRACTS = [
"inspect_plist",
"Parse Info.plist from the active artifact by default, or pass any local plist path. Returns normalized JSON rather than plutil text.",
z.object({
path: z.string().min(1).optional(),
path: localPathStringSchema.optional(),
}),
),
native(
+6 -7
View File
@@ -1,12 +1,13 @@
import { executableFormatHintSchema } from "../domain/dosCom.js";
import { isAbsoluteLocalPath } from "../domain/localPath.js";
import {
isAbsoluteLocalPath,
localPathStringSchema,
} from "../domain/localPath.js";
import { z } from "zod";
import { analysisProviderSelectorSchema } from "./providerSelection.js";
/** Caller-supplied analysis snapshot path; must name its destination absolutely. */
const snapshotPathSchema = z
.string()
.min(1)
const snapshotPathSchema = localPathStringSchema
.refine(isAbsoluteLocalPath, {
message:
"snapshot_path must be an absolute local filesystem path (for example /tmp/rea/analysis.json or C:\\rea\\analysis.json)",
@@ -16,9 +17,7 @@ const snapshotPathSchema = z
);
/** Caller-supplied analysis target path; must name the target absolutely. */
const binaryTargetPathSchema = z
.string()
.min(1)
const binaryTargetPathSchema = localPathStringSchema
.refine(isAbsoluteLocalPath, {
message:
"path must be an absolute local filesystem path (for example /tmp/fixture.bin or C:\\analysis\\fixture.bin)",
+23
View File
@@ -147,6 +147,29 @@ it.each(cases)("$name requires an absolute local path", (pathCase) => {
expect(pathCase.omitted(), pathCase.name).toBe(true);
});
it("preserves whitespace in selected browser and Electron filesystem names", () => {
const executable = "/opt/browser with trailing space ";
expect(
browserScenarioBrowserSchema.parse({
mode: "launch",
executable_path: executable,
}),
).toMatchObject({ executable_path: executable });
const application = "/tmp/application ";
const root = "/tmp/application root ";
expect(
electronActiveObservationInputSchema.parse({
executable_path: executable,
application_path: application,
application_root: root,
}),
).toMatchObject({
executable_path: executable,
application_path: application,
application_root: root,
});
});
it("keeps evidence bundle export overwrite disabled by default", () => {
expect(
exportEvidenceBundleInputSchema.safeParse({ path: "/tmp/bundle.json" }),
+5 -4
View File
@@ -1,6 +1,9 @@
import { z } from "zod";
import { isAbsoluteLocalPath } from "../domain/localPath.js";
import {
isAbsoluteLocalPath,
localPathStringSchema,
} from "../domain/localPath.js";
/** Optional expectations used to compare the live session with its caller. */
export const binarySessionInputSchema = z.strictObject({
@@ -9,9 +12,7 @@ export const binarySessionInputSchema = z.strictObject({
.string()
.regex(/^[a-f0-9]{64}$/u)
.optional(),
expected_server_path: z
.string()
.min(1)
expected_server_path: localPathStringSchema
.refine(isAbsoluteLocalPath, {
message:
"expected_server_path must be an absolute local filesystem path (for example /opt/rea/dist/main.js)",
+9 -5
View File
@@ -1,6 +1,9 @@
import { z } from "zod";
import { isAbsoluteLocalPath } from "../domain/localPath.js";
import {
isAbsoluteLocalPath,
localPathStringSchema,
} from "../domain/localPath.js";
import {
sessionOutputSchemas,
requireOutputSchema,
@@ -31,7 +34,10 @@ import { examplesFor } from "./toolContractHelpers.js";
import type { ToolContract } from "./toolContractTypes.js";
import { toolContractMetadata } from "./toolEffects.js";
const session = <Name extends string, Schema extends z.ZodObject>(
const session = <
Name extends keyof typeof sessionOutputSchemas,
Schema extends z.ZodObject,
>(
name: Name,
description: string,
inputSchema: Schema,
@@ -48,9 +54,7 @@ const session = <Name extends string, Schema extends z.ZodObject>(
/** Session-owned Evidence bundle export options. */
export const exportEvidenceBundleInputSchema = z.strictObject({
path: z
.string()
.min(1)
path: localPathStringSchema
.refine(isAbsoluteLocalPath, {
message:
"path must be an absolute local filesystem path (for example /tmp/rea/evidence.json or C:\\rea\\evidence.json)",
+5 -5
View File
@@ -1,13 +1,15 @@
import { z } from "zod";
import { isAbsoluteLocalPath } from "../domain/localPath.js";
import {
isAbsoluteLocalPath,
localPathStringSchema,
} from "../domain/localPath.js";
import { artifactComparisonInputSchema } from "../domain/artifactComparison.js";
import { bundleComparisonInputSchema } from "../domain/bundleComparison.js";
import { callPathInputSchema } from "../domain/callPath.js";
import { changedBehaviorInputSchema } from "../domain/changedBehavior.js";
import { functionComparisonInputSchema } from "../domain/functionComparison.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { processScenarioSchema } from "../domain/process/processCapture.js";
import { processTraceSpecificationSchema } from "../domain/process/processTraceComparison.js";
import { recordUnknownInputSchema } from "../domain/residualUnknown.js";
@@ -38,9 +40,7 @@ export const addressContextInputSchema = z.strictObject({
/** Session-owned Evidence bundle import options. */
export const importEvidenceBundleInputSchema = z.strictObject({
path: z
.string()
.min(1)
path: localPathStringSchema
.refine(isAbsoluteLocalPath, {
message:
"path must be an absolute local filesystem path (for example /tmp/evidence.json or C:\\rea\\evidence.json)",
+2 -2
View File
@@ -373,7 +373,7 @@ export const managedWorkflowOutputSchemas: Readonly<
};
/** Exact structured-content schemas for target lifecycle operations. */
export const sessionOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
export const sessionOutputSchemas = {
open_binary: lifecycleResultOf(
z.object({
path: z.string(),
@@ -469,4 +469,4 @@ export const sessionOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
unknown: residualUnknownSchema,
}),
),
};
} satisfies Readonly<Record<string, z.ZodObject>>;
+11 -6
View File
@@ -25,7 +25,8 @@ import { prefixedDigestSchema } from "./../domain/digests.js";
/** Inline result with its complete Evidence record. */
export const inlineEvidenceRecordSchema = evidenceEnvelopeSchema;
export const evidenceResultOf = (schema: z.ZodType) =>
/** Wrap a result and its Evidence while preserving the result schema type. */
export const evidenceResultOf = <Schema extends z.ZodType>(schema: Schema) =>
z.strictObject({
result: schema,
evidence_id: prefixedDigestSchema("ev"),
@@ -33,14 +34,18 @@ export const evidenceResultOf = (schema: z.ZodType) =>
});
const resultOf = evidenceResultOf;
export const lifecycleResultOf = (schema: z.ZodType) =>
/** Wrap a lifecycle result while preserving its exact schema type. */
export const lifecycleResultOf = <Schema extends z.ZodType>(schema: Schema) =>
z.object({ result: schema });
/** Resolve a required named output schema or reject contract drift. */
export const requireOutputSchema = (
schemas: Readonly<Record<string, z.ZodObject>>,
name: string,
): z.ZodObject => {
export const requireOutputSchema = <
Schemas extends Readonly<Record<string, z.ZodObject>>,
Name extends keyof Schemas & string,
>(
schemas: Schemas,
name: Name,
): Schemas[Name] => {
const schema = schemas[name];
if (schema === undefined)
throw new Error(`Missing output schema for ${name}`);
+6 -7
View File
@@ -1,6 +1,6 @@
import { z } from "zod";
import { isAbsoluteLocalPath } from "./localPath.js";
import { isAbsoluteLocalPath, localPathStringSchema } from "./localPath.js";
import { browserNetworkContentSelectionSchema } from "./browserNetworkEvidence.js";
import {
@@ -12,14 +12,13 @@ export const scenarioIdentifierSchema = z
.string()
.regex(/^[A-Za-z][A-Za-z0-9._-]*$/u);
const browserExecutablePathSchema = z
.string()
.trim()
.min(1)
.refine(isAbsoluteLocalPath, {
const browserExecutablePathSchema = localPathStringSchema.refine(
isAbsoluteLocalPath,
{
message:
"executable_path must be an absolute local filesystem path (for example /opt/chromium/chrome or C:\\chromium\\chrome.exe)",
});
},
);
const browserScenarioBaseUrlSchema = z
.string()
+3 -2
View File
@@ -11,6 +11,7 @@ import type { ResidualUnknown } from "./residualUnknown.js";
import { digestSchema } from "./../domain/digests.js";
import { prefixedDigestSchema } from "./../domain/digests.js";
import { comparisonStatusSchema } from "./comparisonStatus.js";
import { localPathStringSchema } from "./localPath.js";
const evidenceIdSchema = prefixedDigestSchema("ev");
const classificationSchema = z.enum([
@@ -24,8 +25,8 @@ const classificationSchema = z.enum([
/** Strict input for canonical Evidence bundle comparison. */
export const bundleComparisonInputSchema = z.strictObject({
left_bundle_path: z.string().min(1),
right_bundle_path: z.string().min(1),
left_bundle_path: localPathStringSchema,
right_bundle_path: localPathStringSchema,
record_pairs: z
.array(
z.object({
+25 -10
View File
@@ -42,7 +42,18 @@ const comparison = (
? "rea.process-comparison"
: "rea.artifact-comparison",
operation,
parameters: input.salt === undefined ? {} : { salt: input.salt },
parameters: {
...(process
? {
left_evidence_id: left.evidence_id,
right_evidence_id: right.evidence_id,
}
: {
left_evidence_ids: [left.evidence_id],
right_evidence_ids: [right.evidence_id],
}),
...(input.salt === undefined ? {} : { salt: input.salt }),
},
result,
confidence: "derived",
authority: "analyst-inference",
@@ -273,13 +284,17 @@ describe("changed behavior aggregation", () => {
processResult({ status: "changed", terminal: "changed" }),
{
salt: comparisonIndex,
evidenceLinks: Array.from(
{ length: 200 },
(_, linkIndex) =>
`ev_${(comparisonIndex * 200 + linkIndex + 1)
.toString(16)
.padStart(64, "0")}`,
),
evidenceLinks: [
left.evidence_id,
right.evidence_id,
...Array.from(
{ length: 200 },
(_, linkIndex) =>
`ev_${(comparisonIndex * 200 + linkIndex + 1)
.toString(16)
.padStart(64, "0")}`,
),
],
},
),
);
@@ -287,7 +302,7 @@ describe("changed behavior aggregation", () => {
const result = findChangedBehavior(comparisons);
expect(result.summary.observed_changes).toBe(comparisons.length);
expect(result.evidence_links).toHaveLength(comparisons.length * 201);
expect(result.findings.items[0]?.evidence_links).toHaveLength(201);
expect(result.evidence_links).toHaveLength(comparisons.length * 201 + 2);
expect(result.findings.items[0]?.evidence_links).toHaveLength(203);
});
});
+10 -6
View File
@@ -2,6 +2,7 @@ import { z } from "zod";
import { artifactComparisonResultSchema } from "./artifactComparison.js";
import { uniqueSorted } from "./canonicalOrdering.js";
import { comparisonSourceEvidenceIds } from "./comparisonSourceEvidence.js";
import { evidenceSchema, parseEvidence, type Evidence } from "./evidence.js";
import { functionComparisonResultSchema } from "./functionComparison.js";
import {
@@ -20,7 +21,7 @@ const evidenceIdSchema = prefixedDigestSchema("ev");
/** Input for aggregating existing comparison Evidence. */
export const changedBehaviorInputSchema = z.strictObject({
comparisons: z.array(evidenceSchema).default([]),
comparisons: z.array(evidenceSchema).min(1),
});
const findingSchema = z.object({
@@ -34,7 +35,7 @@ const findingSchema = z.object({
]),
status: comparisonStatusSchema,
source_comparison_id: evidenceIdSchema,
evidence_links: z.array(evidenceIdSchema).min(3),
evidence_links: z.array(evidenceIdSchema).min(2),
limitations: z.array(z.string()),
});
@@ -56,7 +57,7 @@ export const changedBehaviorResultSchema = z
findings: z.object({
items: z.array(findingSchema),
}),
evidence_links: z.array(evidenceIdSchema).min(3),
evidence_links: z.array(evidenceIdSchema).min(2),
limitations: z.array(z.string()),
})
.superRefine((result, context) => {
@@ -185,11 +186,14 @@ const parseComparisonEvidence = (input: unknown): Evidence => {
throw new TypeError(
"Comparison Evidence operation, predicate, and provider disagree",
);
if (evidence.evidence_links.length < 2)
if (
!comparisonSourceEvidenceIds(evidence).every((id) =>
evidence.evidence_links.includes(id),
)
)
throw new TypeError(
"Comparison Evidence must cite both source observations",
"Comparison Evidence must cite both selected source observations",
);
assertUnique(evidence.evidence_links, "source Evidence links");
parseResult(evidence);
return evidence;
};
+57
View File
@@ -0,0 +1,57 @@
import { z } from "zod";
import { uniqueSorted } from "./canonicalOrdering.js";
import { prefixedDigestSchema } from "./digests.js";
import type { Evidence } from "./evidence.js";
const evidenceIdSchema = prefixedDigestSchema("ev");
const sideParametersSchema = z.object({
left_evidence_id: evidenceIdSchema.optional(),
right_evidence_id: evidenceIdSchema.optional(),
left_evidence_ids: z.array(evidenceIdSchema).min(1).optional(),
right_evidence_ids: z.array(evidenceIdSchema).min(1).optional(),
});
/** Resolve each selected side and validate aliases and duplicates within that role. */
export const comparisonSourceEvidenceSides = (
evidence: Evidence,
): { left: string[]; right: string[] } => {
const parameters = sideParametersSchema.parse(evidence.parameters);
if (
(parameters.left_evidence_id === undefined) !==
(parameters.right_evidence_id === undefined) ||
(parameters.left_evidence_ids === undefined) !==
(parameters.right_evidence_ids === undefined)
)
throw new TypeError(
"Comparison Evidence source representation omitted one side",
);
const side = (single: string | undefined, multiple: string[] | undefined) => {
if (
single !== undefined &&
multiple !== undefined &&
(multiple.length !== 1 || multiple[0] !== single)
)
throw new TypeError(
"Comparison Evidence source representations disagree",
);
const selected = single === undefined ? (multiple ?? []) : [single];
if (selected.length === 0)
throw new TypeError("Comparison Evidence omitted its source parameters");
if (new Set(selected).size !== selected.length)
throw new TypeError(
"Comparison Evidence repeats a source within one side",
);
return selected;
};
return {
left: side(parameters.left_evidence_id, parameters.left_evidence_ids),
right: side(parameters.right_evidence_id, parameters.right_evidence_ids),
};
};
/** Resolve the source closure without counting observations shared by both roles twice. */
export const comparisonSourceEvidenceIds = (evidence: Evidence): string[] => {
const sides = comparisonSourceEvidenceSides(evidence);
return uniqueSorted([...sides.left, ...sides.right]);
};
+6 -7
View File
@@ -1,14 +1,12 @@
import { z } from "zod";
import { digestSchema } from "../digests.js";
import { jsonValueSchema } from "../jsonValue.js";
import { isAbsoluteLocalPath } from "../localPath.js";
import { isAbsoluteLocalPath, localPathStringSchema } from "../localPath.js";
const bytes = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER);
const range = z.strictObject({ offset: bytes, length: bytes.positive() });
const target = {
path: z
.string()
.min(1)
path: localPathStringSchema
.refine(isAbsoluteLocalPath, {
message:
"path must be an absolute local filesystem path (for example /tmp/firmware.bin or C:\\firmware\\firmware.bin)",
@@ -23,9 +21,10 @@ export const firmwareInputSchemas = {
inspect_firmware_regions: z.strictObject(target),
extract_firmware: z.strictObject({
...target,
output_directory: z
.string()
.min(1)
output_directory: localPathStringSchema
.refine(isAbsoluteLocalPath, {
message: "output_directory must be an absolute local filesystem path",
})
.describe("Absolute, absent output directory; parent must exist"),
range: range
.optional()
@@ -12,12 +12,13 @@ import {
} from "./javascriptSemanticGraph.js";
import { digestSchema } from "../digests.js";
import { prefixedDigestSchema } from "../digests.js";
import { localPathStringSchema } from "../localPath.js";
const countSchema = z.number().int().min(0);
/** Public target-free request for static JavaScript application analysis. */
export const analyzeJavaScriptApplicationInputSchema = z.strictObject({
input_path: z.string().min(1),
input_path: localPathStringSchema,
format: z.enum(["auto", "asar", "directory"]).default("auto"),
});
+7 -8
View File
@@ -1,17 +1,16 @@
import { z } from "zod";
import { digestSchema } from "../digests.js";
import { analyzeJavaScriptApplicationInputSchema } from "./javascriptApplicationAnalysis.js";
import { localPathStringSchema } from "../localPath.js";
/** Recover derived readable modules from one explicitly selected local script. */
export const javascriptRecoveryInputSchema = z.strictObject({
path: z
.string()
.min(1)
.describe("Absolute path to a UTF-8 JavaScript file; never executed"),
output_directory: z
.string()
.min(1)
.describe("Absolute absent output directory; parent must exist"),
path: localPathStringSchema.describe(
"Absolute path to a UTF-8 JavaScript file; never executed",
),
output_directory: localPathStringSchema.describe(
"Absolute absent output directory; parent must exist",
),
extraction_mode: z
.enum(["structural", "heuristic", "inspection"])
.default("structural")
+7
View File
@@ -1,4 +1,11 @@
import { isAbsolute, relative, sep } from "node:path";
import { z } from "zod";
/** Preserve selected filesystem names while rejecting OS-invalid NUL bytes. */
export const localPathStringSchema = z
.string()
.min(1)
.regex(/^[^\0]*$/u, "Local filesystem paths cannot contain NUL");
/** True when the value is an absolute filesystem path on the host platform. */
export const isAbsoluteLocalPath = (value: string): boolean =>
+12 -8
View File
@@ -1,16 +1,21 @@
import { z } from "zod";
import { localPathStringSchema } from "../localPath.js";
const hexSchema = z.string().regex(/^0x[0-9a-f]+$/u);
/** Objective-C class names and selectors never contain spaces or brackets. */
const objcName = z
const nativeString = z
.string()
.regex(
/^[^\0]*$/u,
"Native launch and breakpoint strings cannot contain NUL",
);
/** Objective-C class names and selectors never contain spaces or brackets. */
const objcName = nativeString
.min(1)
.regex(/^[^\s[\]]+$/u, "Use the bare name without spaces or brackets");
const functionBreakpoint = {
kind: z.literal("function"),
name: z
.string()
name: nativeString
.min(1)
.describe(
"Exact symbol name, such as `open`, a C++ or mangled Swift symbol, or `-[NSString length]`.",
@@ -28,8 +33,7 @@ const objcMethodBreakpoint = {
export const nativeCallBreakpointSchema = z.discriminatedUnion("kind", [
z.strictObject({
...functionBreakpoint,
module: z
.string()
module: nativeString
.min(1)
.optional()
.describe(
@@ -51,7 +55,7 @@ export const nativeCallBreakpointSchema = z.discriminatedUnion("kind", [
*/
export const nativeCallObservationInputSchema = z.strictObject({
breakpoints: z.array(nativeCallBreakpointSchema).min(1),
arguments: z.array(z.string()).default([]),
arguments: z.array(nativeString).default([]),
environment: z
.record(
z
@@ -62,7 +66,7 @@ export const nativeCallObservationInputSchema = z.strictObject({
)
.default({})
.describe("Overrides on top of the environment REA runs with."),
working_directory: z.string().min(1).optional(),
working_directory: localPathStringSchema.optional(),
duration_ms: z
.number()
.int()
-117
View File
@@ -1,117 +0,0 @@
import { describe, expect, it } from "vitest";
import { processScenarioSchema } from "./processScenario.js";
describe("direct process scenarios", () => {
it("defaults to one command with inherited environment and no filesystem scan", () => {
expect(processScenarioSchema.parse({ executable: "node" })).toMatchObject({
executable: "node",
arguments: [],
working_directory: ".",
environment: {},
filesystem_observation_paths: [],
events: [],
});
});
it("accepts caller-selected environment and explicit filesystem observations", () => {
const environment = { "app.setting": "value", "1": "numbered" };
const scenario = processScenarioSchema.parse({
executable: "node",
arguments: ["./task.mjs"],
working_directory: "./project",
environment,
filesystem_observation_paths: ["state"],
});
expect(scenario.environment).toEqual(environment);
expect(scenario.filesystem_observation_paths).toEqual(["state"]);
});
it("rejects malformed environment names and adapter-reserved identity", () => {
for (const name of ["", "KEY=VALUE", "KEY\0VALUE", "REA_PROCESS_RUN_ID"]) {
const result = processScenarioSchema.safeParse({
executable: "node",
environment: { [name]: "value" },
});
expect(result.success).toBe(false);
if (name === "" && !result.success) {
const invalidKey = result.error.issues[0];
expect(invalidKey?.code).toBe("invalid_key");
if (invalidKey?.code === "invalid_key") {
expect(invalidKey.issues[0]?.message).toBe(
"Environment names must not be empty",
);
}
}
if (name === "REA_PROCESS_RUN_ID" && !result.success) {
const invalidKey = result.error.issues[0];
expect(invalidKey?.code).toBe("invalid_key");
if (invalidKey?.code === "invalid_key") {
expect(invalidKey.issues[0]?.message).toBe(
"REA_PROCESS_RUN_ID is reserved by the process adapter",
);
}
}
}
expect(
processScenarioSchema.safeParse({
executable: "node",
environment: { "REA_PROCESS_RUN_ID\n": "caller-value" },
}).success,
).toBe(true);
});
it("rejects NUL in strings passed to child-process APIs", () => {
const nul = "\0";
for (const input of [
{ executable: `node${nul}` },
{ executable: "node", arguments: [nul] },
{ executable: "node", working_directory: `.${nul}` },
{ executable: "node", environment: { KEY: nul } },
{ executable: "node", filesystem_observation_paths: [nul] },
])
expect(processScenarioSchema.safeParse(input).success).toBe(false);
expect(
processScenarioSchema.safeParse({
executable: "node",
events: [{ type: "input", at_ms: 0, data: nul }],
}).success,
).toBe(true);
});
it("keeps bounded capture controls and ordered scheduled interaction", () => {
const scenario = processScenarioSchema.parse({
executable: "node",
events: [
{ type: "input", at_ms: 10, data: "answer", sensitive: true },
{ type: "resize", at_ms: 20, columns: 100, rows: 30 },
{ type: "signal", at_ms: 30, signal: "SIGTERM" },
],
limits: { output_bytes: 2_000_000, filesystem_depth: 0 },
});
expect(scenario.events).toHaveLength(3);
expect(scenario.events[0]).toMatchObject({ sensitive: true });
expect(scenario.limits.output_bytes).toBe(2_000_000);
expect(scenario.limits.filesystem_depth).toBe(0);
});
it("rejects events after timeout and unknown orchestration fields", () => {
expect(
processScenarioSchema.safeParse({
executable: "node",
timeout_ms: 10,
events: [{ type: "input", at_ms: 11, data: "late" }],
}).success,
).toBe(false);
for (const [field, value] of [
["command_shims", []],
["replay", {}],
["reactive", {}],
["checkpoints", []],
] as const)
expect(
processScenarioSchema.safeParse({ executable: "node", [field]: value })
.success,
).toBe(false);
});
});
+13 -10
View File
@@ -91,7 +91,7 @@ export const processComparisonContract = (
* Defaults are part of the evidence contract and must remain deterministic.
*/
export const processScenarioSchema = z
.object({
.strictObject({
executable: childProcessString.min(1),
arguments: z.array(childProcessString).default([]),
working_directory: childProcessString.default("."),
@@ -100,7 +100,7 @@ export const processScenarioSchema = z
.array(childProcessString.min(1))
.default([]),
terminal: z
.object({
.strictObject({
columns: z.number().int().min(1).max(65_535).default(80),
rows: z.number().int().min(1).max(65_535).default(24),
scrollback: z.number().int().min(0).default(1_000),
@@ -109,19 +109,19 @@ export const processScenarioSchema = z
events: z
.array(
z.discriminatedUnion("type", [
z.object({
z.strictObject({
...timedEventBase,
type: z.literal("input"),
data: z.string(),
sensitive: z.boolean().default(false),
}),
z.object({
z.strictObject({
...timedEventBase,
type: z.literal("resize"),
columns: z.number().int().min(1).max(65_535),
rows: z.number().int().min(1).max(65_535),
}),
z.object({
z.strictObject({
...timedEventBase,
type: z.literal("signal"),
signal: z.enum(["SIGINT", "SIGTERM", "SIGKILL"]),
@@ -133,7 +133,7 @@ export const processScenarioSchema = z
idle_timeout_ms: positiveBudget.default(30_000),
settle_ms: z.number().int().safe().nonnegative().default(100),
limits: z
.object({
.strictObject({
output_bytes: positiveBudget.default(1_000_000),
files: positiveBudget.default(10_000),
file_bytes: positiveBudget.default(10_000_000),
@@ -149,15 +149,19 @@ export const processScenarioSchema = z
filesystem_depth: 16,
}),
normalization: z
.object({
.strictObject({
paths: z.boolean().default(false),
pids: z.boolean().default(true),
ports: z.boolean().default(true),
time_bucket_ms: positiveBudget.default(10),
patterns: z
.array(
z.object({
pattern: z.string(),
z.strictObject({
pattern: z
.string()
.describe(
"Literal text to replace in captured output; not a regular expression.",
),
replacement: z.string(),
}),
)
@@ -171,7 +175,6 @@ export const processScenarioSchema = z
patterns: [],
}),
})
.strict()
.superRefine((scenario, context) => {
for (let index = 1; index < scenario.events.length; index += 1) {
const event = scenario.events[index];
@@ -322,10 +322,6 @@ describe("function reconstruction malformed source IDs", () => {
);
it.each([
[
"same singular source on both sides",
{ ...singular, right_evidence_id: left.evidence_id },
],
[
"duplicate left plural source",
{ ...plural, left_evidence_ids: [left.evidence_id, left.evidence_id] },
@@ -334,17 +330,9 @@ describe("function reconstruction malformed source IDs", () => {
"duplicate right plural source",
{ ...plural, right_evidence_ids: [right.evidence_id, right.evidence_id] },
],
[
"same plural source on both sides",
{ ...plural, right_evidence_ids: [left.evidence_id] },
],
[
"overlapping plural sides",
{ ...plural, right_evidence_ids: [right.evidence_id, left.evidence_id] },
],
])("rejects %s with the existing uniqueness guard", (_name, parameters) => {
])("rejects %s", (_name, parameters) => {
expect(admittedVerification(comparison(parameters))).toThrow(
/unique and two-sided/u,
/repeats a source within one side/u,
);
});
});
+8 -56
View File
@@ -6,6 +6,7 @@ import { z } from "zod";
import { artifactComparisonResultSchema } from "./artifactComparison.js";
import { artifactInventoryResultSchema } from "./artifactGraph.js";
import { uniqueSorted } from "./canonicalOrdering.js";
import { comparisonSourceEvidenceSides } from "./comparisonSourceEvidence.js";
import { parseEvidence, type Evidence } from "./evidence.js";
import { parseEvidenceBundle } from "./evidenceBundle.js";
import { functionComparisonResultSchema } from "./functionComparison.js";
@@ -36,13 +37,10 @@ import {
} from "./reconstructionUnknowns.js";
import type { ResidualUnknown } from "./residualUnknown.js";
const evidenceIdSchema = prefixedDigestSchema("ev");
export {
reconstructionVerificationInputSchema,
reconstructionVerificationResultSchema,
} from "./reconstructionVerificationSchemas.js";
import { prefixedDigestSchema } from "./../domain/digests.js";
export type { ReconstructionVerificationResult } from "./reconstructionVerificationSchemas.js";
const providers = {
@@ -130,12 +128,8 @@ const evaluateClaim = (
`Missing comparison Evidence ${claim.comparison_evidence_id}`,
);
validateComparisonIdentity(claim, comparison);
const sides = sourceSides(claim, comparison);
const allSourceIds = [...sides.left, ...sides.right];
if (new Set(allSourceIds).size !== allSourceIds.length)
throw new TypeError(
"Comparison source Evidence must be unique and two-sided",
);
const sides = comparisonSourceEvidenceSides(comparison);
const allSourceIds = uniqueSorted([...sides.left, ...sides.right]);
if (!sameSet(comparison.evidence_links, allSourceIds))
throw new TypeError(
"Comparison Evidence closure disagrees with its parameters",
@@ -224,52 +218,6 @@ const validateComparisonIdentity = (claim: Claim, evidence: Evidence): void => {
throw new TypeError("Comparison Evidence identity or authority disagrees");
};
const sourceSides = (
claim: Claim,
evidence: Evidence,
): { left: string[]; right: string[] } => {
const parameters = evidence.parameters;
let singularSides: { left: string[]; right: string[] } | undefined;
if (
claim.kind === "behavioral" ||
(claim.kind === "structural-function" &&
("left_evidence_id" in parameters || "right_evidence_id" in parameters))
) {
const parsed = z
.object({
left_evidence_id: evidenceIdSchema,
right_evidence_id: evidenceIdSchema,
})
.passthrough()
.parse(parameters);
singularSides = {
left: [parsed.left_evidence_id],
right: [parsed.right_evidence_id],
};
if (
claim.kind === "behavioral" ||
!("left_evidence_ids" in parameters || "right_evidence_ids" in parameters)
)
return singularSides;
}
const parsed = z
.object({
left_evidence_ids: z.array(evidenceIdSchema).min(1),
right_evidence_ids: z.array(evidenceIdSchema).min(1),
})
.passthrough()
.parse(parameters);
if (
singularSides !== undefined &&
(parsed.left_evidence_ids.length !== 1 ||
parsed.right_evidence_ids.length !== 1 ||
parsed.left_evidence_ids[0] !== singularSides.left[0] ||
parsed.right_evidence_ids[0] !== singularSides.right[0])
)
throw new TypeError("Function comparison source Evidence aliases disagree");
return { left: parsed.left_evidence_ids, right: parsed.right_evidence_ids };
};
const observedStatus = (claim: Claim, evidence: Evidence): ObservedStatus => {
if (claim.kind === "behavioral") {
const result = processCaptureComparisonSchema.parse(
@@ -335,6 +283,10 @@ const authorityLimitations = (
const requiredAuthority =
claim.kind === "behavioral" ? "controlled-replay" : "shipped-artifact";
const limitations: string[] = [];
if (sides.left.some((id) => sides.right.includes(id)))
limitations.push(
"A source observation appears on both comparison sides; independent original and reconstruction observations are unavailable.",
);
if (
sources.some(
(source) =>
@@ -393,5 +345,5 @@ const count = (
status: ClaimResult["status"],
): number => items.filter((item) => item.status === status).length;
const sameSet = (left: readonly string[], right: readonly string[]): boolean =>
left.length === right.length &&
uniqueSorted(left).length === uniqueSorted(right).length &&
uniqueSorted(left).every((id, index) => id === uniqueSorted(right)[index]);
@@ -89,7 +89,7 @@ export const reconstructionClaimResultSchema = z.object({
comparison_evidence_id: evidenceIdSchema,
left_evidence_ids: z.array(evidenceIdSchema).min(1),
right_evidence_ids: z.array(evidenceIdSchema).min(1),
evidence_links: z.array(evidenceIdSchema).min(3),
evidence_links: z.array(evidenceIdSchema).min(2),
unknown_ids: z.array(unknownIdSchema),
limitations: z.array(z.string()),
});
@@ -118,7 +118,7 @@ export const reconstructionVerificationResultSchema = z
unknown_ids: z.array(unknownIdSchema),
}),
),
evidence_links: z.array(evidenceIdSchema).min(3),
evidence_links: z.array(evidenceIdSchema).min(2),
limitations: z.array(z.string()),
})
.superRefine((result, context) => {
+8 -51
View File
@@ -5,6 +5,7 @@ import { z } from "zod";
import { artifactComparisonResultSchema } from "./artifactComparison.js";
import { uniqueSorted } from "./canonicalOrdering.js";
import { comparisonSourceEvidenceIds } from "./comparisonSourceEvidence.js";
import { evidenceSchema, parseEvidence, type Evidence } from "./evidence.js";
import { functionComparisonResultSchema } from "./functionComparison.js";
import {
@@ -392,66 +393,22 @@ const assertComparisonIdentity = (evidence: Evidence): void => {
evidence.provider.version !== "1") ||
evidence.confidence !== "derived" ||
evidence.authority !== "analyst-inference" ||
evidence.subject !== null ||
evidence.evidence_links.length < 2 ||
new Set(evidence.evidence_links).size !== evidence.evidence_links.length
evidence.subject !== null
)
throw new TypeError(
"Comparison Evidence operation, predicate, provider, or citations disagree",
);
const parameterLinks = comparisonParameterLinks(evidence);
if (!sameSet(parameterLinks, evidence.evidence_links))
const expectedLinks = comparisonSourceEvidenceIds(evidence);
const actualLinks = uniqueSorted(evidence.evidence_links);
if (
expectedLinks.length !== actualLinks.length ||
expectedLinks.some((id, index) => id !== actualLinks[index])
)
throw new TypeError(
"Comparison Evidence closure disagrees with its source parameters",
);
};
const comparisonParameterLinks = (evidence: Evidence): string[] => {
if (evidence.operation === "compare_process_captures") {
const parameters = z
.object({
left_evidence_id: evidenceIdSchema,
right_evidence_id: evidenceIdSchema,
})
.passthrough()
.parse(evidence.parameters);
return [parameters.left_evidence_id, parameters.right_evidence_id];
}
const parameters = z
.object({
left_evidence_id: evidenceIdSchema.optional(),
right_evidence_id: evidenceIdSchema.optional(),
left_evidence_ids: z.array(evidenceIdSchema).min(1).optional(),
right_evidence_ids: z.array(evidenceIdSchema).min(1).optional(),
})
.passthrough()
.parse(evidence.parameters);
const left =
parameters.left_evidence_id === undefined
? (parameters.left_evidence_ids ?? [])
: [parameters.left_evidence_id];
const right =
parameters.right_evidence_id === undefined
? (parameters.right_evidence_ids ?? [])
: [parameters.right_evidence_id];
if (left.length === 0 || right.length === 0)
throw new TypeError("Comparison Evidence omitted its source parameters");
return [...left, ...right];
};
const sameSet = (
left: readonly string[],
right: readonly string[],
): boolean => {
if (left.length !== right.length) return false;
const rightSet = new Set(right);
return (
rightSet.size === right.length &&
new Set(left).size === left.length &&
left.every((item) => rightSet.has(item))
);
};
const observedPattern = (
staticStatus: ComparisonStatus,
runtimeStatus: ComparisonStatus,
@@ -1,33 +0,0 @@
import { describe, expect, it } from "vitest";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import {
ProcessCaptureError,
processCaptureCancelled,
} from "./ProcessCaptureError.js";
describe("process capture error projection", () => {
it("reports incomplete cleanup resources without exposing its cause", () => {
const projected = projectAnalysisError(
new ProcessCaptureError("terminal cleanup failed", {
cause: new Error("secret-token"),
reason: "cleanup_incomplete",
cleanupResources: ["process_group"],
}),
);
expect(projected).toMatchObject({
code: "cleanup_incomplete",
details: { cleanup: "incomplete", resources: ["process_group"] },
});
expect(JSON.stringify(projected)).not.toContain("secret-token");
});
it("projects caller cancellation as completed cleanup", () => {
expect(projectAnalysisError(processCaptureCancelled())).toMatchObject({
code: "cancelled",
category: "cancelled",
details: { operation: "process_capture", cleanup: "complete" },
});
});
});
+2 -2
View File
@@ -31,14 +31,14 @@ export class ProcessCaptureError extends AnalysisError {
constructor(message: string, options?: ProcessCaptureErrorOptions) {
super(message, options);
this.userMessage = options?.userMessage;
this.userMessage = options?.userMessage ?? message;
this.userCategory = options?.userCategory;
this.reason =
options?.reason ??
(options?.userCategory === "cancelled" ? "cancelled" : "capture_failed");
this.cleanupIncomplete = this.reason === "cleanup_incomplete";
this.cleanupResources = options?.cleanupResources ?? [];
this.executionFailure = options?.executionFailure;
this.executionFailure = options?.executionFailure ?? message;
this.partialObservation = options?.partialObservation;
this.cleanupReport = options?.cleanupReport;
}
+16 -6
View File
@@ -56,7 +56,12 @@ export const registerArtifactComparisonTool = (
recordDerivedEvidence(
session,
evidence,
artifactUnknownInput(left, right, comparison.status),
artifactUnknownInput(
left,
right,
comparison.status,
evidence.evidence_id,
),
),
);
},
@@ -67,16 +72,21 @@ const artifactUnknownInput = (
left: { readonly evidence_id: string },
right: { readonly evidence_id: string },
status: ReturnType<typeof compareArtifacts>["status"],
comparisonEvidenceId: string,
): RecordUnknownInput | undefined => {
if (status === "unchanged" || left.evidence_id === right.evidence_id)
return undefined;
if (status === "unchanged") return undefined;
const contradictory =
(status === "changed" || status === "contradiction") &&
left.evidence_id !== right.evidence_id;
return {
question: `Artifact comparison is ${status}`,
question: `Artifact comparison is ${status} (comparison ${comparisonEvidenceId})`,
severity:
status === "unknown" || status === "truncated" ? "high" : "medium",
domain: "artifact-comparison",
supporting_evidence_ids: [left.evidence_id],
contradicting_evidence_ids: [right.evidence_id],
supporting_evidence_ids: contradictory
? [left.evidence_id]
: [...new Set([left.evidence_id, right.evidence_id])],
contradicting_evidence_ids: contradictory ? [right.evidence_id] : [],
required_authority: "shipped-artifact",
required_confidence: "observed",
required_environment: null,
+9 -3
View File
@@ -87,6 +87,7 @@ export const registerFunctionComparisonTool = (
status: comparison.status,
leftIds,
rightIds,
comparisonEvidenceId: evidence.evidence_id,
}),
);
return toEvidenceToolResult(evidence, contract, recorded);
@@ -98,18 +99,23 @@ const functionUnknownInput = ({
status,
leftIds,
rightIds,
comparisonEvidenceId,
}: {
status: ReturnType<typeof compareFunctions>["status"];
leftIds: readonly string[];
rightIds: readonly string[];
comparisonEvidenceId: string;
}): RecordUnknownInput | undefined => {
if (status === "unchanged") return undefined;
const supportingIds = [...new Set([...leftIds, ...rightIds])];
const contradictingIds = rightIds.filter((id) => !leftIds.includes(id));
const contradictory = status === "changed";
return {
question: `Function comparison is ${status}`,
question: `Function comparison is ${status} (comparison ${comparisonEvidenceId})`,
severity: status === "changed" ? "medium" : "high",
domain: "function-comparison",
supporting_evidence_ids: [...leftIds],
contradicting_evidence_ids: [...rightIds],
supporting_evidence_ids: contradictory ? [...leftIds] : supportingIds,
contradicting_evidence_ids: contradictory ? contradictingIds : [],
required_authority: "shipped-artifact",
required_confidence: "observed",
required_environment: null,
+8 -3
View File
@@ -182,14 +182,19 @@ const comparisonUnknownInput = (
.filter(([, status]) => status !== "unchanged")
.map(([scope]) => scope)
.join(", ");
const contradictory =
comparison.status === "changed" &&
parsed.left_evidence_id !== parsed.right_evidence_id;
// Unknown identity uses the question, not its supporting records. Bind the
// question to this comparison so distinct capture pairs and policies coexist.
return {
question: `Process captures disagree across: ${differingScopes} (comparison ${parsed.comparison_evidence_id})`,
question: `${contradictory ? "Process captures disagree" : `Process comparison is ${comparison.status}`} across: ${differingScopes} (comparison ${parsed.comparison_evidence_id})`,
severity: "high",
domain: "process-comparison",
supporting_evidence_ids: [parsed.left_evidence_id],
contradicting_evidence_ids: [parsed.right_evidence_id],
supporting_evidence_ids: contradictory
? [parsed.left_evidence_id]
: [...new Set([parsed.left_evidence_id, parsed.right_evidence_id])],
contradicting_evidence_ids: contradictory ? [parsed.right_evidence_id] : [],
required_authority: "controlled-replay",
required_confidence: "observed",
required_environment: null,
+22
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { runDerivedOperation } from "./runDerivedOperation.js";
import { EvidenceIntegrityError } from "../domain/evidenceErrors.js";
const context = (
controller: AbortController,
@@ -84,7 +85,28 @@ describe("derived MCP operation boundary", () => {
error: {
_tag: "AnalysisInputError",
operation: "compare_artifacts",
issues: [
{
path: [],
reason: "invalid_value",
message: "malformed comparison evidence",
},
],
},
});
});
it("preserves a tagged integrity failure instead of replacing it with invalid input", async () => {
const failure = new EvidenceIntegrityError(
"Source Evidence does not match its digest",
);
const result = await runDerivedOperation(
context(new AbortController()),
"compare_artifacts",
() => {
throw failure;
},
);
expect(result).toEqual({ ok: false, error: failure });
});
});
+18 -5
View File
@@ -1,10 +1,13 @@
import type { ServerContext } from "@modelcontextprotocol/server";
import { setImmediate } from "node:timers/promises";
import { z } from "zod";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisInputError,
} from "../domain/analysisErrorCore.js";
import { describeValidationFailure } from "../domain/evidenceBundle.js";
import { err, ok, type Result } from "../domain/result.js";
import { mcpProgressReporter, type McpProgressContext } from "./mcpProgress.js";
@@ -18,7 +21,7 @@ export const runDerivedOperation = async <Value>(
context: DerivedOperationContext,
operation: string,
compute: () => Value,
): Promise<Result<Value, AnalysisCancelledError | AnalysisInputError>> => {
): Promise<Result<Value, AnalysisError>> => {
const progress = mcpProgressReporter(context);
await progress.report({
phase: "prepare",
@@ -34,11 +37,21 @@ export const runDerivedOperation = async <Value>(
value = compute();
} catch (cause: unknown) {
// Typed causes already name the failed constraint; keep their issues.
if (cause instanceof AnalysisError) return err(cause);
return err(
cause instanceof AnalysisCancelledError ||
cause instanceof AnalysisInputError
? cause
: new AnalysisInputError(operation, { cause }),
new AnalysisInputError(
operation,
{ cause },
cause instanceof TypeError || cause instanceof z.ZodError
? [
{
path: [],
reason: "invalid_value",
message: describeValidationFailure(cause),
},
]
: [],
),
);
}
await progress.report({
@@ -0,0 +1,126 @@
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { expect, it, onTestFinished } from "vitest";
import { z } from "zod";
import { ARTIFACT_COMPARISON_EXAMPLE } from "../../../src/contracts/artifactComparisonExample.js";
import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js";
import {
createEvidence,
parseEvidence,
type Evidence,
} from "../../../src/domain/evidence.js";
import { jsonValueSchema } from "../../../src/domain/jsonValue.js";
import { createServer } from "../../../src/server/createServer.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
const connect = async () => {
const session = createTestBinarySession(() => {
throw new Error("Evidence comparison must not launch a provider");
});
const server = createServer(session, session);
const client = new Client({ name: "comparison-unknowns", version: "1" });
onTestFinished(async () => {
await Promise.allSettled([client.close(), server.close(), session.close()]);
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
return { client, session };
};
const observeAgain = (
source: Evidence,
observation: number,
result = source.normalized_result,
) =>
createEvidence(
source.subject === null
? undefined
: {
path: `/tmp/fixture-${observation}`,
sha256: source.subject.digest.sha256,
format: source.subject.format,
},
source.provider,
{
predicateType: source.predicate_type,
operation: source.operation,
parameters: { observation },
result,
confidence: source.confidence,
authority: source.authority,
},
);
it("retains one open unknown for an incomplete artifact compared with itself", async () => {
const { client, session } = await connect();
const source = ARTIFACT_COMPARISON_EXAMPLE.left;
const result = jsonValueSchema.parse(source.normalized_result);
if (result === null || Array.isArray(result) || typeof result !== "object")
throw new Error("Expected an inventory object");
const incomplete = observeAgain(source, 1, {
...result,
nodes: [],
occurrences: [],
});
const response = await client.callTool({
name: "compare_artifacts",
arguments: { left: incomplete, right: incomplete },
});
expect(response.isError, JSON.stringify(response)).not.toBe(true);
expect(response.structuredContent).toMatchObject({
result: { status: "truncated" },
});
expect(session.listUnknowns({ domain: "artifact-comparison" })).toMatchObject(
[
{
status: "open",
supporting_evidence_ids: [incomplete.evidence_id],
contradicting_evidence_ids: [],
},
],
);
});
it.each([
{
tool: "compare_artifacts",
domain: "artifact-comparison",
pair: ARTIFACT_COMPARISON_EXAMPLE,
},
{
tool: "compare_functions",
domain: "function-comparison",
pair: FUNCTION_COMPARISON_EXAMPLE,
},
])(
"keeps independent $domain observations and repeated comparisons idempotent",
async ({ tool, domain, pair }) => {
const { client, session } = await connect();
const right = observeAgain(pair.right, 2);
for (const source of [pair.right, right]) {
const response = await client.callTool({
name: tool,
arguments: { left: pair.left, right: source },
});
expect(response.isError, JSON.stringify(response)).not.toBe(true);
expect(response.structuredContent).toMatchObject({
result: { status: "changed" },
});
}
expect(session.listUnknowns({ domain })).toHaveLength(2);
const before = session.exportEvidenceBundle();
const repeated = await client.callTool({
name: tool,
arguments: { left: pair.left, right },
});
expect(repeated.isError, JSON.stringify(repeated)).not.toBe(true);
const compared = parseEvidence(
z.object({ evidence: z.unknown() }).parse(repeated.structuredContent)
.evidence,
);
expect(session.evidenceById(compared.evidence_id)).toEqual(compared);
expect(session.exportEvidenceBundle()).toEqual(before);
},
);
@@ -70,6 +70,14 @@ describe.skipIf(process.platform !== "darwin")(
expect(JSON.stringify(spanning.content)).toContain(
"Each Swift symbol must be one line.",
);
const invalidNativeString = await client.callTool({
name: "demangle_swift",
arguments: { symbols: ["$s4main\0FooV"] },
});
expect(invalidNativeString.isError).toBe(true);
expect(JSON.stringify(invalidNativeString.content)).toContain(
"Swift symbols cannot contain NUL.",
);
},
);
},
@@ -9,6 +9,7 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { itWithCaptureCapability } from "./processCaptureCapability.js";
import { captureProcessScenario } from "../../../src/process/capture/ProcessHarness.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { snapshotRoots } from "../../../src/process/capture/FilesystemSnapshot.js";
import { ProcessCaptureError } from "../../../src/process/capture/ProcessCaptureError.js";
import {
@@ -264,6 +265,13 @@ itWithCaptureCapability(
expect(result.error.executionFailure).toBe(
"fixture final snapshot failure",
);
const projected = projectAnalysisError(result.error);
expect(projected).toMatchObject({
code: "cleanup_incomplete",
details: { cleanup: "incomplete", resources: ["owned_process_group"] },
});
expect(projected).not.toHaveProperty("stack");
expect(projected).not.toHaveProperty("cause");
expect(partial).toBeDefined();
if (partial === undefined || !("observations" in partial))
throw new Error("expected incomplete process observations");
@@ -489,6 +497,11 @@ itWithCaptureCapability(
);
if (result.ok) throw new Error("expected initial snapshot cancellation");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "cancelled",
category: "cancelled",
details: { operation: "process_capture", cleanup: "complete" },
});
expect(result.error).toMatchObject({
reason: "cancelled",
userCategory: "cancelled",