From 6b7aa124ed87ba38a657bd3cc2e7fe86b66466d3 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 23 Oct 2026 07:27:15 +0800 Subject: [PATCH] fix(native): own matching source validation and admission Separate source corruption and selected-role errors from unexpected derivation failures. Normalize coordinates before body membership and conflict checks, reuse parsed requests, and account for outer observation arrays before validation copies. --- .../NativeFunctionMatchingService.test.ts | 288 ++++++++++++++- .../native/NativeFunctionMatchingService.ts | 335 ++++++++---------- .../native/NativeMatchingBundleSources.ts | 18 +- .../native/NativeMatchingSources.ts | 112 ++++++ .../native/functionMatchingContracts.ts | 14 +- src/domain/evidenceValidation.ts | 16 +- src/domain/native/functionByteCoverage.ts | 7 +- src/domain/native/functionInventory.ts | 100 ++++-- src/domain/native/nativeAddress.ts | 3 - .../registerNativeFunctionMatchingTool.ts | 21 +- .../tools/nativeFunctionMatching.test.ts | 255 ++++++++++++- .../derivedOperationFailures.test.ts | 60 ++++ 12 files changed, 947 insertions(+), 282 deletions(-) create mode 100644 src/application/native/NativeMatchingSources.ts diff --git a/src/application/native/NativeFunctionMatchingService.test.ts b/src/application/native/NativeFunctionMatchingService.test.ts index a8c3ef6987..060ab34959 100644 --- a/src/application/native/NativeFunctionMatchingService.test.ts +++ b/src/application/native/NativeFunctionMatchingService.test.ts @@ -5,6 +5,7 @@ import { nativeFunctionMatchingSchema } from "../../domain/native/functionMatchi import { createEvidence } from "../../domain/evidence.js"; import { createAnalysisProfile } from "../../domain/analysisProfile.js"; import { functionDossierSchema } from "../../domain/hopperValues.js"; +import { procedureInfoSchema } from "../../domain/native/procedureInfo.js"; import { nativeMatchingInventory, nativeMatchingDetails, @@ -628,34 +629,92 @@ it("does not use generated symbols and preserves literal named FUN_ symbols when ); }); -it("rejects tampered, non-inventory and mixed-target observations with typed failures", async () => { +it("assigns source authentication, role selection, context, and lookup failures to their owning boundary", async () => { const left = nativeMatchingInventory("1", [ { address: "0x10", value: "main" }, ]); - for (const input of [ - { left: { ...left, normalized_result: [] }, right: left }, - { left: nativeMatchingDetails("1", "0x10", "c3")[0], right: left }, + const cases = [ { - left, - right: left, - left_observations: nativeMatchingDetails("2", "0x10", "c3"), + input: { left: { ...left, normalized_result: [] }, right: left }, + tag: "EvidenceIntegrityError", }, { - left, - right: left, - left_observations: [ - { kind: "retained-evidence", evidence_id: `ev_${"a".repeat(64)}` }, - ], + input: { left: nativeMatchingDetails("1", "0x10", "c3")[0], right: left }, + tag: "AnalysisInputError", + path: ["left"], }, - ]) { + { + input: { + left, + right: left, + left_observations: nativeMatchingDetails("2", "0x10", "c3"), + }, + tag: "EvidenceIntegrityError", + }, + { + input: { + left: nativeMatchingInventory("1", [ + { address: "not-an-address", value: "main" }, + ]), + right: left, + }, + tag: "EvidenceIntegrityError", + }, + { + input: { left, right: left, left_observations: [left] }, + tag: "AnalysisInputError", + path: ["left_observations", 0], + }, + { + input: { + left, + right: left, + left_observations: [ + createEvidence(nativeMatchingTarget("1"), nativeMatchingProvider, { + operation: "procedure_info", + parameters: { procedure: "0x10" }, + result: { + ...procedureInfoSchema.parse( + nativeMatchingDetails("1", "0x10", "c3")[0]!.normalized_result, + ), + entrypoint: "not-an-address", + }, + }), + ], + }, + tag: "EvidenceIntegrityError", + }, + { + input: { + left, + right: left, + left_observations: [ + { kind: "retained-evidence", evidence_id: `ev_${"a".repeat(64)}` }, + ], + }, + tag: "EvidenceIntegrityError", + evidenceId: `ev_${"a".repeat(64)}`, + reason: "missing", + }, + ] as const; + for (const { input, tag, ...expected } of cases) { const result = await matchNativeFunctions(input); expect(result.ok).toBe(false); - if (!result.ok) - expect([ - "AnalysisInputError", - "EvidenceIntegrityError", - "EvidenceReferenceError", - ]).toContain(result.error._tag); + if (!result.ok) { + expect(result.error._tag).toBe(tag); + if ("path" in expected) { + expect(result.error).toMatchObject({ + issues: expect.arrayContaining([ + expect.objectContaining({ path: expected.path }), + ]), + }); + } + if ("evidenceId" in expected) + expect(result.error).toMatchObject({ + evidenceId: expected.evidenceId, + reason: expected.reason, + }); + } } }); @@ -705,3 +764,194 @@ it("keeps duplicate candidate storage linear and emits every input occurrence ex 1000, ); }); + +it("normalizes accepted body range address spaces before matching byte coverage", async () => { + const body = { + available: true as const, + provenance: "synthetic mixed-space body", + ranges: [ + { start: "ram:0x10", end: "ram:0x10" }, + { start: "other%ab:0x20", end: "other%ab:0x20" }, + ], + total_bytes: 2, + span_bytes: null, + non_contiguous: true, + contains_entry: true, + }; + const info = createEvidence( + nativeMatchingTarget("1"), + nativeMatchingProvider, + { + operation: "procedure_info", + parameters: { procedure: "ram:0x10" }, + result: { + name: "main", + entrypoint: "ram:0x10", + basicblock_count: 1, + length: 2, + signature: null, + locals: [], + classification: null, + body, + }, + }, + ); + const read = (address: string, value: string) => + createEvidence(nativeMatchingTarget("1"), nativeMatchingProvider, { + operation: "read_bytes", + parameters: {}, + result: { + address, + requested_bytes: 1, + returned_bytes: 1, + bytes_hex: value, + complete: true, + }, + }); + const { report } = await compare({ + left: nativeMatchingInventory("1", [ + { address: "ram:0x10", value: "main" }, + ]), + right: nativeMatchingInventory("2", [{ address: "0x30", value: "main" }]), + left_observations: [ + info, + read("ram:0x10", "01"), + read("other%AB:0x20", "02"), + ], + }); + expect(report.matches[0]?.left[0]?.body_sha256).toBe( + createHash("sha256").update(Buffer.from("0102", "hex")).digest("hex"), + ); +}); + +it("compares repeated bodies and entry membership after address-space normalization", async () => { + const procedure = (entrypoint: string, rangeAddress: string) => + createEvidence(nativeMatchingTarget("1"), nativeMatchingProvider, { + operation: "procedure_info", + parameters: { procedure: entrypoint }, + result: { + name: "main", + entrypoint, + basicblock_count: 1, + length: 2, + signature: null, + locals: [], + classification: null, + body: { + available: true, + provenance: "synthetic escaped-space body", + ranges: [ + { + start: `${rangeAddress}:0x10`, + end: `${rangeAddress}:0x11`, + }, + ], + total_bytes: 2, + span_bytes: 2, + non_contiguous: false, + contains_entry: true, + }, + }, + }); + const first = procedure("ram%AB:0x10", "ram%ab"); + const equivalent = procedure("ram%ab:0x10", "ram%AB"); + const bytes = createEvidence( + nativeMatchingTarget("1"), + nativeMatchingProvider, + { + operation: "read_bytes", + parameters: { address: "ram%AB:0x10", length: 2 }, + result: { + address: "ram%AB:0x10", + requested_bytes: 2, + returned_bytes: 2, + bytes_hex: "0102", + complete: true, + }, + }, + ); + const { report } = await compare({ + left: nativeMatchingInventory("1", [ + { address: "ram%AB:0x10", value: "main" }, + ]), + right: nativeMatchingInventory("2", [{ address: "0x20", value: "main" }]), + left_observations: [first, equivalent, bytes], + }); + const member = report.matches[0]?.left[0]; + expect(member?.body_sha256).toBe( + createHash("sha256").update(Buffer.from("0102", "hex")).digest("hex"), + ); + expect(member?.limitations).not.toContain( + "Conflicting procedure observations; body and dossier remain unknown.", + ); + expect(member?.evidence_links).toEqual( + expect.arrayContaining([first.evidence_id, equivalent.evidence_id]), + ); +}); + +it("retains zero-byte reads with unavailable addresses when no request coordinate exists", async () => { + const info = createEvidence( + nativeMatchingTarget("1"), + nativeMatchingProvider, + { + operation: "procedure_info", + parameters: { procedure: "0x10" }, + result: { + name: "main", + entrypoint: "0x10", + basicblock_count: 1, + length: 1, + signature: null, + locals: [], + classification: null, + body: { + available: true, + provenance: "synthetic body", + ranges: [{ start: "0x10", end: "0x10" }], + total_bytes: 1, + span_bytes: 1, + non_contiguous: false, + contains_entry: true, + }, + }, + }, + ); + const emptyRead = createEvidence( + nativeMatchingTarget("1"), + nativeMatchingProvider, + { + operation: "read_bytes", + parameters: {}, + result: { + address: "unavailable", + requested_bytes: 1, + returned_bytes: 0, + bytes_hex: "", + complete: false, + }, + }, + ); + const left = nativeMatchingInventory("1", [ + { address: "0x10", value: "main" }, + ]); + const right = nativeMatchingInventory("2", [ + { address: "0x20", value: "main" }, + ]); + const result = await matchNativeFunctions({ + left, + right, + left_observations: [info, emptyRead], + }); + if (!result.ok) throw result.error; + const report = nativeFunctionMatchingSchema.parse( + result.value.evidence.normalized_result, + ); + expect(report.matches[0]?.left[0]).toMatchObject({ + body_bytes: 1, + body_sha256: null, + }); + expect(result.value.evidence.evidence_links).toContain(emptyRead.evidence_id); + expect(result.value.sources.map((source) => source.evidence_id)).toContain( + emptyRead.evidence_id, + ); +}); diff --git a/src/application/native/NativeFunctionMatchingService.ts b/src/application/native/NativeFunctionMatchingService.ts index 47cf75de45..0a28f0dd4c 100644 --- a/src/application/native/NativeFunctionMatchingService.ts +++ b/src/application/native/NativeFunctionMatchingService.ts @@ -4,26 +4,19 @@ import { RegularFileCleanupFailure } from "../../filesystem/OwnedRegularFile.js" import { matchFunctionInventories } from "../../domain/native/matchFunctionInventories.js"; import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { setImmediate } from "node:timers/promises"; -import { matchNativeFunctionsInputSchema } from "../../contracts/native/functionMatchingContracts.js"; -import { retainedEvidenceReferenceSchema } from "../../domain/evidenceReference.js"; import { - createEvidence, - parseEvidence, - type Evidence, -} from "../../domain/evidence.js"; + matchNativeFunctionsInputSchema, + type MatchNativeFunctionsInput, +} from "../../contracts/native/functionMatchingContracts.js"; +import { createEvidence, type Evidence } from "../../domain/evidence.js"; import { readFunctionInventory } from "../../domain/native/functionInventory.js"; import { nativeFunctionMatchingSchema } from "../../domain/native/functionMatching.js"; import { AnalysisError } from "../../domain/analysisErrorBase.js"; -import { - AnalysisCancelledError, - AnalysisInputError, -} from "../../domain/analysisErrorCore.js"; +import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js"; import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; import { err, ok, type Result } from "../../domain/result.js"; -import { - resolveEvidenceInput, - type EvidenceLookup, -} from "../EvidenceInputResolver.js"; +import type { EvidenceLookup } from "../EvidenceInputResolver.js"; +import { resolveNativeMatchingSources } from "./NativeMatchingSources.js"; import type { ExecutionOptions } from "../AnalysisProvider.js"; import { FUNCTION_COMPARISON_PROVIDER } from "../../domain/comparisonEvidenceIdentity.js"; @@ -31,220 +24,168 @@ const OPERATION = "match_native_functions"; export interface NativeFunctionMatchingResult { readonly evidence: Evidence; readonly sources: readonly Evidence[]; + readonly detail: MatchNativeFunctionsInput["detail"]; } /** Pair existing observations through one source/analysis admission. */ -export const matchNativeFunctions = ( +export const matchNativeFunctions = async ( rawInput: unknown, options?: ExecutionOptions, lookup?: EvidenceLookup, -): Promise> => - matchWithAdmission( - rawInput, - options, - lookup, - new NativeMatchingAdmission(OPERATION, options?.signal), - ); - -const matchWithAdmission = async ( - rawInput: unknown, - options: ExecutionOptions | undefined, - lookup: EvidenceLookup | undefined, - admission: NativeMatchingAdmission, ): Promise> => { + const admission = new NativeMatchingAdmission(OPERATION, options?.signal); try { - const checkpoint = admission.checkpoint; await setImmediate(); - await checkpoint(); + await admission.checkpoint(); + await admission.admit(rawInput); const parsed = matchNativeFunctionsInputSchema.safeParse(rawInput); if (!parsed.success) return err( analysisInputErrorFromIssues(OPERATION, parsed.error.issues, rawInput), ); - const sources = new Map(); - const resolve = async (input: unknown): Promise => { - const reference = retainedEvidenceReferenceSchema.safeParse(input); - let raw = input; - if (reference.success) { - const found = resolveEvidenceInput(reference.data, lookup); - if (!found.ok) throw found.error; - raw = found.value; - } - await admission.admit(raw); - const evidence = parseEvidence(raw); - const cached = sources.get(evidence.evidence_id); - if (cached !== undefined) return cached; - sources.set(evidence.evidence_id, evidence); - return evidence; - }; - const left = await resolve(parsed.data.left), - right = await resolve(parsed.data.right); - const observations = async (inputs: readonly unknown[]) => { - const result: Evidence[] = []; - const seen = new Set(); - for (const input of inputs) { - const record = await resolve(input); - if (!seen.has(record.evidence_id)) { - seen.add(record.evidence_id); - result.push(record); - } - } - return result; - }; - const leftObservations = await observations(parsed.data.left_observations); - const rightObservations = await observations( - parsed.data.right_observations, - ); - const directLinks = [...sources.keys()].sort(); - // Expand provenance separately from role observations: dependencies may be - // any Evidence operation and must not be interpreted as native body facts. - for (const source of sources.values()) - for (const id of source.evidence_links) - if (!sources.has(id)) - await resolve({ kind: "retained-evidence", evidence_id: id }); - const leftFunctions = await readFunctionInventory( - left, - leftObservations, - checkpoint, - ); - const rightFunctions = await readFunctionInventory( - right, - rightObservations, - checkpoint, - ); - const compatible = - left.subject?.architecture != null && - left.subject.architecture === right.subject?.architecture; - const { matches, summary } = await matchFunctionInventories( - leftFunctions, - rightFunctions, - { identicalBytesComparable: compatible, checkpoint }, - ); - const limitations = [ - ...new Set([ - ...left.limitations.map((x) => `Left: ${x}`), - ...right.limitations.map((x) => `Right: ${x}`), - "Coverage is restricted to the supplied provider-observed inventories; missing/truncated inventory facts never prove absence from either binary.", - "Only unique exact symbols and bytes are paired. Compatible observed CFG/call/string fingerprints propose unresolved candidates, never identity or equivalence. Missing structural coverage stays unknown.", - "Name or byte correspondence is not behavioral equivalence; use analyze_function for selected pairs, then compare_functions. Existing dossier pairs include directly usable retained-reference inputs.", - ...(!compatible - ? [ - "Identical-byte matching unavailable: input architectures are missing or differ.", - ] - : []), - ]), - ]; - const report = nativeFunctionMatchingSchema.parse({ - left: { - evidence_id: left.evidence_id, - sha256: left.subject!.digest.sha256, - functions: leftFunctions.length, - }, - right: { - evidence_id: right.evidence_id, - sha256: right.subject!.digest.sha256, - functions: rightFunctions.length, - }, - scope: "provider-observed-inputs", - source_coverage: { - status: - summary.only_left + summary.only_right + summary.unresolved > 0 - ? "partial" - : "complete-within-inputs", - left_inventory_completeness: "unknown", - right_inventory_completeness: "unknown", - left_byte_fingerprints: leftFunctions.filter( - (x) => x.fingerprint !== null, - ).length, - left_structural_candidates: leftFunctions.filter( - (x) => x.member.structural_candidate_sha256 !== null, - ).length, - right_structural_candidates: rightFunctions.filter( - (x) => x.member.structural_candidate_sha256 !== null, - ).length, - right_byte_fingerprints: rightFunctions.filter( - (x) => x.fingerprint !== null, - ).length, - }, - summary, - matches, - limitations, - }); - await checkpoint(); - const evidence = createEvidence(undefined, FUNCTION_COMPARISON_PROVIDER, { - operation: OPERATION, - predicateType: "rea.native-function-matching", - confidence: "derived", - authority: "analyst-inference", - parameters: { - left_evidence_id: left.evidence_id, - right_evidence_id: right.evidence_id, - left_observation_ids: leftObservations.map((x) => x.evidence_id), - right_observation_ids: rightObservations.map((x) => x.evidence_id), - algorithm: "exact-symbol-body-bytes-and-structural-candidates-v1", - }, - result: report, - limitations, - evidenceLinks: directLinks, - }); - await checkpoint(); - return ok({ evidence, sources: [...sources.values()] }); + return ok(await matchWithAdmission(parsed.data, admission, lookup)); } catch (cause: unknown) { - if (options?.signal?.aborted) - return err( - new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }), - ); - if (cause instanceof AnalysisError) return err(cause); - return err( - new AnalysisInputError(OPERATION, { cause }, [ - { - path: [], - reason: "invalid_value", - message: - cause instanceof Error - ? cause.message - : "Invalid native matching observations", - }, - ]), - ); + return matchingFailure(cause, options); } }; +const matchWithAdmission = async ( + input: MatchNativeFunctionsInput, + admission: NativeMatchingAdmission, + lookup: EvidenceLookup | undefined, +): Promise => { + const checkpoint = admission.checkpoint; + const { + left, + right, + leftObservations, + rightObservations, + directLinks, + sources, + } = await resolveNativeMatchingSources(input, admission, lookup); + const leftFunctions = await readFunctionInventory( + left, + leftObservations, + checkpoint, + ); + const rightFunctions = await readFunctionInventory( + right, + rightObservations, + checkpoint, + ); + const compatible = + left.subject?.architecture != null && + left.subject.architecture === right.subject?.architecture; + const { matches, summary } = await matchFunctionInventories( + leftFunctions, + rightFunctions, + { identicalBytesComparable: compatible, checkpoint }, + ); + const limitations = [ + ...new Set([ + ...left.limitations.map((x) => `Left: ${x}`), + ...right.limitations.map((x) => `Right: ${x}`), + "Coverage is restricted to the supplied provider-observed inventories; missing/truncated inventory facts never prove absence from either binary.", + "Only unique exact symbols and bytes are paired. Compatible observed CFG/call/string fingerprints propose unresolved candidates, never identity or equivalence. Missing structural coverage stays unknown.", + "Name or byte correspondence is not behavioral equivalence; use analyze_function for selected pairs, then compare_functions. Existing dossier pairs include directly usable retained-reference inputs.", + ...(!compatible + ? [ + "Identical-byte matching unavailable: input architectures are missing or differ.", + ] + : []), + ]), + ]; + const report = nativeFunctionMatchingSchema.parse({ + left: { + evidence_id: left.evidence_id, + sha256: left.subject!.digest.sha256, + functions: leftFunctions.length, + }, + right: { + evidence_id: right.evidence_id, + sha256: right.subject!.digest.sha256, + functions: rightFunctions.length, + }, + scope: "provider-observed-inputs", + source_coverage: { + status: + summary.only_left + summary.only_right + summary.unresolved > 0 + ? "partial" + : "complete-within-inputs", + left_inventory_completeness: "unknown", + right_inventory_completeness: "unknown", + left_byte_fingerprints: leftFunctions.filter( + (x) => x.fingerprint !== null, + ).length, + left_structural_candidates: leftFunctions.filter( + (x) => x.member.structural_candidate_sha256 !== null, + ).length, + right_structural_candidates: rightFunctions.filter( + (x) => x.member.structural_candidate_sha256 !== null, + ).length, + right_byte_fingerprints: rightFunctions.filter( + (x) => x.fingerprint !== null, + ).length, + }, + summary, + matches, + limitations, + }); + await checkpoint(); + const evidence = createEvidence(undefined, FUNCTION_COMPARISON_PROVIDER, { + operation: OPERATION, + predicateType: "rea.native-function-matching", + confidence: "derived", + authority: "analyst-inference", + parameters: { + left_evidence_id: left.evidence_id, + right_evidence_id: right.evidence_id, + left_observation_ids: leftObservations.map((x) => x.evidence_id), + right_observation_ids: rightObservations.map((x) => x.evidence_id), + algorithm: "exact-symbol-body-bytes-and-structural-candidates-v1", + }, + result: report, + limitations, + evidenceLinks: directLinks, + }); + await checkpoint(); + return { evidence, sources, detail: input.detail }; +}; + +/** Preserve cleanup owners and expected failures; unexpected implementation failures escape. */ +const matchingFailure = ( + cause: unknown, + options: ExecutionOptions | undefined, +): Result => { + if (cause instanceof RegularFileCleanupFailure) throw cause; + if (options?.signal?.aborted) + return err( + new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }), + ); + if (cause instanceof AnalysisError) return err(cause); + throw cause; +}; + /** Load selected portable records; do not retain unrelated records across bundles. */ export const matchNativeFunctionsFromBundles = async ( - input: unknown, + input: MatchNativeFunctionsInput, paths: readonly string[], resources: ArtifactResourceScope, options?: ExecutionOptions, ): Promise> => { try { - const parsed = matchNativeFunctionsInputSchema.safeParse(input); - if (!parsed.success) - return err( - analysisInputErrorFromIssues(OPERATION, parsed.error.issues, input), - ); const admission = new NativeMatchingAdmission(OPERATION, options?.signal); + await admission.admit(input); const loaded = await loadNativeMatchingBundleSources({ - input: parsed.data, + input, paths, resources, admission, }); - return await matchWithAdmission( - loaded.input, - options, - loaded.lookup, - admission, - ); + await setImmediate(); + await admission.checkpoint(); + return ok(await matchWithAdmission(loaded.input, admission, loaded.lookup)); } catch (cause: unknown) { - // This marker carries the exact descriptor owner; the caller must finish - // that owner before classifying cancellation or unexpected I/O failures. - if (cause instanceof RegularFileCleanupFailure) throw cause; - if (options?.signal?.aborted) - return err( - new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }), - ); - if (cause instanceof AnalysisError) return err(cause); - return err(new AnalysisInputError(OPERATION, { cause })); + return matchingFailure(cause, options); } }; diff --git a/src/application/native/NativeMatchingBundleSources.ts b/src/application/native/NativeMatchingBundleSources.ts index 0ac14d9f03..2243cc4e98 100644 --- a/src/application/native/NativeMatchingBundleSources.ts +++ b/src/application/native/NativeMatchingBundleSources.ts @@ -1,26 +1,25 @@ import { createHash } from "node:crypto"; -import type { z } from "zod"; import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import type { EvidenceBundle } from "../../domain/evidenceBundle.js"; -import { immutableEvidence, type Evidence } from "../../domain/evidence.js"; +import type { Evidence } from "../../domain/evidence.js"; +import { readImmutableEvidence } from "../../domain/evidenceValidation.js"; import { EvidenceIntegrityError, EvidenceReferenceError, } from "../../domain/evidenceErrors.js"; import { digestCanonicalValue } from "../../domain/canonicalDigest.js"; import { retainedEvidenceReferenceSchema } from "../../domain/evidenceReference.js"; -import type { matchNativeFunctionsInputSchema } from "../../contracts/native/functionMatchingContracts.js"; +import type { MatchNativeFunctionsInput } from "../../contracts/native/functionMatchingContracts.js"; import { readImmutableEvidenceBundle } from "../EvidenceBundleFiles.js"; import type { EvidenceLookup } from "../EvidenceInputResolver.js"; import type { NativeMatchingAdmission } from "./NativeMatchingAdmission.js"; -type Request = z.output; interface SourceEntry { readonly file: number; readonly links: readonly string[]; } interface Options { - readonly input: Request; + readonly input: MatchNativeFunctionsInput; readonly paths: readonly string[]; readonly resources: ArtifactResourceScope; readonly admission: NativeMatchingAdmission; @@ -55,7 +54,10 @@ export const loadNativeMatchingBundleSources = async ({ paths, resources, admission, -}: Options): Promise<{ input: Request; lookup: EvidenceLookup }> => { +}: Options): Promise<{ + input: MatchNativeFunctionsInput; + lookup: EvidenceLookup; +}> => { const records = new Map(), index = new Map(); const rootIds: string[] = []; @@ -69,7 +71,9 @@ export const loadNativeMatchingBundleSources = async ({ if (parsed.success) rootIds.push(parsed.data.evidence_id); else { await admission.admit(value); - const record = immutableEvidence(value); + const checked = readImmutableEvidence(value); + if (!checked.ok) throw checked.error; + const record = checked.value; rootIds.push(record.evidence_id); records.set(record.evidence_id, record); index.set(record.evidence_id, { file: -1, links: record.evidence_links }); diff --git a/src/application/native/NativeMatchingSources.ts b/src/application/native/NativeMatchingSources.ts new file mode 100644 index 0000000000..2a3dd61bb9 --- /dev/null +++ b/src/application/native/NativeMatchingSources.ts @@ -0,0 +1,112 @@ +import type { MatchNativeFunctionsInput } from "../../contracts/native/functionMatchingContracts.js"; +import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; +import type { Evidence } from "../../domain/evidence.js"; +import { readEvidence } from "../../domain/evidenceValidation.js"; +import { retainedEvidenceReferenceSchema } from "../../domain/evidenceReference.js"; +import { + resolveEvidenceInput, + type EvidenceLookup, +} from "../EvidenceInputResolver.js"; +import type { NativeMatchingAdmission } from "./NativeMatchingAdmission.js"; + +const INVENTORY_OPERATIONS = ["list_procedures"] as const; +const OBSERVATION_OPERATIONS = [ + "list_names", + "procedure_info", + "read_bytes", + "analyze_function", +] as const; + +/** Authenticate selected roles and their closure without interpreting dependencies as observations. */ +export const resolveNativeMatchingSources = async ( + input: MatchNativeFunctionsInput, + admission: NativeMatchingAdmission, + lookup: EvidenceLookup | undefined, +): Promise<{ + readonly left: Evidence; + readonly right: Evidence; + readonly leftObservations: readonly Evidence[]; + readonly rightObservations: readonly Evidence[]; + readonly directLinks: readonly string[]; + readonly sources: readonly Evidence[]; +}> => { + const sources = new Map(); + const resolve = async (input: unknown): Promise => { + const reference = retainedEvidenceReferenceSchema.safeParse(input); + let raw = input; + if (reference.success) { + const found = resolveEvidenceInput(reference.data, lookup); + if (!found.ok) throw found.error; + raw = found.value; + } + await admission.admit(raw); + const checked = readEvidence(raw); + if (!checked.ok) throw checked.error; + const evidence = checked.value; + const cached = sources.get(evidence.evidence_id); + if (cached !== undefined) return cached; + sources.set(evidence.evidence_id, evidence); + return evidence; + }; + const select = async ( + raw: unknown, + path: readonly (string | number)[], + operations: readonly string[], + ): Promise => { + const evidence = await resolve(raw); + if ( + evidence.predicate_type !== "rea.analysis" || + !operations.includes(evidence.operation) + ) { + const message = `Expected rea.analysis ${operations.join(" or ")} Evidence, received ${evidence.predicate_type} ${evidence.operation}`; + throw new AnalysisInputError( + admission.operation, + { userMessage: message }, + [{ path, reason: "invalid_value", message }], + ); + } + return evidence; + }; + const left = await select(input.left, ["left"], INVENTORY_OPERATIONS); + const right = await select(input.right, ["right"], INVENTORY_OPERATIONS); + const observations = async ( + inputs: readonly unknown[], + field: "left_observations" | "right_observations", + ): Promise => { + const result: Evidence[] = []; + const seen = new Set(); + for (const [index, input] of inputs.entries()) { + const record = await select( + input, + [field, index], + OBSERVATION_OPERATIONS, + ); + if (!seen.has(record.evidence_id)) { + seen.add(record.evidence_id); + result.push(record); + } + } + return result; + }; + const leftObservations = await observations( + input.left_observations, + "left_observations", + ); + const rightObservations = await observations( + input.right_observations, + "right_observations", + ); + const directLinks = [...sources.keys()].sort(); + for (const source of sources.values()) + for (const id of source.evidence_links) + if (!sources.has(id)) + await resolve({ kind: "retained-evidence", evidence_id: id }); + return { + left, + right, + leftObservations, + rightObservations, + directLinks, + sources: [...sources.values()], + }; +}; diff --git a/src/contracts/native/functionMatchingContracts.ts b/src/contracts/native/functionMatchingContracts.ts index 00cbf301b2..0c703a5130 100644 --- a/src/contracts/native/functionMatchingContracts.ts +++ b/src/contracts/native/functionMatchingContracts.ts @@ -5,6 +5,10 @@ import { evidenceInputSchema } from "../../domain/evidenceReference.js"; // Evidence/reference union without a pre-admission recursive SDK clone. const input = z.unknown().nonoptional(); input._zod.processJSONSchema = evidenceInputSchema._zod.processJSONSchema; +// Borrow the outer selections too: admission must account for their width before +// any array traversal/copy. The owner authenticates each selected carrier later. +const observations = z.custom(Array.isArray); +observations._zod.processJSONSchema = z.array(input)._zod.processJSONSchema; /** Inventories stay portable; optional observations avoid all-function decompilation. */ export const matchNativeFunctionsInputSchema = z.strictObject({ @@ -14,17 +18,19 @@ export const matchNativeFunctionsInputSchema = z.strictObject({ right: input.describe( "Right list_procedures Evidence or exact retained reference", ), - left_observations: z - .array(input) + left_observations: observations .default([]) .describe( "Optional same-context list_names, procedure_info, read_bytes or analyze_function Evidence", ), - right_observations: z - .array(input) + right_observations: observations .default([]) .describe( "Optional same-context list_names, procedure_info, read_bytes or analyze_function Evidence", ), detail: z.enum(["summary", "complete"]).default("summary"), }); + +export type MatchNativeFunctionsInput = z.output< + typeof matchNativeFunctionsInputSchema +>; diff --git a/src/domain/evidenceValidation.ts b/src/domain/evidenceValidation.ts index 52be1fe6bd..fc853d91fa 100644 --- a/src/domain/evidenceValidation.ts +++ b/src/domain/evidenceValidation.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { parseEvidence, type Evidence } from "./evidence.js"; +import { parseEvidence, immutableEvidence, type Evidence } from "./evidence.js"; import { EvidenceIntegrityError } from "./evidenceErrors.js"; import { describeValidationFailure } from "./validationFailure.js"; import { err, ok, type Result } from "./result.js"; @@ -8,9 +8,21 @@ import { err, ok, type Result } from "./result.js"; /** Authenticate one source record, preserving unexpected implementation failures. */ export const readEvidence = ( input: unknown, +): Result => + authenticateEvidence(input, parseEvidence); + +/** Authenticate and seal source records retained independently of mutable input. */ +export const readImmutableEvidence = ( + input: unknown, +): Result => + authenticateEvidence(input, immutableEvidence); + +const authenticateEvidence = ( + input: unknown, + parse: (input: unknown) => Evidence, ): Result => { try { - return ok(parseEvidence(input)); + return ok(parse(input)); } catch (cause: unknown) { if (!(cause instanceof z.ZodError)) throw cause; return err(integrityFailure(cause)); diff --git a/src/domain/native/functionByteCoverage.ts b/src/domain/native/functionByteCoverage.ts index b3889fc636..c5f1a75ebf 100644 --- a/src/domain/native/functionByteCoverage.ts +++ b/src/domain/native/functionByteCoverage.ts @@ -3,7 +3,6 @@ import type { z } from "zod"; import type { functionBodySchema } from "../hopperValues.js"; import type { nativeByteReadSchema } from "./nativeByteRead.js"; import { addressCoordinate } from "./addressCoordinate.js"; -import { parseNativeAddress } from "./nativeAddress.js"; export type NativeMatchingCheckpoint = (bytes?: number) => Promise; interface Interval { @@ -94,7 +93,7 @@ export class FunctionByteCoverage { "Cannot append byte observations after fingerprinting", ); if (read.returned_bytes === 0) return; - const coordinate = addressCoordinate(parseNativeAddress(read.address))!; + const coordinate = addressCoordinate(read.address)!; let group = this.#pieces.get(coordinate.space); if (group === undefined) this.#pieces.set(coordinate.space, (group = [])); await this.checkpoint(192 + read.bytes_hex.length * 2); @@ -162,8 +161,8 @@ export class FunctionByteCoverage { lengths: string[] = [], links = new Set(); for (const range of body.ranges) { - const start = addressCoordinate(parseNativeAddress(range.start))!, - end = addressCoordinate(parseNativeAddress(range.end))!; + const start = addressCoordinate(range.start)!, + end = addressCoordinate(range.end)!; const pieces = this.#pieces.get(start.space) ?? []; lengths.push(String(end.offset - start.offset + 1n)); let index = firstOverlapping(pieces, start.offset), diff --git a/src/domain/native/functionInventory.ts b/src/domain/native/functionInventory.ts index a89e09802b..3aa40b6a68 100644 --- a/src/domain/native/functionInventory.ts +++ b/src/domain/native/functionInventory.ts @@ -1,7 +1,8 @@ import { functionStructuralCandidate } from "./functionStructuralCandidate.js"; -import type { z } from "zod"; +import { z } from "zod"; import type { Evidence } from "../evidence.js"; import { EvidenceIntegrityError } from "../evidenceErrors.js"; +import { readEvidencePayload } from "../evidenceValidation.js"; import { digestCanonicalValue } from "../canonicalDigest.js"; import { functionBodySchema, @@ -13,7 +14,7 @@ import { addressedValueSchema, nativeInventoryRowsSchemas, } from "./nativeInventory.js"; -import { parseNativeAddress } from "./nativeAddress.js"; +import { nativeAddressInputSchema } from "./nativeAddress.js"; import { nativeByteReadSchema } from "./nativeByteRead.js"; import { procedureInfoSchema } from "./procedureInfo.js"; import type { NativeFunctionMatchMember } from "./functionMatching.js"; @@ -22,15 +23,52 @@ import { type NativeMatchingCheckpoint, } from "./functionByteCoverage.js"; +const matchingRowsSchema = nativeInventoryRowsSchemas.names.element + .extend({ address: nativeAddressInputSchema }) + .array(); +const availableBodySchema = functionBodySchema.options[1]; +const matchingBodySchema = z.discriminatedUnion("available", [ + functionBodySchema.options[0], + availableBodySchema.safeExtend({ + ranges: availableBodySchema.shape.ranges.element + .extend({ + start: nativeAddressInputSchema, + end: nativeAddressInputSchema, + }) + .array(), + }), +]); +const matchingProcedureInfoSchema = procedureInfoSchema + .safeExtend({ + entrypoint: nativeAddressInputSchema, + body: matchingBodySchema, + }) + .transform(({ entrypoint, body }) => ({ address: entrypoint, body })); +const matchingFunctionDossierSchema = functionDossierSchema.safeExtend({ + procedure: functionDossierSchema.shape.procedure.safeExtend({ + address: nativeAddressInputSchema, + body: matchingBodySchema, + }), +}); +const sourcePayload = ( + schema: z.ZodType, + input: unknown, +): Output => { + const result = readEvidencePayload(schema, input); + if (!result.ok) throw result.error; + return result.value; +}; + type Body = z.infer; type AddressedValue = z.infer; + export interface MatchingFunction { readonly member: NativeFunctionMatchMember; readonly symbol: string | null; readonly fingerprint: string | null; } interface Detail { - body?: Body; + body: Body; dossier?: string | null; candidate?: string | null; conflict: boolean; @@ -66,27 +104,30 @@ const requireContext = (inventory: Evidence, observation: Evidence): void => { const addBody = (observation: Evidence, details: Facts["details"]): void => { const dossier = observation.operation === "analyze_function" - ? functionDossierSchema.parse(observation.normalized_result) + ? sourcePayload( + matchingFunctionDossierSchema, + observation.normalized_result, + ) : undefined; const procedure = dossier?.procedure ?? - (() => { - const info = procedureInfoSchema.parse(observation.normalized_result); - return { address: info.entrypoint, body: info.body }; - })(); - const address = parseNativeAddress(procedure.address); + sourcePayload(matchingProcedureInfoSchema, observation.normalized_result); + const address = procedure.address; if (!functionBodyEntryAgrees(procedure.body, address)) throw new EvidenceIntegrityError( "Procedure body entry membership contradicts its address", ); let detail = details.get(address); - if (detail === undefined) - details.set(address, (detail = { conflict: false, links: [] })); - if ( - detail.body !== undefined && + if (detail === undefined) { + details.set( + address, + (detail = { body: procedure.body, conflict: false, links: [] }), + ); + } else if ( digestCanonicalValue(detail.body) !== digestCanonicalValue(procedure.body) - ) + ) { detail.conflict = true; + } detail.body = procedure.body; if (observation.operation === "analyze_function") { const candidate = functionStructuralCandidate(observation, dossier!); @@ -106,11 +147,12 @@ const addNames = async ( symbols: Facts["symbols"], checkpoint: NativeMatchingCheckpoint, ): Promise => { - for (const symbol of nativeInventoryRowsSchemas.names.parse( + for (const symbol of sourcePayload( + matchingRowsSchema, observation.normalized_result, )) { await checkpoint(128 + symbol.value.length * 2); - const address = parseNativeAddress(symbol.address); + const address = symbol.address; let group = symbols.get(address); if (group === undefined) symbols.set(address, (group = { values: [], links: new Set() })); @@ -122,11 +164,23 @@ const addBytes = async ( observation: Evidence, bytes: FunctionByteCoverage, ): Promise => { - const read = nativeByteReadSchema.parse(observation.normalized_result); + const parsed = sourcePayload( + nativeByteReadSchema, + observation.normalized_result, + ); + const requestAddress = + typeof observation.parameters.address === "string" + ? sourcePayload(nativeAddressInputSchema, observation.parameters.address) + : undefined; + const read = { + ...parsed, + address: + requestAddress !== undefined || parsed.returned_bytes > 0 + ? sourcePayload(nativeAddressInputSchema, parsed.address) + : parsed.address, + }; if ( - (typeof observation.parameters.address === "string" && - parseNativeAddress(observation.parameters.address) !== - parseNativeAddress(read.address)) || + (requestAddress !== undefined && requestAddress !== read.address) || (typeof observation.parameters.length === "number" && observation.parameters.length !== read.requested_bytes) ) @@ -176,7 +230,7 @@ const functionFact = async ( facts: Facts, checkpoint: NativeMatchingCheckpoint, ): Promise => { - const address = parseNativeAddress(row.address), + const address = row.address, detail = facts.details.get(address), symbols = facts.symbols.get(address); // Bound expansion before creating per-occurrence sets, arrays and subsequent @@ -250,9 +304,7 @@ export const readFunctionInventory = async ( throw new EvidenceIntegrityError( "Expected artifact-bound list_procedures Evidence", ); - const rows = nativeInventoryRowsSchemas.names.parse( - inventory.normalized_result, - ); + const rows = sourcePayload(matchingRowsSchema, inventory.normalized_result); const facts: Facts = { details: new Map(), symbols: new Map(), diff --git a/src/domain/native/nativeAddress.ts b/src/domain/native/nativeAddress.ts index f15d53b0c7..969ce49cb7 100644 --- a/src/domain/native/nativeAddress.ts +++ b/src/domain/native/nativeAddress.ts @@ -18,6 +18,3 @@ export const nativeAddressInputSchema = z .replace(/%[0-9a-fA-F]{2}/gu, (escape) => escape.toUpperCase()); return `${space}0x${BigInt(address.slice(separator)).toString(16)}`; }); - -export const parseNativeAddress = (input: unknown): string => - nativeAddressInputSchema.parse(input); diff --git a/src/server/registerNativeFunctionMatchingTool.ts b/src/server/registerNativeFunctionMatchingTool.ts index 9d27ed723b..128f885688 100644 --- a/src/server/registerNativeFunctionMatchingTool.ts +++ b/src/server/registerNativeFunctionMatchingTool.ts @@ -7,9 +7,7 @@ import { withAdmittedAnalysis } from "./analysisAdmission.js"; import { createEvidenceToolHandler } from "./contractToolHandler.js"; import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; import { recordSessionEvidenceSources } from "./sessionEvidence.js"; -import { matchNativeFunctionsInputSchema } from "../contracts/native/functionMatchingContracts.js"; -import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; -import { err, ok } from "../domain/result.js"; +import { ok } from "../domain/result.js"; import type { Logger } from "pino"; /** Bind one named contract to the same retained-Evidence workflow used by CLI. */ @@ -26,27 +24,14 @@ export const registerNativeFunctionMatchingTool = ( const handler = createEvidenceToolHandler( server, async (input, options) => { - const parsed = matchNativeFunctionsInputSchema.safeParse(input); - if (!parsed.success) - return err( - analysisInputErrorFromIssues( - contract.name, - parsed.error.issues, - input, - ), - ); - const result = await matchNativeFunctions( - parsed.data, - options, - evidenceById, - ); + const result = await matchNativeFunctions(input, options, evidenceById); if (!result.ok) return result; const recorded = recordSessionEvidenceSources( recordEvidence, result.value.sources, ); if (!recorded.ok) return recorded; - return parsed.data.detail === "summary" + return result.value.detail === "summary" ? summarizeRetainedAnalysis(result.value.evidence, { evidenceById, recordEvidence, diff --git a/tests/acceptance/tools/nativeFunctionMatching.test.ts b/tests/acceptance/tools/nativeFunctionMatching.test.ts index dbab41cbd8..1ba643792d 100644 --- a/tests/acceptance/tools/nativeFunctionMatching.test.ts +++ b/tests/acceptance/tools/nativeFunctionMatching.test.ts @@ -4,12 +4,15 @@ import { expect } from "vitest"; import { cliTest, type TestCli } from "../../support/cli/cliFixture.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { nativeMatchingInventory, nativeMatchingDetails, + nativeMatchingTarget, } from "../../fixtures/nativeFunctionMatching.js"; import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; import { nativeMatchingProvider } from "../../fixtures/nativeFunctionMatching.js"; +import { procedureInfoSchema } from "../../../src/domain/native/procedureInfo.js"; import { createEvidenceBundle, parseEvidenceBundle, @@ -176,12 +179,15 @@ const verifyPortableCli = async ( }); }; -cliTest( - "shared public CLI/MCP matching retains sources, pages exact facts, and feeds selected dossier pairs directly to compare_functions", - async ({ cli }) => { +cliTest.for([{ profile: "full" }, { profile: "compact" }] as const)( + "shared public CLI/MCP matching retains sources, pages exact facts, and feeds selected dossier pairs directly to compare_functions ($profile)", + { timeout: 60000 }, + async ({ profile }, { cli }) => { const fixture = await createMatchingFixture(); const { left, right, leftDetails, rightDetails, importedPath } = fixture; - const { call } = await connectLocalToolsMcp(); + const { call } = await connectLocalToolsMcp({ + REA_MCP_INPUT_SCHEMA_PROFILE: profile, + }); const imported = await call("import_evidence_bundle", { path: importedPath, }); @@ -272,5 +278,246 @@ cliTest( }); expect(missing.isError).toBe(true); }, +); + +cliTest( + "native matching rejects corrupt and misselected MCP sources without retaining them", + async () => { + const fixture = await createMatchingFixture(); + const { left, right, leftDetails, root, importedPath } = fixture; + const { call } = await connectLocalToolsMcp(); + const importedSources = await call("import_evidence_bundle", { + path: importedPath, + }); + expect(importedSources.isError, JSON.stringify(importedSources)).not.toBe( + true, + ); + const before = await call("get_evidence_bundle", {}); + const corruptEnvelope = { ...left, normalized_result: [] }; + const badCoordinate = nativeMatchingInventory("1", [ + { address: "not-an-address", value: "main" }, + ]); + const observationResult = procedureInfoSchema.parse( + nativeMatchingDetails("1", "0x10", "c3")[0]!.normalized_result, + ); + const corruptObservation = createEvidence( + nativeMatchingTarget("1"), + nativeMatchingProvider, + { + operation: "procedure_info", + parameters: { procedure: "0x10" }, + result: { + ...observationResult, + entrypoint: "not-an-address", + }, + }, + ); + for (const { request, expected } of [ + { + request: { left: corruptEnvelope, right }, + expected: { code: "evidence_integrity_mismatch" }, + }, + { + request: { left: badCoordinate, right }, + expected: { code: "evidence_integrity_mismatch" }, + }, + { + request: { left, right, left_observations: [corruptObservation] }, + expected: { code: "evidence_integrity_mismatch" }, + }, + { + request: { left: leftDetails[0], right }, + expected: { + code: "invalid_request", + details: { + issues: [expect.objectContaining({ path: ["left"] })], + }, + }, + }, + ]) { + const rejected = await call("match_native_functions", { + ...request, + detail: "complete", + }); + expect(parseMcpToolError(rejected).error).toMatchObject(expected); + expect((await call("get_evidence_bundle", {})).structuredContent).toEqual( + before.structuredContent, + ); + } + + const corruptBundlePath = join(root, "corrupt-source.json"); + await writeFile( + corruptBundlePath, + JSON.stringify(createEvidenceBundle([badCoordinate, right])), + ); + const importedCorruptPayload = await call("import_evidence_bundle", { + path: corruptBundlePath, + }); + expect( + importedCorruptPayload.isError, + JSON.stringify(importedCorruptPayload), + ).not.toBe(true); + const retainedBefore = await call("get_evidence_bundle", {}); + const retainedFailure = await call("match_native_functions", { + left: { + kind: "retained-evidence", + evidence_id: badCoordinate.evidence_id, + }, + right: { kind: "retained-evidence", evidence_id: right.evidence_id }, + detail: "complete", + }); + expect(parseMcpToolError(retainedFailure).error).toMatchObject({ + code: "evidence_integrity_mismatch", + details: { + reason: expect.stringContaining("Evidence validation failed"), + }, + }); + expect((await call("get_evidence_bundle", {})).structuredContent).toEqual( + retainedBefore.structuredContent, + ); + + const malformedOuterRequest = await call("match_native_functions", { + right, + detail: "complete", + }); + expect(parseMcpToolError(malformedOuterRequest).error.code).toBe( + "invalid_request", + ); + expect((await call("get_evidence_bundle", {})).structuredContent).toEqual( + retainedBefore.structuredContent, + ); + }, + 60000, +); + +cliTest( + "native matching CLI inline and portable sources report the same validation errors", + async ({ cli }) => { + const fixture = await createMatchingFixture(); + const { left, right, leftDetails, root } = fixture; + const badCoordinate = nativeMatchingInventory("1", [ + { address: "not-an-address", value: "main" }, + ]); + const corruptBundlePath = join(root, "corrupt-source.json"); + await writeFile( + corruptBundlePath, + JSON.stringify(createEvidenceBundle([badCoordinate, right])), + ); + const requestPath = join(root, "wrong-role-request.json"); + const portablePath = join(root, "wrong-role-sources.json"); + const wrongRole = leftDetails[0]!; + const inlineRequest = { left: wrongRole, right, detail: "complete" }; + const portableRequest = { + left: { kind: "retained-evidence", evidence_id: wrongRole.evidence_id }, + right: { kind: "retained-evidence", evidence_id: right.evidence_id }, + detail: "complete", + }; + await writeFile(requestPath, JSON.stringify(inlineRequest)); + const inline = await cli.run({ + arguments: ["match-native-functions", requestPath, "--json"], + }); + await writeFile( + portablePath, + JSON.stringify(createEvidenceBundle([wrongRole, right])), + ); + await writeFile(requestPath, JSON.stringify(portableRequest)); + const portable = await cli.run({ + arguments: [ + "match-native-functions", + requestPath, + "--source_bundle", + portablePath, + "--json", + ], + }); + expect(inline.exitCode).not.toBe(0); + expect(portable.exitCode).toBe(inline.exitCode); + const wrongRoleError = { + code: "invalid_request", + details: { + issues: [expect.objectContaining({ path: ["left"] })], + }, + }; + expect(inline.json).toMatchObject(wrongRoleError); + expect(portable.json).toMatchObject(wrongRoleError); + + const corruptRequest = { left: badCoordinate, right, detail: "complete" }; + await writeFile(requestPath, JSON.stringify(corruptRequest)); + const corruptInline = await cli.run({ + arguments: ["match-native-functions", requestPath, "--json"], + }); + const corruptPortableRequest = { + left: { + kind: "retained-evidence", + evidence_id: badCoordinate.evidence_id, + }, + right: { kind: "retained-evidence", evidence_id: right.evidence_id }, + detail: "complete", + }; + await writeFile(requestPath, JSON.stringify(corruptPortableRequest)); + const corruptPortable = await cli.run({ + arguments: [ + "match-native-functions", + requestPath, + "--source_bundle", + corruptBundlePath, + "--json", + ], + }); + expect(corruptInline.json).toMatchObject({ + code: "evidence_integrity_mismatch", + }); + expect(corruptPortable.json).toMatchObject({ + code: "evidence_integrity_mismatch", + }); + + const malformedBundlePath = join(root, "malformed-outer-frame.json"); + await writeFile(malformedBundlePath, JSON.stringify({ records: [left] })); + const malformedBundle = await cli.run({ + arguments: [ + "match-native-functions", + requestPath, + "--source_bundle", + malformedBundlePath, + "--json", + ], + }); + expect(malformedBundle.exitCode).not.toBe(0); + expect(malformedBundle.json).toMatchObject({ + code: "evidence_integrity_mismatch", + }); + }, + 60000, +); + +cliTest( + "native matching admits the request representation before authenticating observation carriers", + async () => { + const { call } = await connectLocalToolsMcp(); + const left = nativeMatchingInventory("1", [ + { address: "0x10", value: "main" }, + ]); + const right = nativeMatchingInventory("2", [ + { address: "0x20", value: "main" }, + ]); + const before = await call("get_evidence_bundle", {}); + const observations = Array.from({ length: 524288 }, () => ({})); + const response = await call("match_native_functions", { + left, + right, + left_observations: observations, + detail: "complete", + }); + expect(parseMcpToolError(response).error).toMatchObject({ + code: "resource_constraint", + details: { + operation: "match_native_functions", + resource: "capacity", + }, + }); + expect((await call("get_evidence_bundle", {})).structuredContent).toEqual( + before.structuredContent, + ); + }, 60000, ); diff --git a/tests/process-global/derivedOperationFailures.test.ts b/tests/process-global/derivedOperationFailures.test.ts index 482013fdae..ea7229c935 100644 --- a/tests/process-global/derivedOperationFailures.test.ts +++ b/tests/process-global/derivedOperationFailures.test.ts @@ -40,6 +40,13 @@ import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../../src/contracts/jav import { javascriptApplicationAnalysisResultSchema } from "../../src/domain/javascript/javascriptApplicationAnalysis.js"; import { javaScriptSemanticQueryResultSchema } from "../../src/domain/javascript/javascriptSemanticQuerySchemas.js"; +import { nativeFunctionMatchingSchema } from "../../src/domain/native/functionMatching.js"; +import { FunctionByteCoverage } from "../../src/domain/native/functionByteCoverage.js"; +import { + nativeMatchingInventory, + nativeMatchingDetails, +} from "../fixtures/nativeFunctionMatching.js"; + /** Unexpected implementation failures retain the SDK text surface, without a typed caller error. */ const expectUnexpectedToolFailure = ( response: Awaited>, @@ -509,3 +516,56 @@ describe("semantic query output failures through the public SDK", () => { }, ); }); + +describe("native matching failure ownership through the public SDK", () => { + afterEach(() => vi.restoreAllMocks()); + + const matchingInput = () => ({ + left: nativeMatchingInventory("1", [{ address: "0x10", value: "main" }]), + right: nativeMatchingInventory("2", [{ address: "0x20", value: "main" }]), + left_observations: nativeMatchingDetails("1", "0x10", "c3"), + right_observations: nativeMatchingDetails("2", "0x20", "c3"), + detail: "complete", + }); + + it.each(unexpectedResultFailures)( + "preserves an unexpected %s from native matching result validation", + async (_label, createFailure) => { + await withSdk(async ({ client, session }) => { + const input = matchingInput(); + const failure = createFailure(); + vi.spyOn(nativeFunctionMatchingSchema, "parse").mockImplementation( + () => { + throw failure; + }, + ); + const response = await client.callTool({ + name: "match_native_functions", + arguments: input, + }); + expectUnexpectedToolFailure(response, failure); + expect(session.exportEvidenceBundle().records).toEqual([]); + }); + }, + ); + + it.each(unexpectedResultFailures)( + "preserves an unexpected %s from native byte fingerprint derivation", + async (_label, createFailure) => { + await withSdk(async ({ client, session }) => { + const input = matchingInput(); + const failure = createFailure(); + vi.spyOn( + FunctionByteCoverage.prototype, + "fingerprint", + ).mockRejectedValue(failure); + const response = await client.callTool({ + name: "match_native_functions", + arguments: input, + }); + expectUnexpectedToolFailure(response, failure); + expect(session.exportEvidenceBundle().records).toEqual([]); + }); + }, + ); +});