diff --git a/docs/native-function-matching.md b/docs/native-function-matching.md index d393c0dd53..5744398758 100644 --- a/docs/native-function-matching.md +++ b/docs/native-function-matching.md @@ -128,3 +128,5 @@ It includes unchanged/changed code, a byte-identical rename, a removed/added observation, an inlined function, identical-code aliases and a renamed changed function with an observed call/string signature. Analyze without executing the fixture. Full-provider verification still needs caller-supplied Ghidra and Java. + +Malformed inline Evidence is rejected after admission with its selected input field and observation index. A structurally valid record whose authenticated contents or digest disagree remains an integrity failure; changing input diagnostics does not make that record trustworthy. diff --git a/src/application/native/NativeMatchingBundleSources.ts b/src/application/native/NativeMatchingBundleSources.ts index 2243cc4e98..9af839d3ad 100644 --- a/src/application/native/NativeMatchingBundleSources.ts +++ b/src/application/native/NativeMatchingBundleSources.ts @@ -14,6 +14,8 @@ import { readImmutableEvidenceBundle } from "../EvidenceBundleFiles.js"; import type { EvidenceLookup } from "../EvidenceInputResolver.js"; import type { NativeMatchingAdmission } from "./NativeMatchingAdmission.js"; +import { nativeMatchingInputError } from "./NativeMatchingInputError.js"; + interface SourceEntry { readonly file: number; readonly links: readonly string[]; @@ -61,18 +63,32 @@ export const loadNativeMatchingBundleSources = async ({ const records = new Map(), index = new Map(); const rootIds: string[] = []; - for (const value of [ - input.left, - input.right, - ...input.left_observations, - ...input.right_observations, - ]) { + const selected: readonly (readonly [ + unknown, + readonly (string | number)[], + ])[] = [ + [input.left, ["left"]], + [input.right, ["right"]], + ...input.left_observations.map( + (value, index) => [value, ["left_observations", index]] as const, + ), + ...input.right_observations.map( + (value, index) => [value, ["right_observations", index]] as const, + ), + ]; + for (const [value, path] of selected) { const parsed = retainedEvidenceReferenceSchema.safeParse(value); if (parsed.success) rootIds.push(parsed.data.evidence_id); else { await admission.admit(value); const checked = readImmutableEvidence(value); - if (!checked.ok) throw checked.error; + if (!checked.ok) + throw nativeMatchingInputError( + value, + path, + admission.operation, + checked.error, + ); const record = checked.value; rootIds.push(record.evidence_id); records.set(record.evidence_id, record); diff --git a/src/application/native/NativeMatchingInputError.ts b/src/application/native/NativeMatchingInputError.ts new file mode 100644 index 0000000000..141272b995 --- /dev/null +++ b/src/application/native/NativeMatchingInputError.ts @@ -0,0 +1,23 @@ +import { evidenceInputSchema } from "../../domain/evidenceReference.js"; +import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; +import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js"; +import { projectInputIssues } from "../../domain/inputIssueProjection.js"; + +/** Attribute malformed caller values after admission; authenticated facts keep integrity semantics. */ +export const nativeMatchingInputError = ( + input: unknown, + path: readonly (string | number)[], + operation: string, + failure: EvidenceIntegrityError, +): AnalysisInputError | EvidenceIntegrityError => { + const shape = evidenceInputSchema.safeParse(input); + if (shape.success) return failure; + return new AnalysisInputError( + operation, + { cause: failure }, + projectInputIssues(shape.error.issues, input).map((issue) => ({ + ...issue, + path: [...path, ...issue.path], + })), + ); +}; diff --git a/src/application/native/NativeMatchingSources.ts b/src/application/native/NativeMatchingSources.ts index edb89e524b..f632a6075f 100644 --- a/src/application/native/NativeMatchingSources.ts +++ b/src/application/native/NativeMatchingSources.ts @@ -10,6 +10,8 @@ import { } from "../EvidenceInputResolver.js"; import type { NativeMatchingAdmission } from "./NativeMatchingAdmission.js"; +import { nativeMatchingInputError } from "./NativeMatchingInputError.js"; + const INVENTORY_OPERATIONS = ["list_procedures"] as const; const OBSERVATION_OPERATIONS = [ "list_names", @@ -32,7 +34,10 @@ export const resolveNativeMatchingSources = async ( readonly sources: readonly Evidence[]; }> => { const sources = new Map(); - const resolve = async (input: unknown): Promise => { + const resolve = async ( + input: unknown, + path?: readonly (string | number)[], + ): Promise => { const reference = retainedEvidenceReferenceSchema.safeParse(input); let raw = input; if (reference.success) { @@ -42,7 +47,15 @@ export const resolveNativeMatchingSources = async ( } await admission.admit(raw); const checked = readEvidence(raw); - if (!checked.ok) throw checked.error; + if (!checked.ok) + throw path !== undefined && !reference.success + ? nativeMatchingInputError( + input, + path, + admission.operation, + checked.error, + ) + : checked.error; const evidence = checked.value; const cached = sources.get(evidence.evidence_id); if (cached !== undefined) return cached; @@ -54,7 +67,7 @@ export const resolveNativeMatchingSources = async ( path: readonly (string | number)[], operations: readonly string[], ): Promise => { - const evidence = await resolve(raw); + const evidence = await resolve(raw, path); if ( evidence.predicate_type !== "rea.analysis" || !operations.includes(evidence.operation) || diff --git a/tests/acceptance/tools/nativeFunctionMatching.test.ts b/tests/acceptance/tools/nativeFunctionMatching.test.ts index 1ba643792d..fc2c0bf5fa 100644 --- a/tests/acceptance/tools/nativeFunctionMatching.test.ts +++ b/tests/acceptance/tools/nativeFunctionMatching.test.ts @@ -390,6 +390,76 @@ cliTest( 60000, ); +cliTest( + "native matching retains malformed inline field paths through MCP and CLI source admission", + async ({ cli }) => { + const left = nativeMatchingInventory("1", [ + { address: "0x10", value: "main" }, + ]); + const right = nativeMatchingInventory("2", [ + { address: "0x20", value: "main" }, + ]); + const root = await createTestTempDirectory("rea-matching-input-"); + const bundlePath = join(root, "sources.json"); + await writeFile( + bundlePath, + JSON.stringify(createEvidenceBundle([left, right])), + ); + const { call } = await connectLocalToolsMcp(); + for (const field of [ + "left", + "right", + "left_observations", + "right_observations", + ] as const) { + const observation = field.endsWith("observations"); + const request = { + left, + right, + detail: "complete", + [field]: observation ? ["not-evidence"] : "not-evidence", + }; + const expected = { + code: "invalid_request", + details: { + issues: [ + expect.objectContaining({ + path: observation ? [field, 0] : [field], + reason: "invalid_type", + }), + ], + }, + }; + expect( + parseMcpToolError(await call("match_native_functions", request)).error, + ).toMatchObject(expected); + const requestPath = join(root, "request.json"); + await writeFile(requestPath, JSON.stringify(request)); + for (const sourceArguments of [[], ["--source_bundle", bundlePath]]) { + const rejected = await cli.run({ + arguments: [ + "match-native-functions", + requestPath, + ...sourceArguments, + "--json", + ], + }); + expect(rejected.exitCode).not.toBe(0); + expect(rejected.json).toMatchObject(expected); + } + } + const corrected = await call("match_native_functions", { + left, + right, + detail: "complete", + }); + expect(corrected.isError).not.toBe(true); + expect( + parseEvidence(corrected.structuredContent).normalized_result, + ).toMatchObject({ summary: { matched: 1 } }); + }, +); + cliTest( "native matching CLI inline and portable sources report the same validation errors", async ({ cli }) => {