From 70e8a8f86060496452ccbc1a49b39822efef4113 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 9 Oct 2026 06:47:48 +0800 Subject: [PATCH] fix(contracts): preserve local inputs and comparison uncertainty --- src/contracts/managed/managedToolContracts.ts | 9 +- src/contracts/native/nativeToolContracts.ts | 6 +- src/contracts/sessionLifecycleInputs.ts | 13 +- src/contracts/sessionPathInputs.test.ts | 23 ++++ src/contracts/sessionStatusContract.ts | 9 +- src/contracts/sessionToolContracts.ts | 14 +- src/contracts/sessionToolSchemas.ts | 10 +- src/contracts/toolOutputSchemaGroups.ts | 4 +- src/contracts/toolOutputSchemaPrimitives.ts | 17 ++- src/domain/browserScenarioValues.ts | 13 +- src/domain/bundleComparison.ts | 5 +- src/domain/changedBehavior.test.ts | 35 +++-- src/domain/changedBehavior.ts | 16 ++- src/domain/comparisonSourceEvidence.ts | 57 ++++++++ src/domain/firmware/firmwareAnalysis.ts | 13 +- .../javascriptApplicationAnalysis.ts | 3 +- src/domain/javascript/javascriptRecovery.ts | 15 +-- src/domain/localPath.ts | 7 + src/domain/native/nativeCallObservation.ts | 20 +-- src/domain/process/processScenario.test.ts | 117 ---------------- src/domain/process/processScenario.ts | 23 ++-- ...uctionVerification.functionSources.test.ts | 16 +-- src/domain/reconstructionVerification.ts | 64 ++------- .../reconstructionVerificationSchemas.ts | 4 +- src/domain/staticRuntimeCorrelation.ts | 59 ++------ .../capture/ProcessCaptureError.test.ts | 33 ----- src/process/capture/ProcessCaptureError.ts | 4 +- src/server/registerArtifactComparisonTool.ts | 22 ++- src/server/registerFunctionComparisonTool.ts | 12 +- src/server/registerProcessComparisonTool.ts | 11 +- src/server/runDerivedOperation.test.ts | 22 +++ src/server/runDerivedOperation.ts | 23 +++- .../mcp/comparisonUnknownsMcp.test.ts | 126 ++++++++++++++++++ tests/boundary/mcp/nativeDemangleMcp.test.ts | 8 ++ .../process/processCaptureLifecycle.test.ts | 13 ++ 35 files changed, 460 insertions(+), 386 deletions(-) create mode 100644 src/domain/comparisonSourceEvidence.ts delete mode 100644 src/domain/process/processScenario.test.ts delete mode 100644 src/process/capture/ProcessCaptureError.test.ts create mode 100644 tests/boundary/mcp/comparisonUnknownsMcp.test.ts diff --git a/src/contracts/managed/managedToolContracts.ts b/src/contracts/managed/managedToolContracts.ts index 85b5c19506..f256fc5dff 100644 --- a/src/contracts/managed/managedToolContracts.ts +++ b/src/contracts/managed/managedToolContracts.ts @@ -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)", diff --git a/src/contracts/native/nativeToolContracts.ts b/src/contracts/native/nativeToolContracts.ts index afeb9f652a..89b7b8c1e7 100644 --- a/src/contracts/native/nativeToolContracts.ts +++ b/src/contracts/native/nativeToolContracts.ts @@ -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( diff --git a/src/contracts/sessionLifecycleInputs.ts b/src/contracts/sessionLifecycleInputs.ts index 68b61b35fa..a230c9a4f1 100644 --- a/src/contracts/sessionLifecycleInputs.ts +++ b/src/contracts/sessionLifecycleInputs.ts @@ -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)", diff --git a/src/contracts/sessionPathInputs.test.ts b/src/contracts/sessionPathInputs.test.ts index 6f41558244..f8030aa5d6 100644 --- a/src/contracts/sessionPathInputs.test.ts +++ b/src/contracts/sessionPathInputs.test.ts @@ -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" }), diff --git a/src/contracts/sessionStatusContract.ts b/src/contracts/sessionStatusContract.ts index 303034c9c1..7b509b39a9 100644 --- a/src/contracts/sessionStatusContract.ts +++ b/src/contracts/sessionStatusContract.ts @@ -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)", diff --git a/src/contracts/sessionToolContracts.ts b/src/contracts/sessionToolContracts.ts index 5e9de09cfd..484e61bd29 100644 --- a/src/contracts/sessionToolContracts.ts +++ b/src/contracts/sessionToolContracts.ts @@ -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 = ( +const session = < + Name extends keyof typeof sessionOutputSchemas, + Schema extends z.ZodObject, +>( name: Name, description: string, inputSchema: Schema, @@ -48,9 +54,7 @@ const session = ( /** 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)", diff --git a/src/contracts/sessionToolSchemas.ts b/src/contracts/sessionToolSchemas.ts index 87afe287ab..408c14d1de 100644 --- a/src/contracts/sessionToolSchemas.ts +++ b/src/contracts/sessionToolSchemas.ts @@ -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)", diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index a5bfaa0796..0da2b520c8 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -373,7 +373,7 @@ export const managedWorkflowOutputSchemas: Readonly< }; /** Exact structured-content schemas for target lifecycle operations. */ -export const sessionOutputSchemas: Readonly> = { +export const sessionOutputSchemas = { open_binary: lifecycleResultOf( z.object({ path: z.string(), @@ -469,4 +469,4 @@ export const sessionOutputSchemas: Readonly> = { unknown: residualUnknownSchema, }), ), -}; +} satisfies Readonly>; diff --git a/src/contracts/toolOutputSchemaPrimitives.ts b/src/contracts/toolOutputSchemaPrimitives.ts index d5d441fab7..a4c1c636eb 100644 --- a/src/contracts/toolOutputSchemaPrimitives.ts +++ b/src/contracts/toolOutputSchemaPrimitives.ts @@ -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: 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: Schema) => z.object({ result: schema }); /** Resolve a required named output schema or reject contract drift. */ -export const requireOutputSchema = ( - schemas: Readonly>, - name: string, -): z.ZodObject => { +export const requireOutputSchema = < + Schemas extends Readonly>, + 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}`); diff --git a/src/domain/browserScenarioValues.ts b/src/domain/browserScenarioValues.ts index 6313c7ca9e..0344b628cf 100644 --- a/src/domain/browserScenarioValues.ts +++ b/src/domain/browserScenarioValues.ts @@ -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() diff --git a/src/domain/bundleComparison.ts b/src/domain/bundleComparison.ts index ec9c731535..cb01980a47 100644 --- a/src/domain/bundleComparison.ts +++ b/src/domain/bundleComparison.ts @@ -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({ diff --git a/src/domain/changedBehavior.test.ts b/src/domain/changedBehavior.test.ts index d47660f075..cc19c4c50b 100644 --- a/src/domain/changedBehavior.test.ts +++ b/src/domain/changedBehavior.test.ts @@ -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); }); }); diff --git a/src/domain/changedBehavior.ts b/src/domain/changedBehavior.ts index 37cdd97ac7..36f62e94ff 100644 --- a/src/domain/changedBehavior.ts +++ b/src/domain/changedBehavior.ts @@ -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; }; diff --git a/src/domain/comparisonSourceEvidence.ts b/src/domain/comparisonSourceEvidence.ts new file mode 100644 index 0000000000..420428b485 --- /dev/null +++ b/src/domain/comparisonSourceEvidence.ts @@ -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]); +}; diff --git a/src/domain/firmware/firmwareAnalysis.ts b/src/domain/firmware/firmwareAnalysis.ts index 8a7f1f5de4..abea685e7e 100644 --- a/src/domain/firmware/firmwareAnalysis.ts +++ b/src/domain/firmware/firmwareAnalysis.ts @@ -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() diff --git a/src/domain/javascript/javascriptApplicationAnalysis.ts b/src/domain/javascript/javascriptApplicationAnalysis.ts index b7f867a84a..313ab52912 100644 --- a/src/domain/javascript/javascriptApplicationAnalysis.ts +++ b/src/domain/javascript/javascriptApplicationAnalysis.ts @@ -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"), }); diff --git a/src/domain/javascript/javascriptRecovery.ts b/src/domain/javascript/javascriptRecovery.ts index 90c1f5df17..f5cf2b8052 100644 --- a/src/domain/javascript/javascriptRecovery.ts +++ b/src/domain/javascript/javascriptRecovery.ts @@ -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") diff --git a/src/domain/localPath.ts b/src/domain/localPath.ts index 9248de4d71..d1dd98fcee 100644 --- a/src/domain/localPath.ts +++ b/src/domain/localPath.ts @@ -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 => diff --git a/src/domain/native/nativeCallObservation.ts b/src/domain/native/nativeCallObservation.ts index b07a5a0f12..b4e5495f8d 100644 --- a/src/domain/native/nativeCallObservation.ts +++ b/src/domain/native/nativeCallObservation.ts @@ -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() diff --git a/src/domain/process/processScenario.test.ts b/src/domain/process/processScenario.test.ts deleted file mode 100644 index 9f669328d3..0000000000 --- a/src/domain/process/processScenario.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/domain/process/processScenario.ts b/src/domain/process/processScenario.ts index 382cf3196d..f62c947d77 100644 --- a/src/domain/process/processScenario.ts +++ b/src/domain/process/processScenario.ts @@ -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]; diff --git a/src/domain/reconstructionVerification.functionSources.test.ts b/src/domain/reconstructionVerification.functionSources.test.ts index 93e55d9e0e..af3f88594b 100644 --- a/src/domain/reconstructionVerification.functionSources.test.ts +++ b/src/domain/reconstructionVerification.functionSources.test.ts @@ -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, ); }); }); diff --git a/src/domain/reconstructionVerification.ts b/src/domain/reconstructionVerification.ts index 3de1d85096..c010c7d409 100644 --- a/src/domain/reconstructionVerification.ts +++ b/src/domain/reconstructionVerification.ts @@ -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]); diff --git a/src/domain/reconstructionVerificationSchemas.ts b/src/domain/reconstructionVerificationSchemas.ts index 4ce88ba7a0..13f5009f5b 100644 --- a/src/domain/reconstructionVerificationSchemas.ts +++ b/src/domain/reconstructionVerificationSchemas.ts @@ -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) => { diff --git a/src/domain/staticRuntimeCorrelation.ts b/src/domain/staticRuntimeCorrelation.ts index d5558ad277..91c0afa1de 100644 --- a/src/domain/staticRuntimeCorrelation.ts +++ b/src/domain/staticRuntimeCorrelation.ts @@ -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, diff --git a/src/process/capture/ProcessCaptureError.test.ts b/src/process/capture/ProcessCaptureError.test.ts deleted file mode 100644 index d554d8ee89..0000000000 --- a/src/process/capture/ProcessCaptureError.test.ts +++ /dev/null @@ -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" }, - }); - }); -}); diff --git a/src/process/capture/ProcessCaptureError.ts b/src/process/capture/ProcessCaptureError.ts index 4f24225594..1eea782a5f 100644 --- a/src/process/capture/ProcessCaptureError.ts +++ b/src/process/capture/ProcessCaptureError.ts @@ -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; } diff --git a/src/server/registerArtifactComparisonTool.ts b/src/server/registerArtifactComparisonTool.ts index a832a1a561..11a64ccf7a 100644 --- a/src/server/registerArtifactComparisonTool.ts +++ b/src/server/registerArtifactComparisonTool.ts @@ -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["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, diff --git a/src/server/registerFunctionComparisonTool.ts b/src/server/registerFunctionComparisonTool.ts index 8fff00abad..af8a113768 100644 --- a/src/server/registerFunctionComparisonTool.ts +++ b/src/server/registerFunctionComparisonTool.ts @@ -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["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, diff --git a/src/server/registerProcessComparisonTool.ts b/src/server/registerProcessComparisonTool.ts index 931086bc1c..a87aa6a0c2 100644 --- a/src/server/registerProcessComparisonTool.ts +++ b/src/server/registerProcessComparisonTool.ts @@ -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, diff --git a/src/server/runDerivedOperation.test.ts b/src/server/runDerivedOperation.test.ts index e1705cdc0d..f2404279ac 100644 --- a/src/server/runDerivedOperation.test.ts +++ b/src/server/runDerivedOperation.test.ts @@ -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 }); + }); }); diff --git a/src/server/runDerivedOperation.ts b/src/server/runDerivedOperation.ts index 0a7a55da67..bc0c7ef9d1 100644 --- a/src/server/runDerivedOperation.ts +++ b/src/server/runDerivedOperation.ts @@ -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 ( context: DerivedOperationContext, operation: string, compute: () => Value, -): Promise> => { +): Promise> => { const progress = mcpProgressReporter(context); await progress.report({ phase: "prepare", @@ -34,11 +37,21 @@ export const runDerivedOperation = async ( 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({ diff --git a/tests/boundary/mcp/comparisonUnknownsMcp.test.ts b/tests/boundary/mcp/comparisonUnknownsMcp.test.ts new file mode 100644 index 0000000000..ebbfb815e1 --- /dev/null +++ b/tests/boundary/mcp/comparisonUnknownsMcp.test.ts @@ -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); + }, +); diff --git a/tests/boundary/mcp/nativeDemangleMcp.test.ts b/tests/boundary/mcp/nativeDemangleMcp.test.ts index 706826ca6e..6974f5b020 100644 --- a/tests/boundary/mcp/nativeDemangleMcp.test.ts +++ b/tests/boundary/mcp/nativeDemangleMcp.test.ts @@ -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.", + ); }, ); }, diff --git a/tests/boundary/process/processCaptureLifecycle.test.ts b/tests/boundary/process/processCaptureLifecycle.test.ts index dc4219d14e..520caa14b8 100644 --- a/tests/boundary/process/processCaptureLifecycle.test.ts +++ b/tests/boundary/process/processCaptureLifecycle.test.ts @@ -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",