diff --git a/scripts/lib/browser-scenario-storage-fingerprints-e2e.mjs b/scripts/lib/browser-scenario-storage-fingerprints-e2e.mjs index d7aa18df0a..3d25385477 100644 --- a/scripts/lib/browser-scenario-storage-fingerprints-e2e.mjs +++ b/scripts/lib/browser-scenario-storage-fingerprints-e2e.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { requireMcpEvidenceBundle } from "./mcp-verifier-results.mjs"; import { createHash } from "node:crypto"; import { fileURLToPath } from "node:url"; import { Client } from "@modelcontextprotocol/client"; @@ -183,12 +184,20 @@ export async function verifyScenarioStorageFingerprints(executable, origin) { (step) => step.artifacts.storage, ), ); - const bundle = await call("get_evidence_bundle", {}); + const bundleReply = await artifactMcpCall(client, { + name: "get_evidence_bundle", + arguments: {}, + }); + assert.ok( + bundleReply.structuredContent, + "MCP omitted structured Evidence bundle", + ); + const bundle = requireMcpEvidenceBundle(bundleReply); for (const evidence of mcp) { - const record = bundle.result.records.find( + const record = bundle.records.find( ({ evidence_id }) => evidence_id === evidence.evidence_id, ); - assert.deepEqual(parseEvidence(record), evidence); + assert.deepEqual(record, evidence); } await client.ping(); return { diff --git a/scripts/lib/ghidra-reference-snapshot-e2e.mjs b/scripts/lib/ghidra-reference-snapshot-e2e.mjs index 7214a35917..950baa5e7c 100644 --- a/scripts/lib/ghidra-reference-snapshot-e2e.mjs +++ b/scripts/lib/ghidra-reference-snapshot-e2e.mjs @@ -1,4 +1,5 @@ import { + requireMcpEvidenceBundle, requireMcpToolError, requireMcpLifecycleResult, requireMcpEvidenceResult, @@ -26,12 +27,11 @@ export async function verifyLegacyGhidraReferenceSnapshot( client, { target, procedure, omittedEdge, entrypoint, env }, ) { - const bundle = requireMcpLifecycleResult( + const bundle = requireMcpEvidenceBundle( await client.callTool({ name: "get_evidence_bundle", arguments: {}, }), - "get_evidence_bundle", ); const observed = bundle.records.find( (record) => diff --git a/scripts/lib/mcp-verifier-results.mjs b/scripts/lib/mcp-verifier-results.mjs index 6dcd44f1c0..3c98dbc8cb 100644 --- a/scripts/lib/mcp-verifier-results.mjs +++ b/scripts/lib/mcp-verifier-results.mjs @@ -1,3 +1,5 @@ +import { isDeepStrictEqual } from "node:util"; +import { authenticateParsedEvidenceBundle } from "../../dist/domain/evidenceBundle.js"; import { analysisErrorProjectionSchema } from "../../dist/contracts/errorSchemas.js"; import { toolContract } from "../../dist/contracts/toolContracts.js"; import { @@ -108,6 +110,27 @@ export const requireMcpLifecycleResult = (result, operation) => { : projected; }; +/** Require an authenticated complete bundle without changing summary-capable readers. */ +export const requireMcpEvidenceBundle = (result) => { + const operation = "get_evidence_bundle"; + const contract = toolContract(operation); + const bundle = requireOutput(result, operation, contract).result; + if (bundle.kind === "evidence-bundle-summary") + throw new Error( + "get_evidence_bundle returned summary metadata instead of complete Evidence", + ); + if (result.structuredContent !== undefined) { + const structured = contract.outputSchema.parse( + result.structuredContent, + ).result; + if (!isDeepStrictEqual(bundle, structured)) + throw new Error( + "get_evidence_bundle text and structured results disagree", + ); + } + return authenticateParsedEvidenceBundle(bundle); +}; + /** Project a mixed verifier call using its exact named canonical output schema. */ export const requireMcpOperationResult = ( result, diff --git a/scripts/lib/native-value-e2e.mjs b/scripts/lib/native-value-e2e.mjs index ab88bb8606..e58e9ef6d4 100644 --- a/scripts/lib/native-value-e2e.mjs +++ b/scripts/lib/native-value-e2e.mjs @@ -11,7 +11,10 @@ import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { parseEvidence } from "../../dist/domain/evidence.js"; import { analysisProfileSchema } from "../../dist/domain/analysisProfile.js"; import { nativeValueTraceSchema } from "../../dist/domain/native/nativeValueTrace.js"; -import { requireMcpLifecycleResult } from "./mcp-verifier-results.mjs"; +import { + requireMcpEvidenceBundle, + requireMcpLifecycleResult, +} from "./mcp-verifier-results.mjs"; import { verifyGhidraBoundaries } from "./real-ghidra-boundaries.mjs"; const defaultEntrypoint = fileURLToPath(new URL("../rea.mjs", import.meta.url)); @@ -272,18 +275,22 @@ async function verifyNativeValueAdapters( comparisonEvidence.evidence_links.includes(functionEvidence.evidence_id), ); await verifyUnresolvedCallEvidence(client); - const bundle = await client.callTool({ + const bundleReply = await client.callTool({ name: "get_evidence_bundle", arguments: {}, }); - assert.notEqual(bundle.isError, true, JSON.stringify(bundle)); + assert.ok( + bundleReply.structuredContent, + "MCP omitted structured Evidence bundle", + ); + const bundle = requireMcpEvidenceBundle(bundleReply); for (const evidence of [ mcpEvidence, functionEvidence, comparisonEvidence, ]) { assert.deepEqual( - bundle.structuredContent?.result?.records.find( + bundle.records.find( (record) => record.evidence_id === evidence.evidence_id, ), evidence, diff --git a/scripts/lib/real-ghidra-large-results.mjs b/scripts/lib/real-ghidra-large-results.mjs index f721ff503d..f27a5c03c8 100644 --- a/scripts/lib/real-ghidra-large-results.mjs +++ b/scripts/lib/real-ghidra-large-results.mjs @@ -7,6 +7,7 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from "@modelcontextprotocol/server"; +import { parseEvidenceBundle } from "../../dist/domain/evidenceBundle.js"; /** Exercise complete oversized results and recovery through real CLI/MCP adapters. */ export async function verifyGhidraLargeResults({ @@ -132,7 +133,9 @@ export async function verifyGhidraLargeResults({ ); const bundlePath = join(workspace, "bundle.json"); await call("export_evidence_bundle", { path: bundlePath }); - const bundle = JSON.parse(await readFile(bundlePath, "utf8")); + const bundle = parseEvidenceBundle( + JSON.parse(await readFile(bundlePath, "utf8")), + ); const retained = (id) => { const record = bundle.records.find( ({ evidence_id }) => evidence_id === id, diff --git a/scripts/verify-http3-network-captures.mjs b/scripts/verify-http3-network-captures.mjs index 2b34938215..c601cac8bc 100644 --- a/scripts/verify-http3-network-captures.mjs +++ b/scripts/verify-http3-network-captures.mjs @@ -24,7 +24,10 @@ import { publishAttempt, finishProducerGroups, } from "./lib/http3-verifier-lifecycle.mjs"; -import { mcpTextValue } from "./lib/mcp-verifier-results.mjs"; +import { + requireMcpEvidenceBundle, + mcpTextValue, +} from "./lib/mcp-verifier-results.mjs"; const root = process.argv[2]; const inspectExisting = process.argv[3] === "--inspect-existing"; @@ -288,10 +291,8 @@ try { name: "get_evidence_bundle", arguments: {}, }); - assert.notEqual(bundleReply.isError, true, mcpTextValue(bundleReply)); - const bundle = JSON.parse(mcpTextValue(bundleReply)).result; + const bundle = requireMcpEvidenceBundle(bundleReply); const records = bundle.records; - assert.ok(Array.isArray(records), "Evidence bundle omitted its records"); for (const evidence of retainedEvidence) { const retained = records.find( (record) => record.evidence_id === evidence.evidence_id, diff --git a/scripts/verify-package-mcp.mjs b/scripts/verify-package-mcp.mjs index c7dc6d83e1..aeb6d2f01d 100644 --- a/scripts/verify-package-mcp.mjs +++ b/scripts/verify-package-mcp.mjs @@ -1,4 +1,5 @@ import { + requireMcpEvidenceBundle, requireMcpLifecycleResult, requireMcpToolError, } from "./lib/mcp-verifier-results.mjs"; @@ -198,14 +199,18 @@ const verifyMcpInlineArtifactEvidence = async ( compared.structuredContent?.normalized_result, ); assert.ok(comparison.evidence_links.includes(source.evidence_id)); - const bundle = await client.callTool( + const bundleReply = await client.callTool( { name: "get_evidence_bundle", arguments: {} }, mcpOptions, ); - assert.notEqual(bundle.isError, true, JSON.stringify(bundle)); + assert.ok( + bundleReply.structuredContent, + "MCP omitted structured Evidence bundle", + ); + const bundle = requireMcpEvidenceBundle(bundleReply); for (const evidence of [source, comparison]) { assert.deepEqual( - bundle.structuredContent?.result?.records.find( + bundle.records.find( (record) => record.evidence_id === evidence.evidence_id, ), evidence, diff --git a/scripts/verify-packet-capture.mjs b/scripts/verify-packet-capture.mjs index 052ac9c96c..91ad2a8829 100644 --- a/scripts/verify-packet-capture.mjs +++ b/scripts/verify-packet-capture.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node import assert from "node:assert/strict"; +import { requireMcpEvidenceBundle } from "./lib/mcp-verifier-results.mjs"; import { estimateTokenCount } from "tokenx"; import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; @@ -178,11 +179,17 @@ await withVerifierCleanup(async () => { ).stdout, ); assert.deepEqual(parseEvidence(cli), evidence); - const bundle = await client.callTool({ - name: "get_evidence_bundle", - arguments: {}, - }); - assert.ok(JSON.stringify(bundle).includes(evidence.evidence_id)); + const bundle = requireMcpEvidenceBundle( + await client.callTool({ + name: "get_evidence_bundle", + arguments: {}, + }), + ); + assert.ok( + bundle.records.some( + (record) => record.evidence_id === evidence.evidence_id, + ), + ); cases++; return report; }; diff --git a/scripts/verify-signal-recording.mjs b/scripts/verify-signal-recording.mjs index b7965630ff..1cafab175c 100644 --- a/scripts/verify-signal-recording.mjs +++ b/scripts/verify-signal-recording.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node import assert from "node:assert/strict"; +import { requireMcpEvidenceBundle } from "./lib/mcp-verifier-results.mjs"; import { estimateTokenCount } from "tokenx"; import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; @@ -171,11 +172,17 @@ await withVerifierCleanup(async () => { ).stdout, ); assert.equal(parseEvidence(cli).evidence_id, evidence.evidence_id); - const bundle = await client.callTool({ - name: "get_evidence_bundle", - arguments: {}, - }); - assert.ok(JSON.stringify(bundle).includes(evidence.evidence_id)); + const bundle = requireMcpEvidenceBundle( + await client.callTool({ + name: "get_evidence_bundle", + arguments: {}, + }), + ); + assert.ok( + bundle.records.some( + (record) => record.evidence_id === evidence.evidence_id, + ), + ); assert.equal(await readFile(path, "utf8"), source); cases++; } diff --git a/scripts/verify-wasm-artifact.mjs b/scripts/verify-wasm-artifact.mjs index c45adf6107..6161c269ee 100644 --- a/scripts/verify-wasm-artifact.mjs +++ b/scripts/verify-wasm-artifact.mjs @@ -20,6 +20,7 @@ import { Ajv2020 } from "ajv/dist/2020.js"; import { PrivateRuntimeRoot } from "../dist/process/PrivateRuntimeRoot.js"; import { parseEvidence } from "../dist/domain/evidence.js"; import { + requireMcpEvidenceBundle, mcpTextValue, requireMcpToolError, } from "./lib/mcp-verifier-results.mjs"; @@ -382,12 +383,11 @@ try { name: "get_evidence_bundle", arguments: {}, }); - assert.notEqual(bundleReply.isError, true, mcpTextValue(bundleReply)); - const bundle = JSON.parse(mcpTextValue(bundleReply)).result; + const bundle = requireMcpEvidenceBundle(bundleReply); const retained = bundle.records.find( ({ evidence_id }) => evidence_id === mcp.evidence_id, ); - assert.deepEqual(parseEvidence(retained), mcp); + assert.deepEqual(retained, mcp); assert.deepEqual(await readFile(path), original); cases += 2; for (const [label, invalidBytes] of [ @@ -421,17 +421,11 @@ try { name: "get_evidence_bundle", arguments: {}, }); - assert.notEqual( - failedBundleReply.isError, - true, - mcpTextValue(failedBundleReply), - ); - const failedBundle = JSON.parse(mcpTextValue(failedBundleReply)).result; - const failed = parseEvidence( - failedBundle.records.find( - ({ evidence_id }) => evidence_id === partial.evidence_id, - ), + const failedBundle = requireMcpEvidenceBundle(failedBundleReply); + const failed = failedBundle.records.find( + ({ evidence_id }) => evidence_id === partial.evidence_id, ); + assert.ok(failed, "Retained failed WASM Evidence missing from bundle"); assert.equal(failed.subject.digest.sha256, sha256(invalidBytes)); assert.equal( failed.normalized_result.tool_profile.timeout_ms, diff --git a/src/application/native/NativeFunctionMatchingViews.test.ts b/src/application/native/NativeFunctionMatchingViews.test.ts index cfa9def892..c577b9521e 100644 --- a/src/application/native/NativeFunctionMatchingViews.test.ts +++ b/src/application/native/NativeFunctionMatchingViews.test.ts @@ -1,8 +1,12 @@ +import { + projectAnalysisView, + analysisViewResultSchema, +} from "../../domain/analysisView/analysisView.js"; +import { nativeFunctionMatchingSchema } from "../../domain/native/functionMatching.js"; import { expect, it } from "vitest"; import { matchNativeFunctions } from "./NativeFunctionMatchingService.js"; import { inspectAnalysisView } from "../analysisView/AnalysisViewService.js"; import { nativeMatchingNamedInventory as nativeMatchingInventory } from "../../../tests/fixtures/nativeFunctionMatching.js"; -import { analysisViewResultSchema } from "../../domain/analysisView/analysisView.js"; it("pages individual function facts without transferring an entire ambiguous alias group, preserving mandatory identity and source coverage", async () => { const entries = Array.from({ length: 500 }, (_, i) => ({ @@ -103,3 +107,38 @@ it("pages individual function facts without transferring an entire ambiguous ali }, }); }); + +it("rebuilds native row selection when a caller's sealed flag covers only a shallow freeze", async () => { + const matched = await matchNativeFunctions({ + left: nativeMatchingInventory("1", [{ address: "0x100", value: "same" }]), + right: nativeMatchingInventory("2", [{ address: "0x200", value: "same" }]), + }); + if (!matched.ok) throw matched.error; + const report = nativeFunctionMatchingSchema.parse( + matched.value.evidence.normalized_result, + ); + Object.freeze(report); + const parent = { + evidenceId: matched.value.evidence.evidence_id, + operation: matched.value.evidence.operation, + normalizedResult: report, + sealedResult: true, + limitations: [], + }; + const request: Parameters[1] = { + kind: "function-matches", + collection: "functions", + names: ["same"], + sides: ["left"], + fields: ["name"], + offset: 0, + limit: 1, + }; + const first = projectAnalysisView(parent, request); + expect(first).toMatchObject({ ok: true, value: { coverage: { total: 1 } } }); + const member = report.matches[0]?.left[0]; + if (member === undefined) throw new Error("Expected matched left member"); + member.name = "changed"; + const second = projectAnalysisView(parent, request); + expect(second).toMatchObject({ ok: true, value: { coverage: { total: 0 } } }); +}); diff --git a/src/domain/analysisView/apkSignatureView.ts b/src/domain/analysisView/apkSignatureView.ts index cd4dde87a4..6a492401c0 100644 --- a/src/domain/analysisView/apkSignatureView.ts +++ b/src/domain/analysisView/apkSignatureView.ts @@ -20,7 +20,7 @@ import type { AnalysisViewRequest, UnsignedAnalysisView, } from "./analysisView.js"; -import { parseAnalysisViewResult } from "./analysisViewParent.js"; +import { createAnalysisViewResultReader } from "./analysisViewParent.js"; import { analysisViewInputError } from "./analysisViewErrors.js"; import { boundedViewMessages, @@ -90,20 +90,13 @@ interface Checked { readonly unknowns: readonly string[]; } const checked = new WeakMap(); +const readReport = createAnalysisViewResultReader(apkSignatureResultSchema); const read = (parent: AnalysisViewParent): Result => { - const key = parent.normalizedResult; - const reusable = - parent.sealedResult === true && - key !== null && - typeof key === "object" && - isImmutableJsonSnapshot(key); - const cached = reusable ? checked.get(key) : undefined; - let report = cached?.report; - if (report === undefined) { - const parsed = parseAnalysisViewResult(apkSignatureResultSchema, parent); - if (!parsed.ok) return parsed; - report = reusable ? (key as ApkSignatureResult) : parsed.value; - } + const parsed = readReport(parent); + if (!parsed.ok) return parsed; + const report = parsed.value; + const reusable = isImmutableJsonSnapshot(report); + const cached = reusable ? checked.get(report) : undefined; // Parent identity is checked on every read, independently of reusable facts. if ( parent.subject != null && @@ -193,7 +186,7 @@ const read = (parent: AnalysisViewParent): Result => { }, }); const value = { report, summary, unknowns }; - if (reusable) checked.set(key, value); + if (reusable) checked.set(report, value); return ok(value); }; diff --git a/src/domain/analysisView/goBinaryView.test.ts b/src/domain/analysisView/goBinaryView.test.ts index a52c4729eb..f8ff4aecdc 100644 --- a/src/domain/analysisView/goBinaryView.test.ts +++ b/src/domain/analysisView/goBinaryView.test.ts @@ -1,3 +1,4 @@ +import { freezeOwnedJsonTreeSnapshot } from "../immutableJson.js"; import { expect, it } from "vitest"; import { AnalysisInputError } from "../analysisErrorCore.js"; import { createEvidence } from "../evidence.js"; @@ -130,3 +131,22 @@ it.each([ } }, ); + +it("revalidates the parent subject after immutable Go report facts have been cached", () => { + const input = { + ...parent(), + normalizedResult: freezeOwnedJsonTreeSnapshot(structuredClone(report)), + sealedResult: true, + }; + expect(projectAnalysisView(input, { kind: "summary" }).ok).toBe(true); + if (input.subject == null) throw new Error("Expected artifact subject"); + expect( + projectAnalysisView( + { + ...input, + subject: { ...input.subject, local_path: "/artifacts/other" }, + }, + { kind: "summary" }, + ).ok, + ).toBe(false); +}); diff --git a/src/domain/analysisView/goBinaryView.ts b/src/domain/analysisView/goBinaryView.ts index d65fd38176..64a3b63bb9 100644 --- a/src/domain/analysisView/goBinaryView.ts +++ b/src/domain/analysisView/goBinaryView.ts @@ -7,9 +7,7 @@ import { goBuildInfoSchema, goModuleMetadataSchema, goModuleSchema, - type GoBinary, } from "../go/goBinary.js"; -import { isImmutableJsonSnapshot } from "../immutableJson.js"; import { err, ok, type Result } from "../result.js"; import type { AnalysisViewParent, @@ -22,7 +20,7 @@ import { pageViewCoverage, } from "./analysisViewCoverage.js"; import { analysisViewInputError } from "./analysisViewErrors.js"; -import { parseAnalysisViewResult } from "./analysisViewParent.js"; +import { createAnalysisViewResultReader } from "./analysisViewParent.js"; import { selectViewRows } from "./selectViewRows.js"; const indexSchema = z.number().int().nonnegative(); @@ -104,27 +102,7 @@ export const goBinarySummarySchema = z.strictObject({ .nullable(), }); -// Reuse only authenticated immutable report facts, for their Evidence lifetime. -// Inline mutable reports are validated each time; this never caches live files. -const checkedReports = new WeakMap(); -const parseReport = ( - parent: AnalysisViewParent, -): Result => { - const key = parent.normalizedResult; - const reusable = - parent.sealedResult === true && - typeof key === "object" && - key !== null && - isImmutableJsonSnapshot(key); - const existing = reusable ? checkedReports.get(key) : undefined; - if (existing !== undefined) return ok(existing); - const parsed = parseAnalysisViewResult(goBinarySchema, parent); - if (!parsed.ok) return parsed; - // No schema transforms/coercions: keep the validated owner's frozen value. - const report = reusable ? (key as GoBinary) : parsed.value; - if (reusable) checkedReports.set(key, report); - return ok(report); -}; +const parseReport = createAnalysisViewResultReader(goBinarySchema); /** Query the existing metadata owner's facts; never reopen or reinterpret a file. */ export const projectGoBinaryView = ( diff --git a/src/domain/analysisView/gpuCodeObjectsView.test.ts b/src/domain/analysisView/gpuCodeObjectsView.test.ts new file mode 100644 index 0000000000..147a1bbd6e --- /dev/null +++ b/src/domain/analysisView/gpuCodeObjectsView.test.ts @@ -0,0 +1,76 @@ +import { expect, it } from "vitest"; +import { createEvidence } from "../evidence.js"; +import { freezeOwnedJsonTreeSnapshot } from "../immutableJson.js"; +import { gpuCodeObjectsSchema } from "../gpu/gpuCodeObjects.js"; +import type { AnalysisViewParent } from "./analysisView.js"; +import { projectGpuCodeObjectsView } from "./gpuCodeObjectsView.js"; + +const report = () => + gpuCodeObjectsSchema.parse({ + artifact: { + path: "/gpu", + file_size: 0, + sha256: "a".repeat(64), + bytes_read: 0, + }, + format: "elf", + analysis: "inventory", + objects: [], + kernels: [], + ptx_artifacts: [], + coverage: { + discovery_scope: "supported-section-names-and-standalone-formats", + discovery: "partial", + payloads: "not-requested", + examined_sections: 0, + unknown_section_names: 0, + }, + limitations: ["No sections examined."], + }); + +const parent = (normalizedResult: unknown): AnalysisViewParent => ({ + evidenceId: `ev_${"b".repeat(64)}`, + operation: "inspect_gpu_code_objects", + normalizedResult, + sealedResult: true, + limitations: [], +}); + +it("checks each subject despite reusable GPU summary indexes", () => { + const source = freezeOwnedJsonTreeSnapshot(report()); + const input = parent(source); + expect(projectGpuCodeObjectsView(input, { kind: "summary" }).ok).toBe(true); + const subject = createEvidence( + { path: "/other", sha256: source.artifact.sha256, format: "file" }, + { id: "fixture", name: "Fixture", version: "1" }, + { + operation: input.operation, + parameters: {}, + result: source, + confidence: "observed", + }, + ).subject; + expect( + projectGpuCodeObjectsView({ ...input, subject }, { kind: "summary" }), + ).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } }); +}); + +it("revalidates mutable GPU facts and preserves incomplete source coverage", () => { + const source = report(); + Object.freeze(source); + const input = parent(source); + const first = projectGpuCodeObjectsView(input, { kind: "summary" }); + expect(first).toMatchObject({ + ok: true, + value: { + summary: { + coverage: { discovery: "partial", payloads: "not-requested" }, + }, + }, + }); + Object.assign(source.artifact, { sha256: "invalid" }); + expect(projectGpuCodeObjectsView(input, { kind: "summary" })).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); +}); diff --git a/src/domain/analysisView/gpuCodeObjectsView.ts b/src/domain/analysisView/gpuCodeObjectsView.ts index 0b1df43dda..10d0f5800d 100644 --- a/src/domain/analysisView/gpuCodeObjectsView.ts +++ b/src/domain/analysisView/gpuCodeObjectsView.ts @@ -18,7 +18,7 @@ import type { UnsignedAnalysisView, } from "./analysisView.js"; import { analysisViewInputError } from "./analysisViewErrors.js"; -import { parseAnalysisViewResult } from "./analysisViewParent.js"; +import { createAnalysisViewResultReader } from "./analysisViewParent.js"; import { boundedViewMessages, completeWithinViewCoverage, @@ -160,22 +160,16 @@ type CheckedGpu = { }; // Evidence owns this immutable snapshot. Collection eviction releases the indexes as well. const checkedGpu = new WeakMap(); +const readReport = createAnalysisViewResultReader(gpuCodeObjectsSchema); const readGpuFacts = ( parent: AnalysisViewParent, ): Result => { - const key = parent.normalizedResult; - const reusable = - parent.sealedResult === true && - key !== null && - typeof key === "object" && - isImmutableJsonSnapshot(key); - let checked = reusable ? checkedGpu.get(key) : undefined; + const parsed = readReport(parent); + if (!parsed.ok) return parsed; + const report = parsed.value; + const reusable = isImmutableJsonSnapshot(report); + let checked = reusable ? checkedGpu.get(report) : undefined; if (checked === undefined) { - const parsed = parseAnalysisViewResult(gpuCodeObjectsSchema, parent); - if (!parsed.ok) return parsed; - const report = reusable - ? (key as z.infer) - : parsed.value; const { objects, kernels, ptx_artifacts, ...context } = report; const targets = new Set( objects.flatMap((row) => (row.target === null ? [] : [row.target])), @@ -197,7 +191,7 @@ const readGpuFacts = ( .map((target) => target.slice(0, 256)), }, }; - if (reusable) checkedGpu.set(key, checked); + if (reusable) checkedGpu.set(report, checked); } if ( parent.subject != null && diff --git a/src/domain/analysisView/il2cppMetadataView.ts b/src/domain/analysisView/il2cppMetadataView.ts index e18cbeebc2..3878526133 100644 --- a/src/domain/analysisView/il2cppMetadataView.ts +++ b/src/domain/analysisView/il2cppMetadataView.ts @@ -24,7 +24,7 @@ import { il2cppMetadataTypeSchema, } from "../il2cpp/il2cppMetadata.js"; import { err, ok, type Result } from "../result.js"; -import { parseAnalysisViewResult } from "./analysisViewParent.js"; +import { createAnalysisViewResultReader } from "./analysisViewParent.js"; import type { AnalysisViewParent, AnalysisViewRequest, @@ -283,26 +283,21 @@ interface CheckedReport { readonly counts: z.infer; } -// Authenticated, frozen parents own these facts. Cache validation/counts for -// their lifetime without a second report copy or a live filesystem cache. +const readMetadataReport = createAnalysisViewResultReader( + il2cppMetadataParentSchema, +); +// The validated immutable report owns its derived counts for its lifetime. const checkedReports = new WeakMap(); const parseMetadataReport = ( parent: AnalysisViewParent, ): Result => { - const key = parent.normalizedResult; - const reusable = - parent.sealedResult === true && - typeof key === "object" && - key !== null && - isImmutableJsonSnapshot(key); - const cached = reusable ? checkedReports.get(key) : undefined; - if (cached !== undefined) return ok(cached); - const parsed = parseAnalysisViewResult(il2cppMetadataParentSchema, parent); + const parsed = readMetadataReport(parent); if (!parsed.ok) return parsed; - // This schema has no coercions/transforms; the authenticated owner value is - // equivalent to the validated clone and remains immutable across pages. - const report = reusable ? (key as Report) : parsed.value; + const report = parsed.value; + const reusable = isImmutableJsonSnapshot(report); + const cached = reusable ? checkedReports.get(report) : undefined; + if (cached !== undefined) return ok(cached); const checked = { report, counts: { @@ -355,7 +350,7 @@ const parseMetadataReport = ( report.binary?.generic_methods?.issues.length ?? null, }, }; - if (reusable) checkedReports.set(key, checked); + if (reusable) checkedReports.set(report, checked); return ok(checked); }; diff --git a/src/domain/analysisView/managedMemberView.test.ts b/src/domain/analysisView/managedMemberView.test.ts new file mode 100644 index 0000000000..f0492ec4ff --- /dev/null +++ b/src/domain/analysisView/managedMemberView.test.ts @@ -0,0 +1,57 @@ +import { expect, it } from "vitest"; +import { MANAGED_RECONSTRUCTION_IMPORT_EXAMPLE } from "../../contracts/managed/managedWorkflowExamples.js"; +import { freezeOwnedJsonTreeSnapshot } from "../immutableJson.js"; +import { managedMemberInspectionSchema } from "../managed/managedArtifact.js"; +import type { AnalysisViewParent } from "./analysisView.js"; +import { projectManagedMemberView } from "./managedMemberView.js"; + +const parent = (normalizedResult: unknown): AnalysisViewParent => ({ + evidenceId: `ev_${"a".repeat(64)}`, + operation: "inspect_managed_members", + normalizedResult, + sealedResult: true, + limitations: [], +}); + +it("revalidates mutable managed facts despite a sealed flag and shallow freeze", () => { + const source = managedMemberInspectionSchema.parse( + MANAGED_RECONSTRUCTION_IMPORT_EXAMPLE.static_members.normalized_result, + ); + Object.freeze(source); + const input = parent(source); + expect(projectManagedMemberView(input, { kind: "summary" }).ok).toBe(true); + Object.assign(source.coverage, { state: "invalid" }); + expect(projectManagedMemberView(input, { kind: "summary" })).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); +}); + +it("retains historical unknowns while normalizing immutable managed parents", () => { + const complete = managedMemberInspectionSchema.parse( + MANAGED_RECONSTRUCTION_IMPORT_EXAMPLE.static_members.normalized_result, + ); + const { + signature_resolution: _signature, + nesting_read: _nesting, + exported_types: _exported, + user_strings: _strings, + selection: _selection, + method_rows: _rows, + ...legacy + } = complete; + const input = parent(freezeOwnedJsonTreeSnapshot(legacy)); + for (let attempt = 0; attempt < 2; attempt++) { + expect(projectManagedMemberView(input, { kind: "summary" })).toMatchObject({ + ok: true, + value: { + summary: { + signature_resolution: null, + nesting_read: null, + reported_counts: { exported_types: null, user_strings: null }, + }, + }, + }); + } + expect(Object.hasOwn(legacy, "signature_resolution")).toBe(false); +}); diff --git a/src/domain/analysisView/managedMemberView.ts b/src/domain/analysisView/managedMemberView.ts index 57016e9119..a41e89afda 100644 --- a/src/domain/analysisView/managedMemberView.ts +++ b/src/domain/analysisView/managedMemberView.ts @@ -17,7 +17,7 @@ import { pageViewCoverage, } from "./analysisViewCoverage.js"; import { managedViewIdentityUnknowns } from "./managedArtifactView.js"; -import { parseAnalysisViewResult } from "./analysisViewParent.js"; +import { createAnalysisViewResultReader } from "./analysisViewParent.js"; const member = managedMemberInspectionSchema.shape; @@ -146,32 +146,10 @@ const collectionPage = ( } }; -// A sealed parent result cannot change, so its validated report stays exact for -// every later page. The weak reference lets memory pressure reclaim the copy; -// unsealed input is validated on each call. -const validatedReports = new WeakMap< - object, - WeakRef ->(); - -/** Validate the parent once per sealed result instead of once per page. */ -const parseMemberReport = ( - parent: AnalysisViewParent, -): Result => { - const { normalizedResult } = parent; - const sealed = - parent.sealedResult === true && - typeof normalizedResult === "object" && - normalizedResult !== null; - const cached = sealed - ? validatedReports.get(normalizedResult)?.deref() - : undefined; - if (cached !== undefined) return ok(cached); - const parsed = parseAnalysisViewResult(managedMemberInspectionSchema, parent); - if (parsed.ok && sealed) - validatedReports.set(normalizedResult, new WeakRef(parsed.value)); - return parsed; -}; +/** The shared owner proves immutable source identity before retaining normalized facts. */ +const parseMemberReport = createAnalysisViewResultReader( + managedMemberInspectionSchema, +); /** Everything but the member collections, which are replaced by their counts. */ const summarizeMembers = ( diff --git a/src/domain/analysisView/modelArtifactView.ts b/src/domain/analysisView/modelArtifactView.ts index c072779f94..006281aa9d 100644 --- a/src/domain/analysisView/modelArtifactView.ts +++ b/src/domain/analysisView/modelArtifactView.ts @@ -10,7 +10,7 @@ import { } from "../model/modelArtifact.js"; import type { AnalysisError } from "../analysisErrorBase.js"; import { analysisViewInputError } from "./analysisViewErrors.js"; -import { parseAnalysisViewResult } from "./analysisViewParent.js"; +import { createAnalysisViewResultReader } from "./analysisViewParent.js"; import { isImmutableJsonSnapshot } from "../immutableJson.js"; import type { JsonValue } from "../jsonValue.js"; import { err, ok, type Result } from "../result.js"; @@ -135,21 +135,13 @@ type Checked = { types: Map; }; const checked = new WeakMap(); +const readReport = createAnalysisViewResultReader(modelArtifactSchema); const read = (parent: AnalysisViewParent): Result => { - const key = parent.normalizedResult; - const reusable = - parent.sealedResult === true && - key !== null && - typeof key === "object" && - isImmutableJsonSnapshot(key); - const cached = reusable ? checked.get(key) : undefined; - let report: ModelArtifact; - if (cached !== undefined) report = cached.report; - else { - const parsed = parseAnalysisViewResult(modelArtifactSchema, parent); - if (!parsed.ok) return parsed; - report = reusable ? (key as ModelArtifact) : parsed.value; - } + const parsed = readReport(parent); + if (!parsed.ok) return parsed; + const report = parsed.value; + const reusable = isImmutableJsonSnapshot(report); + const cached = reusable ? checked.get(report) : undefined; if ( parent.subject != null && (parent.subject.local_path !== report.artifact.path || @@ -184,7 +176,7 @@ const read = (parent: AnalysisViewParent): Result => { names: new Map(report.tensors.map((row) => [row.name, row.index])), keys: new Map(report.metadata.map((row) => [row.key, row.index])), }; - if (reusable) checked.set(key, value); + if (reusable) checked.set(report, value); return ok(value); }; diff --git a/src/domain/analysisView/nativeFunctionMatchingView.ts b/src/domain/analysisView/nativeFunctionMatchingView.ts index fc0b11e03e..f4090dc4dc 100644 --- a/src/domain/analysisView/nativeFunctionMatchingView.ts +++ b/src/domain/analysisView/nativeFunctionMatchingView.ts @@ -8,7 +8,7 @@ import { type NativeFunctionMatching, } from "../native/functionMatching.js"; import { isImmutableJsonSnapshot } from "../immutableJson.js"; -import { parseAnalysisViewResult } from "./analysisViewParent.js"; +import { createAnalysisViewResultReader } from "./analysisViewParent.js"; import { analysisViewInputError } from "./analysisViewErrors.js"; import { boundedViewMessages, @@ -94,25 +94,19 @@ interface Checked { recent?: { readonly key: string; readonly rows: readonly number[] }; } const checked = new WeakMap(); +const readReport = createAnalysisViewResultReader(nativeFunctionMatchingSchema); type Request = z.output; const checkedReport = ( parent: AnalysisViewParent, ): Result => { - const source = parent.normalizedResult; - const reusable = - typeof source === "object" && - source !== null && - parent.sealedResult === true && - isImmutableJsonSnapshot(source); - const cached = reusable ? checked.get(source) : undefined; - if (cached !== undefined) return ok(cached); - const parsed = parseAnalysisViewResult(nativeFunctionMatchingSchema, parent); + const parsed = readReport(parent); if (!parsed.ok) return parsed; - const entry: Checked = { - report: reusable ? (source as NativeFunctionMatching) : parsed.value, - }; - if (reusable) checked.set(source, entry); + const report = parsed.value; + const cached = checked.get(report); + if (cached !== undefined) return ok(cached); + const entry: Checked = { report }; + if (isImmutableJsonSnapshot(report)) checked.set(report, entry); return ok(entry); }; const functionRows = (entry: Checked): readonly FunctionRow[] => { diff --git a/src/domain/analysisView/sourceToBundleView.ts b/src/domain/analysisView/sourceToBundleView.ts index 7f7541d07e..38a2f616b5 100644 --- a/src/domain/analysisView/sourceToBundleView.ts +++ b/src/domain/analysisView/sourceToBundleView.ts @@ -4,7 +4,6 @@ import { sourceToBundleComparisonResultSchema, type SourceToBundleComparisonResult, } from "../javascript/sourceToBundleComparisonSchemas.js"; -import { isImmutableJsonSnapshot } from "../immutableJson.js"; import { err, ok, type Result } from "../result.js"; import type { AnalysisError } from "../analysisErrorBase.js"; import type { @@ -13,7 +12,7 @@ import type { UnsignedAnalysisView, } from "./analysisView.js"; import { analysisViewInputError } from "./analysisViewErrors.js"; -import { parseAnalysisViewResult } from "./analysisViewParent.js"; +import { createAnalysisViewResultReader } from "./analysisViewParent.js"; import { boundedViewMessages, completeWithinViewCoverage, @@ -94,29 +93,9 @@ export const sourceToBundleViewContextSchema = unmapped_current_node_count: z.number().int().nonnegative(), }); -const checked = new WeakMap(); -const checkedReport = ( - parent: AnalysisViewParent, -): Result => { - const source = parent.normalizedResult; - const reusable = - typeof source === "object" && - source !== null && - parent.sealedResult === true && - isImmutableJsonSnapshot(source); - const cached = reusable ? checked.get(source) : undefined; - if (cached !== undefined) return ok(cached); - const parsed = parseAnalysisViewResult( - sourceToBundleComparisonResultSchema, - parent, - ); - if (!parsed.ok) return parsed; - const report = reusable - ? (source as SourceToBundleComparisonResult) - : parsed.value; - if (reusable) checked.set(source, report); - return ok(report); -}; +const checkedReport = createAnalysisViewResultReader( + sourceToBundleComparisonResultSchema, +); const selected = (values: readonly string[] | undefined) => values === undefined ? undefined : new Set(values); const columns = ( diff --git a/tests/boundary/process/mcpVerifierResults.test.ts b/tests/boundary/process/mcpVerifierResults.test.ts index 1d7ba70ef5..5ff69e866b 100644 --- a/tests/boundary/process/mcpVerifierResults.test.ts +++ b/tests/boundary/process/mcpVerifierResults.test.ts @@ -1,6 +1,16 @@ +import { createEvidence } from "../../../src/domain/evidence.js"; +import { + createEvidenceBundle, + type EvidenceBundle, +} from "../../../src/domain/evidenceBundle.js"; import { expect, it } from "vitest"; interface VerifierResults { + readonly requireMcpEvidenceBundle: (result: unknown) => EvidenceBundle; + readonly requireMcpLifecycleResult: ( + result: unknown, + operation: string, + ) => unknown; readonly expandMcpSessionStatus: (status: unknown) => { readonly analysis_provider_candidates: unknown; }; @@ -68,3 +78,107 @@ it("requires candidate capabilities in CLI provider output", async () => { }).analysis_provider_candidates, ).toEqual([{ ...summaryCandidate, capabilities: [] }]); }); + +const retainedBundle = (value = 7) => + createEvidenceBundle([ + createEvidence( + undefined, + { id: "fixture", name: "Fixture", version: "1" }, + { + operation: "observe", + parameters: {}, + result: { value }, + }, + ), + ]); +const bundleReply = (bundle: unknown) => ({ + content: [{ type: "text", text: JSON.stringify({ result: bundle }) }], + structuredContent: { result: bundle }, +}); + +it("authenticates complete retained bundles and preserves the text-only transport option", async () => { + const { requireMcpEvidenceBundle } = await verifierResults(); + const bundle = retainedBundle(); + expect(requireMcpEvidenceBundle(bundleReply(bundle))).toEqual(bundle); + const { structuredContent: _structured, ...textOnly } = bundleReply(bundle); + expect(requireMcpEvidenceBundle(textOnly)).toEqual(bundle); +}); + +it("rejects duplicate retained record IDs instead of selecting the first matching record", async () => { + const { requireMcpEvidenceBundle, requireMcpLifecycleResult } = + await verifierResults(); + const bundle = retainedBundle(); + const duplicate = { + ...bundle, + records: [...bundle.records, ...bundle.records], + }; + // The named wire contract admits this shape; authentication must reject it. + expect( + requireMcpLifecycleResult(bundleReply(duplicate), "get_evidence_bundle"), + ).toEqual(duplicate); + expect(() => requireMcpEvidenceBundle(bundleReply(duplicate))).toThrow( + "duplicate record IDs", + ); +}); + +it("rejects shape-valid retained Evidence whose semantic identifier was tampered", async () => { + const { requireMcpEvidenceBundle, requireMcpLifecycleResult } = + await verifierResults(); + const bundle = retainedBundle(); + const tampered = { + ...bundle, + records: bundle.records.map((record) => ({ + ...record, + normalized_result: { value: 8 }, + })), + }; + expect( + requireMcpLifecycleResult(bundleReply(tampered), "get_evidence_bundle"), + ).toEqual(tampered); + expect(() => requireMcpEvidenceBundle(bundleReply(tampered))).toThrow( + /semantic identifier/u, + ); +}); + +it("rejects noncanonical manifests even when a selected retained record is authentic", async () => { + const { requireMcpEvidenceBundle, requireMcpLifecycleResult } = + await verifierResults(); + const broken = { ...retainedBundle(), providers: [] }; + expect( + requireMcpLifecycleResult(bundleReply(broken), "get_evidence_bundle"), + ).toEqual(broken); + expect(() => requireMcpEvidenceBundle(bundleReply(broken))).toThrow( + /not canonical/u, + ); +}); + +it("rejects differing named text/structured bundle representations", async () => { + const { requireMcpEvidenceBundle } = await verifierResults(); + const text = retainedBundle(); + const structured = retainedBundle(8); + expect(() => + requireMcpEvidenceBundle({ + ...bundleReply(text), + structuredContent: { result: structured }, + }), + ).toThrow("text and structured results disagree"); +}); + +it("retains summary discovery in the generic lifecycle reader but refuses it as complete Evidence", async () => { + const { requireMcpEvidenceBundle, requireMcpLifecycleResult } = + await verifierResults(); + const summary = { + kind: "evidence-bundle-summary", + filters: {}, + total_retained_records: 0, + matching_records: 0, + unknown_revisions: 0, + records: [], + }; + expect( + requireMcpLifecycleResult(bundleReply(summary), "get_evidence_bundle"), + ).toEqual(summary); + expect(() => requireMcpEvidenceBundle(bundleReply(summary))).toThrow( + "summary metadata instead of complete Evidence", + ); +});