From 53363166f068a21f00a605187e1c3af428aa7d5d Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:27:34 +0800 Subject: [PATCH] fix(evidence)!: validate native view parent predicates (#8451) Reject native view sources whose predicate disagrees with the selected operation. Use the original predicate for existing Evidence; do not alter a record without recomputing its identity. --- docs/mcp-contracts.md | 6 +- .../analysisView/AnalysisViewService.ts | 1 + .../analysisView/BrowserScenarioViews.test.ts | 1 + .../analysisView/NativeLoadImageViews.test.ts | 1 + .../native/NativeFunctionMatchingService.ts | 3 +- .../NativeFunctionMatchingViews.test.ts | 1 + .../native/NativeMatchingSources.ts | 8 +- src/contracts/analysisDetail.test.ts | 3 + src/domain/analysisView/analysisView.test.ts | 5 + src/domain/analysisView/analysisView.ts | 34 ++- .../analysisView/analysisViewParent.test.ts | 1 + src/domain/analysisView/analysisViewParent.ts | 1 + .../analysisView/androidPackageView.test.ts | 1 + .../analysisView/androidSymbolView.test.ts | 1 + .../artifactInventoryView.test.ts | 1 + .../environmentCoverageView.test.ts | 1 + .../environmentManifestView.test.ts | 3 + .../analysisView/environmentStateView.test.ts | 1 + src/domain/analysisView/goBinaryView.test.ts | 1 + .../analysisView/gpuCodeObjectsView.test.ts | 1 + .../analysisView/il2cppMetadataView.test.ts | 1 + .../analysisView/interfaceBuilderView.test.ts | 1 + .../analysisView/javascriptRoutesView.test.ts | 1 + src/domain/analysisView/machoView.test.ts | 1 + .../analysisView/managedMemberView.test.ts | 1 + .../analysisView/nativeInventoryView.test.ts | 1 + .../analysisView/pcapCaptureView.test.ts | 1 + .../analysisView/protobufCaptureView.test.ts | 1 + .../analysisView/rustBinaryView.test.ts | 1 + .../scenarioCaptureRecords.test.ts | 2 + src/domain/analysisView/webBundleView.test.ts | 3 + .../webNetworkApiSurfaceView.test.ts | 4 + .../webNetworkCaptureView.test.ts | 3 + src/domain/evidence.ts | 5 +- src/domain/functionDossierEvidence.ts | 9 +- src/domain/native/functionMatching.ts | 3 + src/domain/webBundleAnalyzer.test.ts | 1 + src/native/exports/NativeExportsView.test.ts | 1 + .../apkSignatureViewIdentity.test.ts | 1 + .../mcp/nativeViewPredicateMcp.test.ts | 200 ++++++++++++++++++ tests/fixtures/environmentStateWrites.ts | 1 + 41 files changed, 307 insertions(+), 10 deletions(-) create mode 100644 tests/boundary/mcp/nativeViewPredicateMcp.test.ts diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 281a9f95c9..66f1311869 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -529,7 +529,11 @@ names, the native API record, and high-pcode facets without starting a provider. and limit default to 0 and 64; pseudocode uses UTF-16 code units and never splits surrogate pairs, while other pages count rows. Unavailable facts and provider limitations remain explicit. Source is -an exact same-session retained reference or portable inline Evidence. Views are +an exact same-session retained reference, portable inline Evidence, or an +Evidence record or bundle file. Native function, inventory, and observation +views require the `rea.analysis` predicate; function-matching views require +`rea.native-function-matching`. A mismatched predicate returns an +`invalid_request` identifying `source.predicate_type`. Views are a summary, a layout mitigations or linkage facet, one section/symbol/module, or a stable page with a caller-selected positive `limit`. Module pages include JavaScript assets, bundled modules, and source modules. Select an exact diff --git a/src/application/analysisView/AnalysisViewService.ts b/src/application/analysisView/AnalysisViewService.ts index 93e4f9f0b1..ac26cbd160 100644 --- a/src/application/analysisView/AnalysisViewService.ts +++ b/src/application/analysisView/AnalysisViewService.ts @@ -225,6 +225,7 @@ const projectResolvedAnalysisView = ( const projected = projection.project({ evidenceId: parent.evidence_id, operation: parent.operation, + predicateType: parent.predicate_type, provider: parent.provider, analysisProfile: parent.analysis_profile, authority: parent.authority, diff --git a/src/application/analysisView/BrowserScenarioViews.test.ts b/src/application/analysisView/BrowserScenarioViews.test.ts index 79bfd43fb1..bb48a39940 100644 --- a/src/application/analysisView/BrowserScenarioViews.test.ts +++ b/src/application/analysisView/BrowserScenarioViews.test.ts @@ -436,6 +436,7 @@ describe("browser scenario network observation", () => { const project = (capture: unknown) => { const result = projectBrowserScenarioView( { + predicateType: "rea.browser-scenario-capture", evidenceId: `ev:${"a".repeat(64)}`, operation: "capture_browser_scenario", normalizedResult: capture, diff --git a/src/application/analysisView/NativeLoadImageViews.test.ts b/src/application/analysisView/NativeLoadImageViews.test.ts index fe99d3f719..4c5b9565a1 100644 --- a/src/application/analysisView/NativeLoadImageViews.test.ts +++ b/src/application/analysisView/NativeLoadImageViews.test.ts @@ -302,6 +302,7 @@ it("rejects collection-inapplicable fields and malformed sibling facts even for ).toBe(false); const report = source(); const parent = { + predicateType: "rea.analysis", evidenceId: "ev_" + "b".repeat(64), operation: "inspect_native_load_image", normalizedResult: report, diff --git a/src/application/native/NativeFunctionMatchingService.ts b/src/application/native/NativeFunctionMatchingService.ts index d7544ba2b8..02e75920cf 100644 --- a/src/application/native/NativeFunctionMatchingService.ts +++ b/src/application/native/NativeFunctionMatchingService.ts @@ -20,6 +20,7 @@ import { resolveNativeMatchingSources } from "./NativeMatchingSources.js"; import type { ExecutionOptions } from "../AnalysisProvider.js"; import type { NativeMatchingAllowance } from "../../config/nativeMatchingAllowance.js"; import { FUNCTION_COMPARISON_PROVIDER } from "../../domain/comparisonEvidenceIdentity.js"; +import { NATIVE_FUNCTION_MATCHING_PREDICATE } from "../../domain/native/functionMatching.js"; const OPERATION = "match_native_functions"; @@ -144,7 +145,7 @@ const matchWithAdmission = async ( await checkpoint(); const evidence = createEvidence(undefined, FUNCTION_COMPARISON_PROVIDER, { operation: OPERATION, - predicateType: "rea.native-function-matching", + predicateType: NATIVE_FUNCTION_MATCHING_PREDICATE, confidence: "derived", authority: "analyst-inference", parameters: { diff --git a/src/application/native/NativeFunctionMatchingViews.test.ts b/src/application/native/NativeFunctionMatchingViews.test.ts index c577b9521e..b80dc719ad 100644 --- a/src/application/native/NativeFunctionMatchingViews.test.ts +++ b/src/application/native/NativeFunctionMatchingViews.test.ts @@ -119,6 +119,7 @@ it("rebuilds native row selection when a caller's sealed flag covers only a shal ); Object.freeze(report); const parent = { + predicateType: matched.value.evidence.predicate_type, evidenceId: matched.value.evidence.evidence_id, operation: matched.value.evidence.operation, normalizedResult: report, diff --git a/src/application/native/NativeMatchingSources.ts b/src/application/native/NativeMatchingSources.ts index 3ad34d8bbd..e8950fd914 100644 --- a/src/application/native/NativeMatchingSources.ts +++ b/src/application/native/NativeMatchingSources.ts @@ -1,7 +1,11 @@ import type { MatchNativeFunctionsInput } from "../../contracts/native/functionMatchingContracts.js"; import { digestCanonicalValue } from "../../domain/canonicalDigest.js"; import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; -import { isImmutableEvidence, type Evidence } from "../../domain/evidence.js"; +import { + ANALYSIS_EVIDENCE_PREDICATE, + isImmutableEvidence, + type Evidence, +} from "../../domain/evidence.js"; import { readEvidence } from "../../domain/evidenceValidation.js"; import { retainedEvidenceReferenceSchema } from "../../domain/evidenceReference.js"; import { @@ -82,7 +86,7 @@ export const resolveNativeMatchingSources = async ( ): Promise => { const evidence = await resolve(raw, path, borrowable); if ( - evidence.predicate_type !== "rea.analysis" || + evidence.predicate_type !== ANALYSIS_EVIDENCE_PREDICATE || !operations.includes(evidence.operation) || evidence.subject === null ) { diff --git a/src/contracts/analysisDetail.test.ts b/src/contracts/analysisDetail.test.ts index 18292692b0..6922350ef7 100644 --- a/src/contracts/analysisDetail.test.ts +++ b/src/contracts/analysisDetail.test.ts @@ -18,12 +18,14 @@ import { toolContract } from "./toolContracts.js"; const projectSummary = (parent: { readonly evidence_id: string; + readonly predicate_type: string; readonly operation: string; readonly normalized_result: unknown; readonly limitations: readonly string[]; }) => projectAnalysisView( { + predicateType: parent.predicate_type, evidenceId: parent.evidence_id, operation: parent.operation, normalizedResult: parent.normalized_result, @@ -74,6 +76,7 @@ it("advertises only that operation's summary from analysis detail outputs", () = ); const parent = analysisViewCaptureEvidence(environmentStateCaptureFixture()); const captureParent = { + predicateType: parent.predicate_type, evidenceId: parent.evidence_id, operation: parent.operation, normalizedResult: parent.normalized_result, diff --git a/src/domain/analysisView/analysisView.test.ts b/src/domain/analysisView/analysisView.test.ts index a26e5658fd..a21fa68919 100644 --- a/src/domain/analysisView/analysisView.test.ts +++ b/src/domain/analysisView/analysisView.test.ts @@ -38,6 +38,7 @@ import { const layoutParent = (): AnalysisViewParent => { const evidence = analysisViewLayoutEvidence(); return { + predicateType: evidence.predicate_type, evidenceId: evidence.evidence_id, operation: evidence.operation, normalizedResult: evidence.normalized_result, @@ -48,6 +49,7 @@ const layoutParent = (): AnalysisViewParent => { const javascriptParent = ( analysis = analysisViewJavaScriptAnalysis(), ): AnalysisViewParent => ({ + predicateType: JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE.predicate_type, evidenceId: JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE.evidence_id, operation: "analyze_javascript_application", normalizedResult: analysis, @@ -57,6 +59,7 @@ const javascriptParent = ( const nativeParent = ( result: unknown = ghidraFunctionDossier(), ): AnalysisViewParent => ({ + predicateType: "rea.analysis", evidenceId: "ev_" + "b".repeat(64), operation: "analyze_function", normalizedResult: result, @@ -363,6 +366,7 @@ it("rejects artifact summary views when inspection substep coverage count disagr const source = ARTIFACT_COMPARISON_EXAMPLE.left; const inspection = createArtifactInspection(source); const parent: AnalysisViewParent = { + predicateType: "rea.analysis", evidenceId: `ev_${"a".repeat(64)}`, operation: "inspect_artifact", normalizedResult: { @@ -1577,6 +1581,7 @@ it("rejects unsupported parent operations and incompatible collections", () => { ).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } }); const unsupported = projectAnalysisView( { + predicateType: "rea.analysis", evidenceId: `ev_${"d".repeat(64)}`, operation: "inspect_recorded_crash", normalizedResult: { path: "/artifacts/crash.core" }, diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index 7b278e1184..d8219a65ea 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -1,4 +1,7 @@ -import { NATIVE_OBSERVATION_OPERATIONS } from "../native/nativeObservation.js"; +import { + isNativeObservationOperation, + NATIVE_OBSERVATION_OPERATIONS, +} from "../native/nativeObservation.js"; import { projectNativeObservationView } from "./nativeObservationView.js"; import { projectDevicetreeView } from "./devicetreeView.js"; import { projectApkSignatureView } from "./apkSignatureView.js"; @@ -22,7 +25,12 @@ import { projectGoBinaryView } from "./goBinaryView.js"; import { projectRustBinaryView } from "./rustBinaryView.js"; import { projectNativeFunctionMatchingView } from "./nativeFunctionMatchingView.js"; import { projectNativeInventoryView } from "./nativeInventoryView.js"; -import { NATIVE_INVENTORY_OPERATIONS } from "../native/nativeInventory.js"; +import { + isNativeInventoryOperation, + NATIVE_INVENTORY_OPERATIONS, +} from "../native/nativeInventory.js"; +import { ANALYSIS_EVIDENCE_PREDICATE } from "../evidence.js"; +import { NATIVE_FUNCTION_MATCHING_PREDICATE } from "../native/functionMatching.js"; import { z } from "zod"; import { projectBrowserScenarioView } from "./browserScenarioView.js"; import { projectNativeLoadImageView } from "./nativeLoadImageView.js"; @@ -414,6 +422,28 @@ export const projectAnalysisView = ( }, ]), ); + const expectedPredicate = + parent.operation === "match_native_functions" + ? NATIVE_FUNCTION_MATCHING_PREDICATE + : parent.operation === "analyze_function" || + isNativeObservationOperation(parent.operation) || + isNativeInventoryOperation(parent.operation) + ? ANALYSIS_EVIDENCE_PREDICATE + : undefined; + if ( + expectedPredicate !== undefined && + parent.predicateType !== expectedPredicate + ) + return err( + analysisViewInputError([ + { + path: ["source", "predicate_type"], + reason: "invalid_value", + expected: expectedPredicate, + message: `${parent.operation} requires ${expectedPredicate} Evidence; received ${parent.predicateType}.`, + }, + ]), + ); if (view.kind === "javascript-routes") { const projected = projectJavaScriptRoutesView(parent, view); return sealProjected(projected); diff --git a/src/domain/analysisView/analysisViewParent.test.ts b/src/domain/analysisView/analysisViewParent.test.ts index 93f56b57db..b0d037f225 100644 --- a/src/domain/analysisView/analysisViewParent.test.ts +++ b/src/domain/analysisView/analysisViewParent.test.ts @@ -8,6 +8,7 @@ import type { AnalysisViewParent } from "./analysisView.js"; import { createAnalysisViewResultReader } from "./analysisViewParent.js"; const parent = (normalizedResult: unknown): AnalysisViewParent => ({ + predicateType: "rea.analysis", evidenceId: `ev_${"a".repeat(64)}`, operation: "inspect_python_pyc", normalizedResult, diff --git a/src/domain/analysisView/analysisViewParent.ts b/src/domain/analysisView/analysisViewParent.ts index 4f1698ccd1..9de5108195 100644 --- a/src/domain/analysisView/analysisViewParent.ts +++ b/src/domain/analysisView/analysisViewParent.ts @@ -10,6 +10,7 @@ import { ownedJsonValueSchema } from "../jsonValue.js"; export interface AnalysisViewParent { readonly evidenceId: string; readonly operation: string; + readonly predicateType: string; readonly normalizedResult: unknown; readonly provider?: Evidence["provider"]; readonly analysisProfile?: Evidence["analysis_profile"]; diff --git a/src/domain/analysisView/androidPackageView.test.ts b/src/domain/analysisView/androidPackageView.test.ts index c97b866966..bbb7cac2cf 100644 --- a/src/domain/analysisView/androidPackageView.test.ts +++ b/src/domain/analysisView/androidPackageView.test.ts @@ -90,6 +90,7 @@ const parent = (value = report()): AnalysisViewParent => { }, ); return { + predicateType: evidence.predicate_type, evidenceId: evidence.evidence_id, operation: evidence.operation, normalizedResult: value, diff --git a/src/domain/analysisView/androidSymbolView.test.ts b/src/domain/analysisView/androidSymbolView.test.ts index 72c38013f0..c0cbe5f530 100644 --- a/src/domain/analysisView/androidSymbolView.test.ts +++ b/src/domain/analysisView/androidSymbolView.test.ts @@ -166,6 +166,7 @@ const fixture = (operation: Operation, report: unknown, rawMetadata = "") => { { immutable: true }, ); const parent: AnalysisViewParent = { + predicateType: evidence.predicate_type, evidenceId: evidence.evidence_id, operation, normalizedResult: diff --git a/src/domain/analysisView/artifactInventoryView.test.ts b/src/domain/analysisView/artifactInventoryView.test.ts index 4323daebd5..1eba5cfe22 100644 --- a/src/domain/analysisView/artifactInventoryView.test.ts +++ b/src/domain/analysisView/artifactInventoryView.test.ts @@ -84,6 +84,7 @@ const freshInventory = () => { return inventory; }; const parent = (normalizedResult: unknown): AnalysisViewParent => ({ + predicateType: carrier.predicate_type, evidenceId: carrier.evidence_id, operation: "inventory_artifact", subject: diff --git a/src/domain/analysisView/environmentCoverageView.test.ts b/src/domain/analysisView/environmentCoverageView.test.ts index 49478d7321..99223be011 100644 --- a/src/domain/analysisView/environmentCoverageView.test.ts +++ b/src/domain/analysisView/environmentCoverageView.test.ts @@ -35,6 +35,7 @@ const parentOf = ( environmentStateCaptureFixture(), ), ): AnalysisViewParent => ({ + predicateType: evidence.predicate_type, evidenceId: evidence.evidence_id, operation: evidence.operation, normalizedResult: evidence.normalized_result, diff --git a/src/domain/analysisView/environmentManifestView.test.ts b/src/domain/analysisView/environmentManifestView.test.ts index fdbdbbe93b..f14d9b2f38 100644 --- a/src/domain/analysisView/environmentManifestView.test.ts +++ b/src/domain/analysisView/environmentManifestView.test.ts @@ -39,6 +39,7 @@ const parentOf = ( ) => { const evidence = analysisViewCaptureEvidence(capture); return { + predicateType: evidence.predicate_type, evidenceId: evidence.evidence_id, operation: evidence.operation, normalizedResult: evidence.normalized_result, @@ -854,6 +855,7 @@ it("refuses non-HAR formats, sensitive parents, unsupported parents and malforme expect( projectAnalysisView( { + predicateType: layout.predicate_type, evidenceId: layout.evidence_id, operation: layout.operation, normalizedResult: layout.normalized_result, @@ -871,6 +873,7 @@ it("refuses non-HAR formats, sensitive parents, unsupported parents and malforme expect( projectAnalysisView( { + predicateType: "rea.analysis", evidenceId: `ev_${"a".repeat(64)}`, operation: "inspect_web_network_capture", normalizedResult, diff --git a/src/domain/analysisView/environmentStateView.test.ts b/src/domain/analysisView/environmentStateView.test.ts index 498d831ca3..80d18e6eff 100644 --- a/src/domain/analysisView/environmentStateView.test.ts +++ b/src/domain/analysisView/environmentStateView.test.ts @@ -57,6 +57,7 @@ const parentOf = ( environmentStateCaptureFixture(), ), ): AnalysisViewParent => ({ + predicateType: evidence.predicate_type, evidenceId: evidence.evidence_id, operation: evidence.operation, normalizedResult: evidence.normalized_result, diff --git a/src/domain/analysisView/goBinaryView.test.ts b/src/domain/analysisView/goBinaryView.test.ts index 807b10f14d..a82c68f6ca 100644 --- a/src/domain/analysisView/goBinaryView.test.ts +++ b/src/domain/analysisView/goBinaryView.test.ts @@ -38,6 +38,7 @@ const parent = (): AnalysisViewParent => { }, ); return { + predicateType: evidence.predicate_type, evidenceId: evidence.evidence_id, operation: evidence.operation, subject: evidence.subject, diff --git a/src/domain/analysisView/gpuCodeObjectsView.test.ts b/src/domain/analysisView/gpuCodeObjectsView.test.ts index 8bc5b9e8ae..a800cee49c 100644 --- a/src/domain/analysisView/gpuCodeObjectsView.test.ts +++ b/src/domain/analysisView/gpuCodeObjectsView.test.ts @@ -32,6 +32,7 @@ const report = () => }); const parent = (normalizedResult: unknown): AnalysisViewParent => ({ + predicateType: "rea.analysis", evidenceId: `ev_${"b".repeat(64)}`, operation: "inspect_gpu_code_objects", normalizedResult, diff --git a/src/domain/analysisView/il2cppMetadataView.test.ts b/src/domain/analysisView/il2cppMetadataView.test.ts index 9aa9a4b9e4..23e27840fc 100644 --- a/src/domain/analysisView/il2cppMetadataView.test.ts +++ b/src/domain/analysisView/il2cppMetadataView.test.ts @@ -67,6 +67,7 @@ const report: Il2cppMetadata = { }; const parent = (normalizedResult: unknown, sealedResult: boolean) => ({ + predicateType: "rea.analysis", evidenceId: `ev_${"b".repeat(64)}`, operation: "inspect_il2cpp_metadata", normalizedResult, diff --git a/src/domain/analysisView/interfaceBuilderView.test.ts b/src/domain/analysisView/interfaceBuilderView.test.ts index 9ec2dd0bfe..9c9921be59 100644 --- a/src/domain/analysisView/interfaceBuilderView.test.ts +++ b/src/domain/analysisView/interfaceBuilderView.test.ts @@ -91,6 +91,7 @@ const parent = ( normalizedResult: unknown = report(), sha256 = target, ): AnalysisViewParent => ({ + predicateType: "rea.analysis", evidenceId: `ev_${"c".repeat(64)}`, operation: "decode_interface_builder", normalizedResult, diff --git a/src/domain/analysisView/javascriptRoutesView.test.ts b/src/domain/analysisView/javascriptRoutesView.test.ts index 68e542a7a0..7b09e25b82 100644 --- a/src/domain/analysisView/javascriptRoutesView.test.ts +++ b/src/domain/analysisView/javascriptRoutesView.test.ts @@ -28,6 +28,7 @@ const inventory = ( ]), ); const parent = (): AnalysisViewParent => ({ + predicateType: "rea.javascript-application-analysis", evidenceId: `ev_${"b".repeat(64)}`, operation: "analyze_javascript_application", limitations: [], diff --git a/src/domain/analysisView/machoView.test.ts b/src/domain/analysisView/machoView.test.ts index 40f650a2cf..13369b8988 100644 --- a/src/domain/analysisView/machoView.test.ts +++ b/src/domain/analysisView/machoView.test.ts @@ -194,6 +194,7 @@ const parent = ( local_path: "/bin/tool", }, ): AnalysisViewParent => ({ + predicateType: "rea.analysis", evidenceId: `ev_${"c".repeat(64)}`, operation: "inspect_macho", normalizedResult, diff --git a/src/domain/analysisView/managedMemberView.test.ts b/src/domain/analysisView/managedMemberView.test.ts index e6d8f401f4..122f27640a 100644 --- a/src/domain/analysisView/managedMemberView.test.ts +++ b/src/domain/analysisView/managedMemberView.test.ts @@ -10,6 +10,7 @@ import { import { projectManagedMemberView } from "./managedMemberView.js"; const parent = (normalizedResult: unknown): AnalysisViewParent => ({ + predicateType: "rea.analysis", evidenceId: `ev_${"a".repeat(64)}`, operation: "inspect_managed_members", normalizedResult, diff --git a/src/domain/analysisView/nativeInventoryView.test.ts b/src/domain/analysisView/nativeInventoryView.test.ts index ed946415ca..cf184fa8b9 100644 --- a/src/domain/analysisView/nativeInventoryView.test.ts +++ b/src/domain/analysisView/nativeInventoryView.test.ts @@ -19,6 +19,7 @@ const rows = () => [ { address: "0x20", value: "last" }, ]; const parent = (normalizedResult: unknown): AnalysisViewParent => ({ + predicateType: "rea.analysis", evidenceId: `ev_${"a".repeat(64)}`, operation: "search_procedures", normalizedResult, diff --git a/src/domain/analysisView/pcapCaptureView.test.ts b/src/domain/analysisView/pcapCaptureView.test.ts index 0f5cf34dda..1e16f2b02d 100644 --- a/src/domain/analysisView/pcapCaptureView.test.ts +++ b/src/domain/analysisView/pcapCaptureView.test.ts @@ -65,6 +65,7 @@ const capture = ( }, ); return { + predicateType: evidence.predicate_type, evidenceId: evidence.evidence_id, operation: evidence.operation, normalizedResult: report, diff --git a/src/domain/analysisView/protobufCaptureView.test.ts b/src/domain/analysisView/protobufCaptureView.test.ts index d0619f31b7..18d737fd1c 100644 --- a/src/domain/analysisView/protobufCaptureView.test.ts +++ b/src/domain/analysisView/protobufCaptureView.test.ts @@ -113,6 +113,7 @@ const capture = async () => { return { report, parent: { + predicateType: evidence.predicate_type, evidenceId: evidence.evidence_id, operation: evidence.operation, normalizedResult: report, diff --git a/src/domain/analysisView/rustBinaryView.test.ts b/src/domain/analysisView/rustBinaryView.test.ts index 065b86a6fd..43d7906e70 100644 --- a/src/domain/analysisView/rustBinaryView.test.ts +++ b/src/domain/analysisView/rustBinaryView.test.ts @@ -54,6 +54,7 @@ const parent = (value = structuredClone(report)): AnalysisViewParent => { }, ); return { + predicateType: evidence.predicate_type, evidenceId: evidence.evidence_id, operation: evidence.operation, subject: evidence.subject, diff --git a/src/domain/analysisView/scenarioCaptureRecords.test.ts b/src/domain/analysisView/scenarioCaptureRecords.test.ts index 6f9516cb08..25d9dfdbbf 100644 --- a/src/domain/analysisView/scenarioCaptureRecords.test.ts +++ b/src/domain/analysisView/scenarioCaptureRecords.test.ts @@ -282,6 +282,7 @@ it("reserves retained request-body expansion before schema decoding or projectio try { const projected = admitScenarioCaptureParent( { + predicateType: "rea.browser-scenario-capture", evidenceId: `ev_${"a".repeat(64)}`, operation: "capture_browser_scenario", normalizedResult: capture, @@ -330,6 +331,7 @@ it("reserves request text even when another event's body was not captured", () = // The capture's containers fit this budget; only its request text does not. const projected = admitScenarioCaptureParent( { + predicateType: "rea.browser-scenario-capture", evidenceId: `ev_${"a".repeat(64)}`, operation: "capture_browser_scenario", normalizedResult: { ...capture, events: { ...capture.events, items } }, diff --git a/src/domain/analysisView/webBundleView.test.ts b/src/domain/analysisView/webBundleView.test.ts index d146529887..c277b39683 100644 --- a/src/domain/analysisView/webBundleView.test.ts +++ b/src/domain/analysisView/webBundleView.test.ts @@ -109,6 +109,7 @@ const sampleAnalysis = (): WebBundleAnalysis => ({ const makeParent = ( overrides?: Partial, ): AnalysisViewParent => ({ + predicateType: "rea.web-bundle-analysis", evidenceId: `ev_${"c".repeat(64)}`, operation: "analyze_web_bundle", limitations: ["Parent run was bounded"], @@ -476,6 +477,7 @@ it.each(["indices", "values", "script_keys", "mechanisms"])( it("rejects non-web-bundle parent operation with invalid_value", () => { const invalidParent: AnalysisViewParent = { + predicateType: "rea.javascript-application-analysis", evidenceId: `ev_${"d".repeat(64)}`, operation: "analyze_javascript_application", limitations: [], @@ -627,6 +629,7 @@ it("correctly counts zero findings in summary for an empty web bundle", () => { it("rejects malformed parent analysis report", () => { const malformedParent: AnalysisViewParent = { + predicateType: "rea.web-bundle-analysis", evidenceId: `ev_${"e".repeat(64)}`, operation: "analyze_web_bundle", limitations: [], diff --git a/src/domain/analysisView/webNetworkApiSurfaceView.test.ts b/src/domain/analysisView/webNetworkApiSurfaceView.test.ts index d2e9662bd7..617811f81b 100644 --- a/src/domain/analysisView/webNetworkApiSurfaceView.test.ts +++ b/src/domain/analysisView/webNetworkApiSurfaceView.test.ts @@ -19,6 +19,7 @@ const project = ( const parent = analysisViewCaptureEvidence(capture); return projectAnalysisView( { + predicateType: parent.predicate_type, evidenceId: parent.evidence_id, operation: parent.operation, normalizedResult: parent.normalized_result, @@ -202,6 +203,7 @@ it("rejects native captures and malformed parents with typed errors", () => { expect( projectAnalysisView( { + predicateType: "rea.analysis", evidenceId: `ev_${"a".repeat(64)}`, operation: "inspect_web_network_capture", normalizedResult: {}, @@ -218,6 +220,7 @@ it.each(["api-surface", "environment-manifest", "environment-state"])( const capture = analysisViewCaptureFixture(); const result = projectAnalysisView( { + predicateType: "rea.analysis", evidenceId: `ev_${"a".repeat(64)}`, operation: "inspect_web_network_capture", limitations: [], @@ -357,6 +360,7 @@ it("rejects cyclic captures and accessor members without invoking them", () => { expect( projectAnalysisView( { + predicateType: "rea.analysis", evidenceId: `ev_${"a".repeat(64)}`, operation: "inspect_web_network_capture", normalizedResult, diff --git a/src/domain/analysisView/webNetworkCaptureView.test.ts b/src/domain/analysisView/webNetworkCaptureView.test.ts index 9fabd6d8aa..4aaecad8c6 100644 --- a/src/domain/analysisView/webNetworkCaptureView.test.ts +++ b/src/domain/analysisView/webNetworkCaptureView.test.ts @@ -69,6 +69,7 @@ const capture = () => { return { report, parent: { + predicateType: evidence.predicate_type, evidenceId: evidence.evidence_id, operation: evidence.operation, normalizedResult: report, @@ -126,6 +127,7 @@ it("uses qlog event headlines without promoting HTTP-shaped extensions", () => { ); const preview = projectAnalysisView( { + predicateType: evidence.predicate_type, evidenceId: evidence.evidence_id, operation: evidence.operation, normalizedResult: qlog, @@ -349,6 +351,7 @@ const nativePreview = ( }); const preview = projectAnalysisView( { + predicateType: evidence.predicate_type, evidenceId: evidence.evidence_id, operation: evidence.operation, normalizedResult: report, diff --git a/src/domain/evidence.ts b/src/domain/evidence.ts index 14c598c506..9439563979 100644 --- a/src/domain/evidence.ts +++ b/src/domain/evidence.ts @@ -32,6 +32,9 @@ import { type RelationshipCoverage, } from "./relationshipCoverage.js"; +/** Predicate shared by ordinary provider analysis observations. */ +export const ANALYSIS_EVIDENCE_PREDICATE = "rea.analysis"; + /** Provider identity schema shared by evidence-bearing persistence formats. */ export const providerSchema = z.strictObject({ id: z.string().min(1), @@ -453,7 +456,7 @@ const normalizeEvidenceObservation = ( version: provider.version, }, analysis_profile: observation.analysisProfile ?? null, - predicate_type: observation.predicateType ?? "rea.analysis", + predicate_type: observation.predicateType ?? ANALYSIS_EVIDENCE_PREDICATE, operation: observation.operation, parameters: observation.parameters, raw_result: observation.rawResult ?? null, diff --git a/src/domain/functionDossierEvidence.ts b/src/domain/functionDossierEvidence.ts index ee3ccd92d6..15abd76511 100644 --- a/src/domain/functionDossierEvidence.ts +++ b/src/domain/functionDossierEvidence.ts @@ -1,4 +1,4 @@ -import type { Evidence } from "./evidence.js"; +import { ANALYSIS_EVIDENCE_PREDICATE, type Evidence } from "./evidence.js"; import { AnalysisInputError } from "./analysisErrorCore.js"; import type { EvidenceIntegrityError } from "./evidenceErrors.js"; import { readEvidence, readEvidencePayload } from "./evidenceValidation.js"; @@ -70,7 +70,7 @@ export const parseFunctionEvidence = ( ); if ( evidence.operation !== "analyze_function" || - evidence.predicate_type !== "rea.analysis" + evidence.predicate_type !== ANALYSIS_EVIDENCE_PREDICATE ) { const location = selection.path.map(String).join("."); const subject = @@ -81,7 +81,10 @@ export const parseFunctionEvidence = ( : `Evidence at ${location}`; return invalidSelection( `Function analysis requires analyze_function Evidence with the rea.analysis predicate; the ${subject} has operation ${evidence.operation} and predicate ${evidence.predicate_type}.`, - { operation: "analyze_function", predicate_type: "rea.analysis" }, + { + operation: "analyze_function", + predicate_type: ANALYSIS_EVIDENCE_PREDICATE, + }, ); } if (evidence.subject === null) diff --git a/src/domain/native/functionMatching.ts b/src/domain/native/functionMatching.ts index e2b6039d31..14c341828f 100644 --- a/src/domain/native/functionMatching.ts +++ b/src/domain/native/functionMatching.ts @@ -3,6 +3,9 @@ import { z } from "zod"; import { digestSchema, prefixedDigestSchema } from "../digests.js"; import { retainedEvidenceReferenceSchema } from "../evidenceReference.js"; +export const NATIVE_FUNCTION_MATCHING_PREDICATE = + "rea.native-function-matching"; + const evidenceId = prefixedDigestSchema("ev"); export const nativeMatchStateSchema = z.enum([ "matched", diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index c2241777c0..c1df025881 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -2066,6 +2066,7 @@ it("keeps known capture omissions in compact route summaries and queryable gap f ); const report = analyzeWebBundleFixture(captured), parent = { + predicateType: "rea.web-bundle-analysis", evidenceId: `ev_${"a".repeat(64)}`, operation: "analyze_web_bundle", normalizedResult: report, diff --git a/src/native/exports/NativeExportsView.test.ts b/src/native/exports/NativeExportsView.test.ts index 53f3a4e0db..869a1b7073 100644 --- a/src/native/exports/NativeExportsView.test.ts +++ b/src/native/exports/NativeExportsView.test.ts @@ -18,6 +18,7 @@ const artifact = { bytes: 2048, }; const parent = (report: unknown): AnalysisViewParent => ({ + predicateType: "rea.analysis", evidenceId: `ev_${"b".repeat(64)}`, operation: "inspect_native_exports", normalizedResult: report, diff --git a/tests/boundary/filesystem/apkSignatureViewIdentity.test.ts b/tests/boundary/filesystem/apkSignatureViewIdentity.test.ts index c7f8863133..c5f4331d09 100644 --- a/tests/boundary/filesystem/apkSignatureViewIdentity.test.ts +++ b/tests/boundary/filesystem/apkSignatureViewIdentity.test.ts @@ -26,6 +26,7 @@ it("checks parent subject identity again when signing facts are already cached", if (evidence.subject === null || evidence.subject.format !== "apk") throw new Error("selected file subject missing"); const parent = { + predicateType: evidence.predicate_type, evidenceId: evidence.evidence_id, operation: evidence.operation, normalizedResult: freezeJsonSnapshot(evidence.normalized_result), diff --git a/tests/boundary/mcp/nativeViewPredicateMcp.test.ts b/tests/boundary/mcp/nativeViewPredicateMcp.test.ts new file mode 100644 index 0000000000..d07ec6bd62 --- /dev/null +++ b/tests/boundary/mcp/nativeViewPredicateMcp.test.ts @@ -0,0 +1,200 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { expect, it } from "vitest"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js"; +import { + createEvidence, + parseEvidence, + type Evidence, +} from "../../../src/domain/evidence.js"; +import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { createMcpSchemaValidation } from "../../fixtures/mcpSchemaValidation.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; + +const fixture = FUNCTION_COMPARISON_EXAMPLE.left; +if (fixture.subject === null) + throw new Error("Expected artifact-bound fixture"); +const subject = { + path: fixture.subject.local_path, + sha256: fixture.subject.digest.sha256, + format: fixture.subject.format, +}; +const selected = ( + operation: string, + result: Evidence["normalized_result"], + predicateType: string, +) => + createEvidence(subject, fixture.provider, { + operation, + parameters: {}, + result, + predicateType, + confidence: "observed", + authority: "shipped-artifact", + }); + +const wrongMatchingPredicate = (matching: Evidence) => + createEvidence(undefined, matching.provider, { + operation: matching.operation, + parameters: matching.parameters, + result: matching.normalized_result, + predicateType: "rea.analysis", + confidence: matching.confidence, + authority: matching.authority, + evidenceLinks: matching.evidence_links, + }); + +it.each(["full", "compact"] as const)( + "validates native parent predicates across public sources (%s schema)", + async (profile) => { + const session = createTestBinarySession(() => { + throw new Error("No provider should start"); + }); + const server = createServer( + { kind: "session", session }, + { + environment: { ...process.env, REA_MCP_OUTPUT_SCHEMA_PROFILE: profile }, + }, + ); + const client = new Client({ name: "native-view-identity", version: "1" }); + const validation = createMcpSchemaValidation(); + const [ct, st] = InMemoryTransport.createLinkedPair(); + const root = await createTestTempDirectory("rea-native-view-identity-"); + try { + await server.connect(st); + await client.connect(ct); + const definitions = new Map( + (await client.listTools()).tools.map((tool) => [tool.name, tool]), + ); + const viewTool = definitions.get("inspect_analysis_view"); + if (viewTool?.outputSchema === undefined) + throw new Error("Missing advertised view schema"); + expect(validation.assertions.validateSchema(viewTool.outputSchema)).toBe( + true, + ); + const call = (name: string, args: Record) => { + const toolDefinition = definitions.get(name); + if (toolDefinition === undefined) throw new Error(`Missing ${name}`); + return client.callTool({ name, arguments: args }, { toolDefinition }); + }; + const inventory = selected( + "list_procedures", + [{ address: "0x1000", value: "main" }], + "rea.analysis", + ); + const matchingResult = await call("match_native_functions", { + left: inventory, + right: inventory, + detail: "complete", + }); + expect( + matchingResult.isError, + JSON.stringify(matchingResult.content), + ).not.toBe(true); + const matching = parseEvidence(matchingResult.structuredContent); + expect(matching.predicate_type).toBe("rea.native-function-matching"); + const wrongMatching = wrongMatchingPredicate(matching); + const graph = { + graph: { + "0": [ + { + address: "0x1000", + status: "ok", + calls: [], + relationship_coverage: { status: "complete" }, + }, + ], + }, + limitations: [], + }; + const cases = [ + { + good: selected( + "analyze_function", + fixture.normalized_result, + "rea.analysis", + ), + bad: selected( + "analyze_function", + fixture.normalized_result, + "rea.unrelated", + ), + view: { kind: "summary" }, + }, + { + good: inventory, + bad: selected( + "list_procedures", + inventory.normalized_result, + "rea.unrelated", + ), + view: { kind: "native-inventory", offset: 0, limit: 1 }, + }, + { + good: selected("procedure_address", "0x1000", "rea.analysis"), + bad: selected("procedure_address", "0x1000", "rea.unrelated"), + view: { kind: "native-observation", offset: 0, limit: 1 }, + }, + { + good: selected("get_call_graph", graph, "rea.analysis"), + bad: selected("get_call_graph", graph, "rea.unrelated"), + view: { kind: "native-observation", offset: 0, limit: 1 }, + }, + { good: matching, bad: wrongMatching, view: { kind: "summary" } }, + ]; + for (const [index, { good, bad, view }] of cases.entries()) { + expect(session.recordEvidence(good).ok).toBe(true); + expect(session.recordEvidence(bad).ok).toBe(true); + const goodReply = await call("inspect_analysis_view", { + source: { kind: "retained-evidence", evidence_id: good.evidence_id }, + view, + }); + expect(goodReply.isError, JSON.stringify(goodReply.content)).not.toBe( + true, + ); + expect( + validation.assertions.validate( + viewTool.outputSchema, + goodReply.structuredContent, + ), + JSON.stringify(validation.assertions.errors), + ).toBe(true); + const goodView = parseEvidence(goodReply.structuredContent); + expect(goodView.evidence_links).toContain(good.evidence_id); + const raw = join(root, `${index}.json`), + bundle = join(root, `${index}-bundle.json`); + await writeFile(raw, JSON.stringify(bad)); + await writeFile(bundle, JSON.stringify(createEvidenceBundle([bad]))); + for (const source of [ + { kind: "inline", evidence: bad }, + { kind: "retained-evidence", evidence_id: bad.evidence_id }, + { kind: "evidence-file", path: raw }, + { kind: "evidence-file", path: bundle }, + ]) { + const reply = await call("inspect_analysis_view", { source, view }); + expect(parseMcpToolError(reply).error).toMatchObject({ + code: "invalid_request", + details: { + issues: [ + expect.objectContaining({ + path: ["source", "predicate_type"], + reason: "invalid_value", + }), + ], + }, + }); + } + } + expect(session.activeTarget()).toBeUndefined(); + } finally { + await client.close(); + await server.close(); + await session.close(); + validation.clear(); + } + }, +); diff --git a/tests/fixtures/environmentStateWrites.ts b/tests/fixtures/environmentStateWrites.ts index 0838ae6d74..23e4455c4f 100644 --- a/tests/fixtures/environmentStateWrites.ts +++ b/tests/fixtures/environmentStateWrites.ts @@ -455,6 +455,7 @@ export const environmentStateViewOf = ( ): EnvironmentStateView => { const result = projectAnalysisView( { + predicateType: capture.predicate_type, evidenceId: capture.evidence_id, operation: capture.operation, normalizedResult: capture.normalized_result,