diff --git a/docs/native-investigation.md b/docs/native-investigation.md index a60428100a..d821c2a81c 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -434,6 +434,13 @@ nodes and edges. Node payloads are bounded to 8 MiB and serialized results to 32 MiB. Pagination returns edges whose source is on the node page and preserves stable IDs for endpoints outside that page. +Procedure, node and edge `evidence_id` values identify the source function +observations used by the trace. In an MCP session, retrieve them from the +Evidence bundle for inspection or export. A replayed source keeps the original +observation's identity; the trace also reports that replay occurred. Older +cached value traces are not replayed under the revised workflow profile, while +their historical Evidence remains available. + The one-shot CLI accepts `--evidence_output ` to save the trace and the transitive source Evidence closure named by its links. The destination is never overwritten. `--detail summary` requires `--evidence_output`; the bundle diff --git a/scripts/lib/native-value-e2e.mjs b/scripts/lib/native-value-e2e.mjs index 959a62b6d8..a5f82569ac 100644 --- a/scripts/lib/native-value-e2e.mjs +++ b/scripts/lib/native-value-e2e.mjs @@ -215,6 +215,19 @@ async function verifyNativeValueAdapters( ); assert.deepEqual(mcpEvidence.normalized_result, mcpGraph); assert.deepEqual(mcpGraph, cliGraph); + const warmReply = await client.callTool( + { + name: "trace_native_values", + arguments: { procedure, max_depth: 2, max_functions: 8, limit: 3 }, + }, + { timeout: 180000 }, + ); + const warmEvidence = parseEvidence( + requireMcpOutput(warmReply, "trace_native_values"), + ); + const warmGraph = nativeValueTraceSchema.parse( + warmEvidence.normalized_result, + ); const analyzed = await client.callTool( { name: "analyze_function", @@ -276,6 +289,25 @@ async function verifyNativeValueAdapters( "MCP omitted structured Evidence bundle", ); const bundle = requireMcpEvidenceBundle(bundleReply); + for (const [evidence, graph] of [ + [mcpEvidence, mcpGraph], + [warmEvidence, warmGraph], + ]) { + for (const source of [ + ...graph.procedures, + ...graph.nodes, + ...graph.edges, + ]) { + assert.ok(evidence.evidence_links.includes(source.evidence_id)); + assert.equal( + bundle.records.find( + (record) => record.evidence_id === source.evidence_id, + )?.operation, + "analyze_function", + `Missing source Evidence ${source.evidence_id}`, + ); + } + } for (const evidence of [ mcpEvidence, functionEvidence, diff --git a/scripts/lib/real-ghidra-boundaries.mjs b/scripts/lib/real-ghidra-boundaries.mjs index e6abb17a26..7a6776f837 100644 --- a/scripts/lib/real-ghidra-boundaries.mjs +++ b/scripts/lib/real-ghidra-boundaries.mjs @@ -504,6 +504,9 @@ export async function verifyGhidraBoundaries( // Prime both inventories before editing so stale names cannot hide in caches. await call("list_names"); await call("list_procedures"); + const traceInput = { procedure: address, max_depth: 0, max_functions: 1 }; + const traceBefore = await evidence("trace_native_values", traceInput); + const sourceBefore = traceBefore.normalized_result.procedures[0].evidence_id; const changes = { procedure: address, name: "dead", @@ -517,6 +520,16 @@ export async function verifyGhidraBoundaries( comment: changes.comment, inline_comment: changes.inline_comment, }); + const traceAfter = await evidence("trace_native_values", traceInput); + const sourceAfter = traceAfter.normalized_result.procedures[0].evidence_id; + assert.notEqual(sourceBefore, sourceAfter); + const sourceProcedure = (evidenceId) => + call("inspect_analysis_view", { + source: { kind: "retained-evidence", evidence_id: evidenceId }, + view: { kind: "native", facet: "procedure", offset: 0, limit: 1 }, + }); + assert.equal((await sourceProcedure(sourceBefore)).item.name, name); + assert.equal((await sourceProcedure(sourceAfter)).item.name, changes.name); const regexPattern = "^(a|aa)*b$"; const regexFailure = await client.callTool( { diff --git a/scripts/verify-real-kuna.mjs b/scripts/verify-real-kuna.mjs index de799e3ddd..9c50ce88c9 100644 --- a/scripts/verify-real-kuna.mjs +++ b/scripts/verify-real-kuna.mjs @@ -511,6 +511,30 @@ const verifyKuna = async ( references, }); } + if (functions.length > 0) { + const trace = await cold.call("trace_native_values", { + procedure: functions[0][1], + max_depth: 0, + max_functions: 1, + }); + const bundle = await cold.call("get_evidence_bundle", { + detail: "complete", + }); + for (const source of [ + ...trace.value.procedures, + ...trace.value.nodes, + ...trace.value.edges, + ]) { + assert.ok(trace.structured.evidence_links.includes(source.evidence_id)); + assert.equal( + bundle.value.records.find( + (record) => record.evidence_id === source.evidence_id, + )?.operation, + "analyze_function", + `Missing source Evidence ${source.evidence_id}`, + ); + } + } } catch (cause) { coldFailure = cause; } finally { diff --git a/src/android/JadxDoctor.ts b/src/android/JadxDoctor.ts index 9667f49920..27074f6ea2 100644 --- a/src/android/JadxDoctor.ts +++ b/src/android/JadxDoctor.ts @@ -1,9 +1,11 @@ -import type { RetainedResourceScope } from "../application/AnalysisProvider.js"; +import type { + RetainedResourceScope, + ProviderAvailability, +} from "../application/AnalysisProvider.js"; import type { DoctorProviderCheck, DoctorProviderInspection, } from "../application/Doctor.js"; -import type { ProviderAvailability } from "../application/AnalysisProvider.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { inspectJadxAvailability, diff --git a/src/application/AnalysisContextQueries.test.ts b/src/application/AnalysisContextQueries.test.ts index 8322fadf43..392a9b1add 100644 --- a/src/application/AnalysisContextQueries.test.ts +++ b/src/application/AnalysisContextQueries.test.ts @@ -8,6 +8,7 @@ import { import { createAnalysisExecution, type AnalysisOperationPort, + type AnalysisExecution, } from "./AnalysisProvider.js"; import { AnalysisCapabilityUnavailableError, @@ -21,7 +22,6 @@ import { primaryAnalysisError, type AnalysisError, } from "../domain/analysisErrorBase.js"; -import type { AnalysisExecution } from "./AnalysisProvider.js"; import type { Result } from "../domain/result.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { err, ok } from "../domain/result.js"; diff --git a/src/application/AnalysisProvider.ts b/src/application/AnalysisProvider.ts index 63a498d810..59cdb245ae 100644 --- a/src/application/AnalysisProvider.ts +++ b/src/application/AnalysisProvider.ts @@ -34,6 +34,8 @@ export interface ExecutionOptions { readonly snapshotContext?: "one-shot-target"; readonly signal?: AbortSignal; readonly progress?: ProgressReporter; + /** The composed workflow will cite this observation and needs its Evidence. */ + readonly retainEvidence?: true; /** * Set when a procedure selector came from provider output, such as a * revisited callee, rather than from the caller. A provider-derived diff --git a/src/application/WorkflowAnalysisProfile.ts b/src/application/WorkflowAnalysisProfile.ts index f06cf5a215..eaf1b18d41 100644 --- a/src/application/WorkflowAnalysisProfile.ts +++ b/src/application/WorkflowAnalysisProfile.ts @@ -21,5 +21,7 @@ export const workflowAnalysisProfile = ( ? { workflow_revision: 4 } : operation === "get_call_graph" ? { workflow_revision: 3 } - : { workflow_revision: 1 }), + : operation === "trace_native_values" + ? { workflow_revision: 2 } + : { workflow_revision: 1 }), }); diff --git a/src/application/WorkflowEvidence.test.ts b/src/application/WorkflowEvidence.test.ts index deed631640..62699309a2 100644 --- a/src/application/WorkflowEvidence.test.ts +++ b/src/application/WorkflowEvidence.test.ts @@ -111,6 +111,99 @@ it.each([false, true])( const provider = { id: "fixture", name: "Fixture", version: "1" }; +it("retains requested live observations and links their original producer sources", async () => { + const upstream = createEvidence(undefined, provider, { + operation: "health", + parameters: {}, + result: "source", + }); + const result = await executeComposedWorkflow( + { + execute: async () => + ok( + createAnalysisExecution("live observation", provider, { + cachePolicy: "live", + sourceEvidence: [upstream], + }), + ), + }, + async (observed) => { + const value = await observed.execute( + "analyze_function", + { procedure: "0x1" }, + { + retainEvidence: true, + }, + ); + return value.ok ? ok({ source: value.value.evidenceId ?? null }) : value; + }, + ); + if (!result.ok) throw result.error; + const source = result.sourceEvidence?.find( + (record) => record.operation === "analyze_function", + ); + expect(source).toBeDefined(); + expect(source?.evidence_links).toEqual([upstream.evidence_id]); + expect(result.value.result).toEqual({ source: source?.evidence_id }); + expect(result.value.evidenceLinks).toContain(source?.evidence_id); + expect(result.value.cachePolicy).toBe("live"); +}); + +it("keeps an existing session-issued source identity on replay", async () => { + const original = createEvidence(undefined, provider, { + operation: "analyze_function", + parameters: { procedure: "0x1" }, + result: "original", + }); + const result = await executeComposedWorkflow( + { + execute: async () => + ok({ + ...createAnalysisExecution("original", provider, { + limitations: ["replayed"], + }), + evidenceId: original.evidence_id, + }), + }, + async (observed) => { + const value = await observed.execute( + "analyze_function", + { procedure: "0x1" }, + { + retainEvidence: true, + }, + ); + return value.ok ? ok({ source: value.value.evidenceId ?? null }) : value; + }, + ); + if (!result.ok) throw result.error; + expect(result.value.result).toEqual({ source: original.evidence_id }); + expect(result.value.evidenceLinks).toEqual([original.evidence_id]); + expect(result.sourceEvidence).toBeUndefined(); +}); + +it("retains a completed requested source if the composed workflow is cancelled", async () => { + const result = await executeComposedWorkflow( + { + execute: async () => ok(createAnalysisExecution("observed", provider)), + }, + async (observed) => { + await observed.execute( + "analyze_function", + { procedure: "0x1" }, + { + retainEvidence: true, + }, + ); + return err(new AnalysisCancelledError("trace_native_values")); + }, + ); + expect(result.ok).toBe(false); + expect(result.sourceEvidence?.map((record) => record.operation)).toEqual([ + "analyze_function", + ]); +}); + it("keeps Evidence identity stable when dependent branches finish in opposite orders", async () => { const run = async (order: readonly number[]) => { const gate = () => { diff --git a/src/application/WorkflowEvidence.ts b/src/application/WorkflowEvidence.ts index 2623fa1f4a..dd3c952279 100644 --- a/src/application/WorkflowEvidence.ts +++ b/src/application/WorkflowEvidence.ts @@ -1,6 +1,7 @@ import { AnalysisWorkScope } from "./AnalysisWorkScope.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; import { jsonStringByteLength } from "../domain/jsonStringByteLength.js"; +import { createAnalysisExecutionEvidence } from "../domain/analysisObservation.js"; import { REA_WORKFLOW_PROVIDER } from "../domain/investigationProviders.js"; import { workflowAnalysisProfile } from "./WorkflowAnalysisProfile.js"; import type { @@ -101,12 +102,10 @@ export const executeComposedWorkflow = async ( const observed: AnalysisOperationPort = { resourceScope: analysis.resourceScope, execute: async (operation, parameters, options) => { - const execution = await analysis.execute(operation, parameters, { + let execution = await analysis.execute(operation, parameters, { ...options, workScope, }); - if (execution.ok && execution.value.evidenceId !== undefined) - evidenceLinks.add(execution.value.evidenceId); if (execution.ok && execution.value.sourceEvidence !== undefined) { const recorded = recordEvidenceSources( (source) => sourceLedger.record(source), @@ -114,6 +113,26 @@ export const executeComposedWorkflow = async ( ); if (!recorded.ok) return recorded; } + if ( + execution.ok && + options?.retainEvidence === true && + execution.value.evidenceId === undefined + ) { + const source = createAnalysisExecutionEvidence( + operation, + parameters, + execution.value, + "observed", + ); + const recorded = recordEvidenceSources( + (evidence) => sourceLedger.record(evidence), + [source], + ); + if (!recorded.ok) return recorded; + execution = ok({ ...execution.value, evidenceId: source.evidence_id }); + } + if (execution.ok && execution.value.evidenceId !== undefined) + evidenceLinks.add(execution.value.evidenceId); if (execution.ok && execution.value.cachePolicy === "live") cachePolicy = "live"; if (execution.ok) diff --git a/src/application/binary/AnalysisSnapshotCache.test.ts b/src/application/binary/AnalysisSnapshotCache.test.ts index bf048e6f3e..59544d1f04 100644 --- a/src/application/binary/AnalysisSnapshotCache.test.ts +++ b/src/application/binary/AnalysisSnapshotCache.test.ts @@ -1182,6 +1182,71 @@ it("does not replay pre-envelope call graphs under the revised workflow profile" ).toBe(false); }); +it("keeps older value-trace Evidence as history without replaying its source IDs", () => { + const cache = new AnalysisSnapshotCache(); + const target = ANALYSIS_SNAPSHOT_TARGET; + const profile = ANALYSIS_SNAPSHOT_PROFILE; + const operation = "trace_native_values" as const; + const parameters = { procedure: "0x1000" }; + const oldProfile = workflowAnalysisProfile(profile); + const oldResult = { + seed: "0x1000", + target_sha256: target.sha256, + nodes: [], + edges: [], + procedures: [ + { + address: "0x1000", + depth: 0, + evidence_id: `ev_${"a".repeat(64)}`, + provider: profile.provider.id, + analysis_profile_digest: profile.digest, + }, + ], + unknowns: [], + total_nodes: 0, + total_edges: 0, + decompilations: 1, + next_offset: null, + page_truncated: false, + traversal: { status: "complete" }, + truncated: false, + limitations: [], + }; + const execution = { + ...createAnalysisExecution(oldResult, REA_WORKFLOW_PROVIDER, { + subject: target, + }), + analysisProfile: oldProfile, + }; + cache.recordWorkflow({ target, profile, operation, parameters, execution }); + const evidence = createEvidence(target, REA_WORKFLOW_PROVIDER, { + operation, + parameters, + result: oldResult, + confidence: "derived", + analysisProfile: oldProfile, + }); + const exported = cache.export( + target, + profile, + createEvidenceBundle([evidence]), + ); + if (!exported.ok) throw exported.error; + const snapshot = parseAnalysisSnapshot(exported.value); + expect(snapshot.evidence_bundle.records).toContainEqual(evidence); + expect( + snapshotEvidenceForQuery(snapshot, { + target, + bindingProfile: profile, + operation, + parameters, + provider: REA_WORKFLOW_PROVIDER, + evidenceProfile: workflowAnalysisProfile(profile, operation), + }), + ).toBeUndefined(); +}); + it("declines source-bearing snapshot entries while replaying omitted and empty source controls", () => { const cache = new AnalysisSnapshotCache(); const target = ANALYSIS_SNAPSHOT_TARGET; diff --git a/src/application/binary/BinarySessionOpen.ts b/src/application/binary/BinarySessionOpen.ts index 81b4655c4f..32cb1787aa 100644 --- a/src/application/binary/BinarySessionOpen.ts +++ b/src/application/binary/BinarySessionOpen.ts @@ -12,8 +12,8 @@ import { snapshotMatchesTarget, snapshotTarget, type SnapshotBindingMismatch, + type AnalysisSnapshot, } from "../../domain/analysisSnapshot.js"; -import type { AnalysisSnapshot } from "../../domain/analysisSnapshot.js"; import { parseBinaryTarget } from "../BinaryTargetResolver.js"; import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { diff --git a/src/application/native/NativeFunctionMatchingService.ts b/src/application/native/NativeFunctionMatchingService.ts index 02e75920cf..b3e67de062 100644 --- a/src/application/native/NativeFunctionMatchingService.ts +++ b/src/application/native/NativeFunctionMatchingService.ts @@ -10,7 +10,10 @@ import { } 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 { + nativeFunctionMatchingSchema, + NATIVE_FUNCTION_MATCHING_PREDICATE, +} from "../../domain/native/functionMatching.js"; import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js"; import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; @@ -20,7 +23,6 @@ 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"; diff --git a/src/application/native/NativeValueTrace.test.ts b/src/application/native/NativeValueTrace.test.ts index 1bd9fb1c7f..88b4cb0bfb 100644 --- a/src/application/native/NativeValueTrace.test.ts +++ b/src/application/native/NativeValueTrace.test.ts @@ -1,6 +1,7 @@ import { createAnalysisExecutionEvidence } from "../../domain/analysisObservation.js"; import { describe, expect, it } from "vitest"; -import { traceNativeValues } from "./NativeValueTrace.js"; +import { traceNativeValues as traceNativeValuesCore } from "./NativeValueTrace.js"; +import { executeComposedWorkflow } from "../WorkflowEvidence.js"; import { createAnalysisExecution, type AnalysisOperationPort, @@ -26,6 +27,27 @@ import { valueTraceProvider as provider, valueTraceSubject as subject, } from "./NativeValueTrace.fixture.js"; + +const traceNativeValues: typeof traceNativeValuesCore = async ( + source, + parameters, + signal, +) => { + const result = await executeComposedWorkflow(source, (observed) => + traceNativeValuesCore(observed, parameters, signal), + ); + return result.ok ? ok(result.value.result) : result; +}; + +it("does not return a source ID without an Evidence owner", async () => { + const result = await traceNativeValuesCore(valueTraceDiamond().port, { + procedure: "0x1000", + }); + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.error._tag).toBe("AnalysisCapabilityUnavailableError"); +}); + const analysis: AnalysisOperationPort = { execute: async (operation, parameters) => { if (operation === "resolve_native_call_targets") diff --git a/src/application/native/NativeValueTrace.ts b/src/application/native/NativeValueTrace.ts index de1e2efb68..c80aaad55a 100644 --- a/src/application/native/NativeValueTrace.ts +++ b/src/application/native/NativeValueTrace.ts @@ -7,6 +7,7 @@ import { import { createAnalysisExecutionEvidence } from "../../domain/analysisObservation.js"; import { AnalysisCancelledError, + AnalysisCapabilityUnavailableError, AnalysisOutputError, } from "../../domain/analysisErrorCore.js"; import { err, ok } from "../../domain/result.js"; @@ -116,6 +117,7 @@ export const traceNativeValues = async ( { ...(signal === undefined ? {} : { signal }), ...(next.depth > 0 ? { selectorOrigin: "provider" as const } : {}), + retainEvidence: true, }, ); decompilations++; @@ -129,12 +131,13 @@ export const traceNativeValues = async ( } continue; } - const evidence = createAnalysisExecutionEvidence( - "analyze_function", - { procedure: next.procedure }, - execution.value, - "observed", - ); + const partialEvidence = () => + createAnalysisExecutionEvidence( + "analyze_function", + { procedure: next.procedure }, + execution.value, + "observed", + ); const parsed = functionDossierSchema.safeParse(execution.value.result); if (!parsed.success || execution.value.subject == null) return err( @@ -142,7 +145,7 @@ export const traceNativeValues = async ( "trace_native_values", "Function dossier lacks validated facts or artifact identity", parsed.success ? undefined : { cause: parsed.error }, - ).retainPartialObservation(evidence), + ).retainPartialObservation(partialEvidence()), ); const dossier = parsed.data; const address = dossier.procedure.address; @@ -155,9 +158,17 @@ export const traceNativeValues = async ( new AnalysisOutputError( "trace_native_values", "Cross-function target digest mismatch", - ).retainPartialObservation(evidence), + ).retainPartialObservation(partialEvidence()), + ); + const evidenceId = execution.value.evidenceId; + if (evidenceId === undefined) + return err( + new AnalysisCapabilityUnavailableError( + "rea", + "trace_native_values", + "The function observation has no Evidence retention owner.", + ).retainPartialObservation(partialEvidence()), ); - const evidenceId = evidence.evidence_id; procedures.push({ address, depth: next.depth, diff --git a/src/application/native/NativeValueTraceStops.test.ts b/src/application/native/NativeValueTraceStops.test.ts index fae841232e..40a1568d63 100644 --- a/src/application/native/NativeValueTraceStops.test.ts +++ b/src/application/native/NativeValueTraceStops.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { AnalysisOperationPort } from "../AnalysisProvider.js"; +import { ok } from "../../domain/result.js"; import { AnalysisCancelledError, AnalysisCapabilityUnavailableError, @@ -34,6 +35,21 @@ describe("native value trace child stops", () => { 0, VALUE_TRACE_DIAMOND_READS.indexOf(read) + 1, ); + // The trace cites each completed dossier. These cases cover stop behavior, + // so the fixture only has to satisfy that precondition. + const withSourceEvidence = ( + port: AnalysisOperationPort, + ): AnalysisOperationPort => ({ + execute: async (operation, parameters, options) => { + const execution = await port.execute(operation, parameters, options); + if (!execution.ok || execution.value.evidenceId !== undefined) + return execution; + return ok({ + ...execution.value, + evidenceId: `ev_${"ab".repeat(32)}`, + }); + }, + }); it.each(childReads)( "returns a cancelled %s read without a partial trace", @@ -43,7 +59,7 @@ describe("native value trace child stops", () => { [read]: cancelled, }); - const result = await traceNativeValues(port, { + const result = await traceNativeValues(withSourceEvidence(port), { procedure: "0x1000", max_depth: 1, }); @@ -86,7 +102,7 @@ describe("native value trace child stops", () => { }; const result = await traceNativeValues( - aborting, + withSourceEvidence(aborting), { procedure: "0x1000", max_depth: 1 }, controller.signal, ); @@ -114,7 +130,7 @@ describe("native value trace child stops", () => { "resolve_native_call_targets:0x1008": failure, }); - const result = await traceNativeValues(port, { + const result = await traceNativeValues(withSourceEvidence(port), { procedure: "0x1000", max_depth: 1, }); diff --git a/src/browser/history/NodeCaptureAdapter.ts b/src/browser/history/NodeCaptureAdapter.ts index 785b157220..6b0a7abd74 100644 --- a/src/browser/history/NodeCaptureAdapter.ts +++ b/src/browser/history/NodeCaptureAdapter.ts @@ -10,8 +10,8 @@ import { NODE_CAPTURE_HEAP_ENFORCED, captureDecoderProcessMemoryLimitBytes, } from "./CaptureRelease.js"; -import { QLOG_CAPTURE_PROVIDER_IDENTITY } from "./CaptureProviderIdentities.js"; import { + QLOG_CAPTURE_PROVIDER_IDENTITY, HAR_CAPTURE_PROVIDER_IDENTITY, SAZ_CAPTURE_PROVIDER_IDENTITY, } from "./CaptureProviderIdentities.js"; diff --git a/tests/boundary/mcp/enhancedTools.01-lists-the-complete-target-open.test.ts b/tests/boundary/mcp/enhancedTools.01-lists-the-complete-target-open.test.ts index 68313a7be4..d1eacd2063 100644 --- a/tests/boundary/mcp/enhancedTools.01-lists-the-complete-target-open.test.ts +++ b/tests/boundary/mcp/enhancedTools.01-lists-the-complete-target-open.test.ts @@ -440,14 +440,14 @@ describe("enhanced MCP tools", () => { ]), }, }); + // A fixed adapter has no session to retain source Evidence for the trace. expect(results[13]).toMatchObject({ - target_sha256: "a".repeat(64), - decompilations: 1, - unknowns: [ - expect.objectContaining({ - reason: "Provider has no native def-use model", - }), - ], + error: { + code: "capability_unavailable", + details: { + operation: "trace_native_values", + }, + }, }); expect(results[14]).toMatchObject({ counts: { selected: 6, written: 6, failed: 0, excluded: 0 }, diff --git a/tests/boundary/mcp/nativeValueTraceCleanupIncomplete.test.ts b/tests/boundary/mcp/nativeValueTraceCleanupIncomplete.test.ts index 6e28b05750..29e1a695ba 100644 --- a/tests/boundary/mcp/nativeValueTraceCleanupIncomplete.test.ts +++ b/tests/boundary/mcp/nativeValueTraceCleanupIncomplete.test.ts @@ -159,10 +159,15 @@ describe("trace_native_values after a validated seed", () => { "keeps an ordinary failure from $read as a located unknown and continues", async ({ read, stop, ordinaryReads }) => { const operation = read.split(":")[0] ?? ""; - const { port, reads } = failingDiamond({ - [read]: childFailure(operation, false), - }); - const client = await connect(port); + // A completed trace cites source Evidence, so continuation needs a + // session that can retain it. A fixed adapter still refuses that result. + const { port, reads } = failingDiamond( + { + [read]: childFailure(operation, false), + }, + { sessionSubject: true }, + ); + const { client } = await openSession(port); const response = await client.callTool(trace); diff --git a/tests/boundary/mcp/nativeValueTraceCompleteness.test.ts b/tests/boundary/mcp/nativeValueTraceCompleteness.test.ts index 35e6332b2e..04ddd5abfb 100644 --- a/tests/boundary/mcp/nativeValueTraceCompleteness.test.ts +++ b/tests/boundary/mcp/nativeValueTraceCompleteness.test.ts @@ -2,9 +2,17 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { afterEach, expect, it } from "vitest"; import { valueTraceDiamond } from "../../../src/application/native/NativeValueTrace.fixture.js"; import { parseEvidence } from "../../../src/domain/evidence.js"; +import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js"; +import { z } from "zod"; import { nativeValueTraceSchema } from "../../../src/domain/native/nativeValueTrace.js"; import { advertisedMcpToolSchemas } from "../../fixtures/advertisedMcpTool.js"; import { createServer } from "../../../src/server/createServer.js"; +import { ok } from "../../../src/domain/result.js"; +import { + createBinarySessionTargets, + createTestBinarySession, +} from "../../fixtures/binarySession.js"; +import { observed } from "../../fixtures/analysisExecution.js"; import { closeEnhancedToolResources, resources, @@ -15,8 +23,19 @@ afterEach(closeEnhancedToolResources); it.each(["full", "compact"] as const)( "separates page omission from named traversal budgets through the advertised %s MCP contract", async (profile) => { + const [target] = await createBinarySessionTargets(); + const analysis = valueTraceDiamond({ sessionSubject: true }).port; + const session = createTestBinarySession(() => ({ + execute: (operation, parameters, options) => + operation === "health" + ? Promise.resolve(observed(null)) + : analysis.execute(operation, parameters, options), + close: async () => ok(null), + })); + const opened = await session.open(target); + expect(opened.ok).toBe(true); const server = createServer( - { kind: "fixed", analysis: valueTraceDiamond().port }, + { kind: "session", session }, { environment: { REA_MCP_OUTPUT_SCHEMA_PROFILE: profile } }, ); const client = new Client({ name: "value-trace-test", version: "1.0.0" }); @@ -59,6 +78,24 @@ it.each(["full", "compact"] as const)( traversal: { status: "complete" }, truncated: false, }); + const bundleReply = await client.callTool({ + name: "get_evidence_bundle", + arguments: { detail: "complete" }, + }); + expect(bundleReply.isError).not.toBe(true); + const bundle = z + .object({ result: evidenceBundleSchema }) + .parse(bundleReply.structuredContent).result; + for (const source of [ + ...complete.procedures, + ...complete.nodes, + ...complete.edges, + ]) + expect( + bundle.records.some( + (record) => record.evidence_id === source.evidence_id, + ), + ).toBe(true); const pages = []; for (let offset: number | null = 0; offset !== null;) { const page = await trace({ ...request, offset, limit: 4 }); diff --git a/tests/boundary/mcp/nativeValueTraceFailures.test.ts b/tests/boundary/mcp/nativeValueTraceFailures.test.ts index 4d0540411b..c66c48fcb5 100644 --- a/tests/boundary/mcp/nativeValueTraceFailures.test.ts +++ b/tests/boundary/mcp/nativeValueTraceFailures.test.ts @@ -3,10 +3,24 @@ import { AnalysisInputError } from "../../../src/domain/analysisErrorCore.js"; import { NoBinaryOpenError } from "../../../src/domain/configurationErrors.js"; import { err } from "../../../src/domain/result.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { valueTraceDiamond } from "../../../src/application/native/NativeValueTrace.fixture.js"; import { closeEnhancedToolResources, connect } from "./enhancedToolsHarness.js"; afterEach(closeEnhancedToolResources); +it("refuses a completed trace when the adapter has no source Evidence owner", async () => { + const client = await connect(valueTraceDiamond().port); + const result = await client.callTool({ + name: "trace_native_values", + arguments: { procedure: "0x1000" }, + }); + expect(result.isError).toBe(true); + expect(parseMcpToolError(result).error).toMatchObject({ + code: "capability_unavailable", + message: expect.stringContaining("source Evidence"), + }); +}); + it.each([ { failure: new AnalysisInputError("analyze_function", { diff --git a/tests/process-global/hopperDownloadHttpFailure.test.ts b/tests/process-global/hopperDownloadHttpFailure.test.ts index 98cd0c707a..07cd9cca02 100644 --- a/tests/process-global/hopperDownloadHttpFailure.test.ts +++ b/tests/process-global/hopperDownloadHttpFailure.test.ts @@ -95,7 +95,7 @@ const vendor = ( return { fetch }; }; const failed = (status: number, cancel?: () => void) => () => - new Response(new ReadableStream({ ...(cancel ? { cancel } : {}) }), { + new Response(new ReadableStream(cancel ? { cancel } : {}), { status, });