From 835904e4ee44d380e6d52b08e32ecceeecbfbe03 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 25 Oct 2026 04:36:05 +0800 Subject: [PATCH] refactor(evidence): carry producer-owned source records Carry the final artifact inspection's exact nested source Evidence through AnalysisExecution. Derive links in the shared Evidence builder instead of reparsing the result in CLI and MCP adapters. Retain validation for unknown and historical Evidence inputs. Move the shared source recorder into application ownership and migrate its consumers. Record sources before parents; keep source recording failures from publishing summaries. Decline snapshot entries whose source closure the current entry format cannot represent, while retaining investigation Evidence. Validation: actual ASAR regression failed before the change with missing links and passes afterward; exact nested identity, source recorder refusal, final-close cancellation and cleanup failure, historical authentication, snapshot controls and public CLI/MCP workflows. Fresh runtime/catalog build, full static/docs, discovery and duplication checks pass. Installed native providers and platform-specific skipped cases remain unverified. --- src/application/AnalysisProvider.ts | 5 + src/application/DirectAnalysis.ts | 37 ++----- .../binary/AnalysisSnapshotCache.test.ts | 57 +++++++++++ .../binary/AnalysisSnapshotCache.ts | 3 + src/application/binary/BinarySession.ts | 33 +++---- .../investigation/recordEvidenceSources.ts} | 10 +- src/artifacts/ArtifactProvider.ts | 6 +- src/domain/analysisObservation.ts | 4 + src/domain/artifactInspection.ts | 25 +++-- .../compareExportShapes.ts | 4 +- .../compareSourceToBundle.ts | 4 +- .../compareVersions.ts | 4 +- .../obligationLedger.ts | 4 +- .../projectMobileApplicationGraph.ts | 4 +- .../registerApplicationTools/traceFeature.ts | 4 +- .../traceSemantics.ts | 9 +- src/server/registerArtifactComparisonTool.ts | 4 +- src/server/registerArtifactTools.ts | 7 -- src/server/registerBundleComparisonTool.ts | 4 +- src/server/registerEvidenceTools.ts | 48 +++------ src/server/registerFunctionComparisonTool.ts | 4 +- .../compareManagedMembers.ts | 4 +- .../projectManagedApplicationGraph.ts | 4 +- .../verifyManagedNativeBoundaries.ts | 4 +- .../registerNativeFunctionMatchingTool.ts | 4 +- src/server/registerProcessComparisonTool.ts | 4 +- src/server/registerWasmTools.ts | 4 +- tests/boundary/mcp/artifactMcp.test.ts | 90 ++++++++++++++++- .../artifactProvider.directAnalysis.test.ts | 42 ++++++++ .../composition/directAnalysisCleanup.test.ts | 97 ++++++++++++++++++- 30 files changed, 394 insertions(+), 139 deletions(-) rename src/{server/sessionEvidence.ts => application/investigation/recordEvidenceSources.ts} (55%) diff --git a/src/application/AnalysisProvider.ts b/src/application/AnalysisProvider.ts index a0621d2afb..b216afa1f8 100644 --- a/src/application/AnalysisProvider.ts +++ b/src/application/AnalysisProvider.ts @@ -11,6 +11,7 @@ import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js"; import type { JsonValue } from "../domain/jsonValue.js"; import type { Result } from "../domain/result.js"; import type { + Evidence, EvidenceLocation, EvidenceSubjectTarget, } from "../domain/evidence.js"; @@ -72,6 +73,7 @@ interface CreateAnalysisExecutionOptions { readonly relationshipCoverage?: RelationshipCoverage; readonly referencesTruncated?: boolean; readonly locations?: readonly EvidenceLocation[]; + readonly sourceEvidence?: readonly Evidence[]; readonly subject?: EvidenceSubjectTarget | null; } @@ -122,6 +124,9 @@ export function createAnalysisExecution( ? {} : { referencesTruncated: options.referencesTruncated }), locations: [...(options.locations ?? [])], + ...(options.sourceEvidence === undefined + ? {} + : { sourceEvidence: Object.freeze([...options.sourceEvidence]) }), ...(options.subject === undefined ? {} : { subject: options.subject }), }; } diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index acac584bd8..462d0a7100 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -8,10 +8,7 @@ import type { SessionProviderRoute } from "./binary/SessionProviderRouter.js"; import type { ResolvedSessionOpen } from "./binary/BinarySessionOpen.js"; import { silentLogger } from "../logger.js"; import type { Logger } from "pino"; -import { - createEvidence, - withEvidenceCallerInputPath, -} from "../domain/evidence.js"; +import { withEvidenceCallerInputPath } from "../domain/evidence.js"; import { createImmutableEvidenceBundle } from "../domain/evidenceBundle.js"; import type { Evidence } from "../domain/evidence.js"; import type { NativeToolName } from "../contracts/native/nativeToolContracts.js"; @@ -58,7 +55,6 @@ import { recordWorkflowUnknowns, } from "./WorkflowEvidence.js"; import type { AnalysisProviderSelector } from "../contracts/providerSelection.js"; -import { artifactInspectionResultSchema } from "../domain/artifactInspection.js"; import { resolveXrefsAddress } from "./XrefsAddressResolution.js"; import { AnalysisCancelledError, @@ -644,30 +640,17 @@ const executeAnalysisTool = async (input: { ); } if (!result.ok) return result; - const evidence = createEvidence( - result.value.subject === undefined - ? input.openedTarget - : (result.value.subject ?? undefined), - result.value.provider, + const evidence = createAnalysisExecutionEvidence( + tool, + input.evidenceParameters, { - operation: tool, - parameters: input.evidenceParameters, - result: result.value.result, - ...(result.value.analysisProfile === undefined - ? {} - : { analysisProfile: result.value.analysisProfile }), - rawResult: result.value.rawResult, - limitations: result.value.limitations, - ...(result.value.relationshipCoverage === undefined - ? {} - : { relationshipCoverage: result.value.relationshipCoverage }), - locations: result.value.locations, - evidenceLinks: - tool === "inspect_artifact" - ? artifactInspectionResultSchema.parse(result.value.result) - .evidence_links - : [], + ...result.value, + subject: + result.value.subject === undefined + ? input.openedTarget + : result.value.subject, }, + "observed", ); return ok({ evidence }); }; diff --git a/src/application/binary/AnalysisSnapshotCache.test.ts b/src/application/binary/AnalysisSnapshotCache.test.ts index f895fc9a15..39441cf936 100644 --- a/src/application/binary/AnalysisSnapshotCache.test.ts +++ b/src/application/binary/AnalysisSnapshotCache.test.ts @@ -930,6 +930,63 @@ it("does not replay pre-envelope call graphs under the revised workflow profile" ).toBe(false); }); +it("declines source-bearing snapshot entries while replaying omitted and empty source controls", () => { + const cache = new AnalysisSnapshotCache(); + const target = ANALYSIS_SNAPSHOT_TARGET; + const profile = ANALYSIS_SNAPSHOT_PROFILE; + const source = createEvidence(target, ANALYSIS_SNAPSHOT_PROVIDER, { + operation: "source", + parameters: {}, + result: "observed source", + }); + const parameters = { address: "0x1000", document: "fixture" }; + cache.record({ + target, + profile, + operation: "procedure_info", + parameters, + execution: createAnalysisExecution( + "parent observation", + ANALYSIS_SNAPSHOT_PROVIDER, + { + subject: target, + analysisProfile: profile, + sourceEvidence: [source], + }, + ), + }); + expect( + cache.lookup(target, profile, "procedure_info", parameters), + ).toBeUndefined(); + expect(cache.entries()).toEqual([]); + + for (const sourceEvidence of [undefined, []] as const) { + const control = { + address: sourceEvidence === undefined ? "0x2000" : "0x3000", + document: "fixture", + }; + cache.record({ + target, + profile, + operation: "procedure_info", + parameters: control, + execution: createAnalysisExecution( + "source-free observation", + ANALYSIS_SNAPSHOT_PROVIDER, + { + subject: target, + analysisProfile: profile, + ...(sourceEvidence === undefined ? {} : { sourceEvidence }), + }, + ), + }); + expect( + cache.lookup(target, profile, "procedure_info", control)?.result, + ).toBe("source-free observation"); + } + expect(cache.entries()).toHaveLength(2); +}); + describe("executions that report truncated references", () => { const record = (referencesTruncated: boolean | undefined) => { const cache = new AnalysisSnapshotCache(); diff --git a/src/application/binary/AnalysisSnapshotCache.ts b/src/application/binary/AnalysisSnapshotCache.ts index dad37c58b3..4db71038ff 100644 --- a/src/application/binary/AnalysisSnapshotCache.ts +++ b/src/application/binary/AnalysisSnapshotCache.ts @@ -436,6 +436,9 @@ export class AnalysisSnapshotCache { // The snapshot format does not carry reference truncation; a replay would // present a capped read as complete, so it is not recorded. if (execution.referencesTruncated === true) return; + // Provider snapshot entries do not carry execution source records. Replay + // must not discard the source closure that supplies the parent's Evidence links. + if ((execution.sourceEvidence?.length ?? 0) > 0) return; const queryId = analysisQueryId( snapshotTarget(target), snapshotBinding(profile), diff --git a/src/application/binary/BinarySession.ts b/src/application/binary/BinarySession.ts index b9ac301fd2..908ecc82ec 100644 --- a/src/application/binary/BinarySession.ts +++ b/src/application/binary/BinarySession.ts @@ -13,7 +13,7 @@ import { import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { err, ok, type Result } from "../../domain/result.js"; import type { JsonValue } from "../../domain/jsonValue.js"; -import { createEvidence } from "../../domain/evidence.js"; +import { createAnalysisExecutionEvidence } from "../../domain/analysisObservation.js"; import type { AnalysisClient, AnalysisExecution, @@ -33,6 +33,7 @@ import { type SessionProviderRoute, } from "./SessionProviderRouter.js"; import { InvestigationRecords } from "../investigation/InvestigationRecords.js"; +import { recordEvidenceSources } from "../investigation/recordEvidenceSources.js"; import { BinarySessionRecords } from "./BinarySessionRecords.js"; import { binarySessionStatus } from "./BinarySessionStatus.js"; import { @@ -1256,26 +1257,24 @@ export class BinarySession name !== "inspect_keyed_archive" && name !== "trace_dylib_resolution" ) { - const evidence = createEvidence( - execution.value.subject === undefined - ? active.target - : (execution.value.subject ?? undefined), - execution.value.provider, + const evidence = createAnalysisExecutionEvidence( + name, + arguments_, { - operation: name, - parameters: arguments_, - result: execution.value.result, + ...execution.value, analysisProfile: profile, - rawResult: execution.value.rawResult, - limitations: execution.value.limitations, - ...(execution.value.relationshipCoverage === undefined - ? {} - : { - relationshipCoverage: execution.value.relationshipCoverage, - }), - locations: execution.value.locations, + subject: + execution.value.subject === undefined + ? active.target + : execution.value.subject, }, + "observed", ); + const sourcesRecorded = recordEvidenceSources( + (source) => this.recordEvidence(source), + execution.value.sourceEvidence ?? [], + ); + if (!sourcesRecorded.ok) return sourcesRecorded; const recorded = this.recordEvidence(evidence); if (!recorded.ok) return recorded; const retained = { ...execution.value, evidenceId: evidence.evidence_id }; diff --git a/src/server/sessionEvidence.ts b/src/application/investigation/recordEvidenceSources.ts similarity index 55% rename from src/server/sessionEvidence.ts rename to src/application/investigation/recordEvidenceSources.ts index a450f31afe..05e7f2ff54 100644 --- a/src/server/sessionEvidence.ts +++ b/src/application/investigation/recordEvidenceSources.ts @@ -1,10 +1,10 @@ -import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; -import { EvidenceIntegrityError } from "../domain/evidenceErrors.js"; -import type { Evidence } from "../domain/evidence.js"; -import { ok, type Result } from "../domain/result.js"; +import type { EvidenceWriter } from "./InvestigationRecordPort.js"; +import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js"; +import type { Evidence } from "../../domain/evidence.js"; +import { ok, type Result } from "../../domain/result.js"; /** Record source evidence in order, stopping at the first session rejection. */ -export const recordSessionEvidenceSources = ( +export const recordEvidenceSources = ( recordEvidence: EvidenceWriter["recordEvidence"] | undefined, sources: readonly Evidence[], ): Result => { diff --git a/src/artifacts/ArtifactProvider.ts b/src/artifacts/ArtifactProvider.ts index 8c7f43a80d..e6e5f04336 100644 --- a/src/artifacts/ArtifactProvider.ts +++ b/src/artifacts/ArtifactProvider.ts @@ -41,7 +41,10 @@ import { artifactCapabilities } from "./ArtifactProviderMetadata.js"; import { ARTIFACT_GRAPH_PROVIDER } from "../domain/investigationProviders.js"; import { createEvidence } from "../domain/evidence.js"; import { freezeJsonSnapshot } from "../domain/immutableJson.js"; -import { createArtifactInspection } from "../domain/artifactInspection.js"; +import { + artifactInspectionSources, + createArtifactInspection, +} from "../domain/artifactInspection.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import type { ResolvedAppBundleResourceRoot } from "../application/AppBundleExecutable.js"; import type { BundleResourceRoot } from "../domain/apple/bundleResourceRoot.js"; @@ -472,6 +475,7 @@ class ArtifactClient implements AnalysisClient { createAnalysisExecution(result, ARTIFACT_GRAPH_PROVIDER, { rawResult: null, limitations: result.limitations, + sourceEvidence: artifactInspectionSources(result), subject, locations: [], }), diff --git a/src/domain/analysisObservation.ts b/src/domain/analysisObservation.ts index 51d8154f15..06ed145a01 100644 --- a/src/domain/analysisObservation.ts +++ b/src/domain/analysisObservation.ts @@ -20,6 +20,8 @@ export interface AnalysisObservation { /** Provider-owned completeness assessment for caller/callee observations. */ readonly relationshipCoverage?: RelationshipCoverage; readonly locations: readonly EvidenceLocation[]; + /** Atomic source records supplied by the producer, with links derived from their identities. */ + readonly sourceEvidence?: readonly Evidence[]; /** * Missing means the selected operation's default subject should be bound by * its caller. Explicit null means the observation has no file subject. @@ -53,6 +55,8 @@ export const createAnalysisExecutionEvidence = ( ? {} : { relationshipCoverage: execution.relationshipCoverage }), locations: execution.locations, + evidenceLinks: + execution.sourceEvidence?.map(({ evidence_id }) => evidence_id) ?? [], confidence, }, options, diff --git a/src/domain/artifactInspection.ts b/src/domain/artifactInspection.ts index 23ec14742a..c0d99083a3 100644 --- a/src/domain/artifactInspection.ts +++ b/src/domain/artifactInspection.ts @@ -464,7 +464,21 @@ const inspectionCoverage = ( return "complete-within-substeps"; }; -/** Own the atomic source closure published or retained with an inspection. */ +/** Select source records from a typed inspection, preserving sealed child ownership. */ +export const artifactInspectionSources = ( + inspection: ArtifactInspectionResult, +): readonly Evidence[] => { + const substeps = immutableJsonSnapshotMember(inspection, "substeps"); + return Object.freeze( + inspection.substeps.map((step, index) => { + const ownedStep = immutableJsonSnapshotMember(substeps, String(index)); + immutableJsonSnapshotMember(ownedStep, "evidence"); + return step.evidence; + }), + ); +}; + +/** Validate an unknown inspection before selecting its atomic source closure. */ export const readArtifactInspectionSources = ( result: unknown, ): Result => { @@ -473,12 +487,5 @@ export const readArtifactInspectionSources = ( const inspection = canBorrowParsedJsonSnapshot(result, parsed.value) ? result : parsed.value; - const substeps = immutableJsonSnapshotMember(inspection, "substeps"); - return ok( - inspection.substeps.map((step, index) => { - const ownedStep = immutableJsonSnapshotMember(substeps, String(index)); - immutableJsonSnapshotMember(ownedStep, "evidence"); - return step.evidence; - }), - ); + return ok(artifactInspectionSources(inspection)); }; diff --git a/src/server/registerApplicationTools/compareExportShapes.ts b/src/server/registerApplicationTools/compareExportShapes.ts index 5b73070424..81123d7b1e 100644 --- a/src/server/registerApplicationTools/compareExportShapes.ts +++ b/src/server/registerApplicationTools/compareExportShapes.ts @@ -1,6 +1,6 @@ import type { EvidenceMcpServer } from "../EvidenceMcpServer.js"; import { runAdmittedToolOperation } from "../admittedToolOperation.js"; -import { recordSessionEvidenceSources } from "../sessionEvidence.js"; +import { recordEvidenceSources } from "../../application/investigation/recordEvidenceSources.js"; import { compareJavaScriptExportShapesEvidence } from "../../application/javascript/JavaScriptApplicationWorkflowService.js"; import { toolContract } from "../../contracts/toolContracts.js"; @@ -39,7 +39,7 @@ export const registerCompareJavaScriptExportShapesTool = ( ); if (!result.ok) return server.delivery.toCallToolResult(result, contract); - const recorded = recordSessionEvidenceSources( + const recorded = recordEvidenceSources( options.recordEvidence, result.value.sources, ); diff --git a/src/server/registerApplicationTools/compareSourceToBundle.ts b/src/server/registerApplicationTools/compareSourceToBundle.ts index 1bc6c6a647..2f2ea545dc 100644 --- a/src/server/registerApplicationTools/compareSourceToBundle.ts +++ b/src/server/registerApplicationTools/compareSourceToBundle.ts @@ -9,7 +9,7 @@ import { import type { EvidenceMcpServer } from "../EvidenceMcpServer.js"; import { runAdmittedToolOperation } from "../admittedToolOperation.js"; import { resolveApplicationEvidenceRequest } from "../../application/EvidenceInputResolver.js"; -import { recordSessionEvidenceSources } from "../sessionEvidence.js"; +import { recordEvidenceSources } from "../../application/investigation/recordEvidenceSources.js"; import { compareSourceToBundleEvidence } from "../../application/javascript/JavaScriptApplicationWorkflowService.js"; import { toolContract } from "../../contracts/toolContracts.js"; @@ -96,7 +96,7 @@ export const registerCompareSourceToBundleTool = ( ); if (!selected.ok) return server.delivery.toCallToolResult(selected, contract); - const recorded = recordSessionEvidenceSources( + const recorded = recordEvidenceSources( options.recordEvidence, result.value.sources, ); diff --git a/src/server/registerApplicationTools/compareVersions.ts b/src/server/registerApplicationTools/compareVersions.ts index c98acc7b79..d2a5580dda 100644 --- a/src/server/registerApplicationTools/compareVersions.ts +++ b/src/server/registerApplicationTools/compareVersions.ts @@ -1,6 +1,6 @@ import type { EvidenceMcpServer } from "../EvidenceMcpServer.js"; import { runAdmittedToolOperation } from "../admittedToolOperation.js"; -import { recordSessionEvidenceSources } from "../sessionEvidence.js"; +import { recordEvidenceSources } from "../../application/investigation/recordEvidenceSources.js"; import { compareApplicationVersionsEvidence } from "../../application/javascript/JavaScriptApplicationWorkflowService.js"; import { toolContract } from "../../contracts/toolContracts.js"; @@ -36,7 +36,7 @@ export const registerCompareApplicationVersionsTool = ( ); if (!result.ok) return server.delivery.toCallToolResult(result, compareContract); - const recorded = recordSessionEvidenceSources( + const recorded = recordEvidenceSources( options.recordEvidence, result.value.sources, ); diff --git a/src/server/registerApplicationTools/obligationLedger.ts b/src/server/registerApplicationTools/obligationLedger.ts index 9bd9aa9bda..67d02773cc 100644 --- a/src/server/registerApplicationTools/obligationLedger.ts +++ b/src/server/registerApplicationTools/obligationLedger.ts @@ -1,6 +1,6 @@ import type { EvidenceMcpServer } from "../EvidenceMcpServer.js"; import { runAdmittedToolOperation } from "../admittedToolOperation.js"; -import { recordSessionEvidenceSources } from "../sessionEvidence.js"; +import { recordEvidenceSources } from "../../application/investigation/recordEvidenceSources.js"; import { buildResolvedReconstructionObligationLedgerEvidence } from "../../application/ReconstructionObligationLedgerService.js"; import { resolveReconstructionObligationLedgerRequest } from "../../application/ReconstructionObligationLedgerRequest.js"; @@ -45,7 +45,7 @@ export const registerReconstructionObligationLedgerTool = ( ); if (!result.ok) return server.delivery.toCallToolResult(result, contract); - const recorded = recordSessionEvidenceSources( + const recorded = recordEvidenceSources( options.recordEvidence, resolved.value.sources.bundle.records, ); diff --git a/src/server/registerApplicationTools/projectMobileApplicationGraph.ts b/src/server/registerApplicationTools/projectMobileApplicationGraph.ts index 6f8b2a5056..4ab5b4ff53 100644 --- a/src/server/registerApplicationTools/projectMobileApplicationGraph.ts +++ b/src/server/registerApplicationTools/projectMobileApplicationGraph.ts @@ -10,7 +10,7 @@ import { toolContract } from "../../contracts/toolContracts.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import type { Result } from "../../domain/result.js"; import type { InventoryProjectionEvidenceResult } from "../../application/InventoryProjectionEvidence.js"; -import { recordSessionEvidenceSources } from "../sessionEvidence.js"; +import { recordEvidenceSources } from "../../application/investigation/recordEvidenceSources.js"; import { logToolExecution } from "../toolLogging.js"; import { toolRegistrationOptions } from "../toolRegistrationOptions.js"; import { recordResult } from "./helpers.js"; @@ -67,7 +67,7 @@ const registerProjection = (context: { ); if (!result.ok) return server.delivery.toCallToolResult(result, contract); - const recordedSources = recordSessionEvidenceSources( + const recordedSources = recordEvidenceSources( options.recordEvidence, result.value.sources, ); diff --git a/src/server/registerApplicationTools/traceFeature.ts b/src/server/registerApplicationTools/traceFeature.ts index 5db4253a35..c808afcebe 100644 --- a/src/server/registerApplicationTools/traceFeature.ts +++ b/src/server/registerApplicationTools/traceFeature.ts @@ -1,6 +1,6 @@ import type { EvidenceMcpServer } from "../EvidenceMcpServer.js"; import { runAdmittedToolOperation } from "../admittedToolOperation.js"; -import { recordSessionEvidenceSources } from "../sessionEvidence.js"; +import { recordEvidenceSources } from "../../application/investigation/recordEvidenceSources.js"; import { traceApplicationFeatureEvidence } from "../../application/javascript/JavaScriptApplicationWorkflowService.js"; import { toolContract } from "../../contracts/toolContracts.js"; @@ -36,7 +36,7 @@ export const registerTraceFeatureTool = ( ); if (!result.ok) return server.delivery.toCallToolResult(result, traceContract); - const recorded = recordSessionEvidenceSources( + const recorded = recordEvidenceSources( options.recordEvidence, result.value.sources, ); diff --git a/src/server/registerApplicationTools/traceSemantics.ts b/src/server/registerApplicationTools/traceSemantics.ts index 14eae128a9..424a53f60f 100644 --- a/src/server/registerApplicationTools/traceSemantics.ts +++ b/src/server/registerApplicationTools/traceSemantics.ts @@ -1,7 +1,7 @@ import type { EvidenceMcpServer } from "../EvidenceMcpServer.js"; import { runAdmittedToolOperation } from "../admittedToolOperation.js"; import { resolveApplicationEvidenceRequest } from "../../application/EvidenceInputResolver.js"; -import { recordSessionEvidenceSources } from "../sessionEvidence.js"; +import { recordEvidenceSources } from "../../application/investigation/recordEvidenceSources.js"; import { traceJavaScriptSemanticsEvidenceValidated } from "../../application/javascript/JavaScriptSemanticTraceService.js"; import { toolContract } from "../../contracts/toolContracts.js"; @@ -44,10 +44,9 @@ export const registerTraceJavaScriptSemanticsTool = ( ); if (!result.ok) return server.delivery.toCallToolResult(result, contract); - const recorded = recordSessionEvidenceSources( - options.recordEvidence, - [parsed.application], - ); + const recorded = recordEvidenceSources(options.recordEvidence, [ + parsed.application, + ]); if (!recorded.ok) return server.delivery.toCallToolResult(recorded, contract); return recordResult( diff --git a/src/server/registerArtifactComparisonTool.ts b/src/server/registerArtifactComparisonTool.ts index 3715ba7834..3e9fe67a40 100644 --- a/src/server/registerArtifactComparisonTool.ts +++ b/src/server/registerArtifactComparisonTool.ts @@ -14,7 +14,7 @@ import { runDerivedOperation } from "./runDerivedOperation.js"; import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; import { withAdmittedAnalysis } from "./analysisAdmission.js"; import { runAdmittedToolOperation } from "./admittedToolOperation.js"; -import { recordSessionEvidenceSources } from "./sessionEvidence.js"; +import { recordEvidenceSources } from "../application/investigation/recordEvidenceSources.js"; /** Register Evidence-backed deterministic artifact comparison. */ export const registerArtifactComparisonTool = ( @@ -60,7 +60,7 @@ export const registerArtifactComparisonTool = ( if (!computed.ok) return server.delivery.toCallToolResult(computed, contract); const { left, right, comparison } = computed.value; - const recordedSources = recordSessionEvidenceSources( + const recordedSources = recordEvidenceSources( (source) => session.recordEvidence(source), [left, right], ); diff --git a/src/server/registerArtifactTools.ts b/src/server/registerArtifactTools.ts index e852654ce7..a29497aa4c 100644 --- a/src/server/registerArtifactTools.ts +++ b/src/server/registerArtifactTools.ts @@ -3,7 +3,6 @@ import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; import { ARTIFACT_TOOL_CONTRACTS } from "../contracts/artifactToolContracts.js"; import { createArtifactExtractionDestination } from "../application/artifacts/ArtifactExtractionDestination.js"; import { registerEvidenceTools } from "./registerEvidenceTools.js"; -import { readArtifactInspectionSources } from "../domain/artifactInspection.js"; import { retainedSummaryDelivery, withoutDeliveryDetail, @@ -34,11 +33,5 @@ export const registerArtifactTools = ( ? { ...parameters, output_root: createArtifactExtractionDestination() } : executionWithoutDelivery(operation, parameters), parametersForEvidence: decodeWithoutDelivery, - sourceEvidence: (operation, result) => { - if (operation !== "inspect_artifact") return []; - const parsed = readArtifactInspectionSources(result); - if (!parsed.ok) throw parsed.error; - return parsed.value; - }, }); }; diff --git a/src/server/registerBundleComparisonTool.ts b/src/server/registerBundleComparisonTool.ts index 52bb0dd62c..1a90b3e839 100644 --- a/src/server/registerBundleComparisonTool.ts +++ b/src/server/registerBundleComparisonTool.ts @@ -7,7 +7,7 @@ import { compareBundles } from "../domain/bundleComparison.js"; import { createEvidence } from "../domain/evidence.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; import { runDerivedOperation } from "./runDerivedOperation.js"; -import { recordSessionEvidenceSources } from "./sessionEvidence.js"; +import { recordEvidenceSources } from "../application/investigation/recordEvidenceSources.js"; import { BUNDLE_COMPARISON_PROVIDER } from "../domain/investigationProviders.js"; import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; import { withAdmittedAnalysis } from "./analysisAdmission.js"; @@ -65,7 +65,7 @@ export const registerBundleComparisonTool = ( ]), ).values(), ]; - const recordedSources = recordSessionEvidenceSources( + const recordedSources = recordEvidenceSources( (evidence) => session.recordEvidence(evidence), sourceRecords, ); diff --git a/src/server/registerEvidenceTools.ts b/src/server/registerEvidenceTools.ts index 6f927b315c..59f4910b85 100644 --- a/src/server/registerEvidenceTools.ts +++ b/src/server/registerEvidenceTools.ts @@ -7,7 +7,8 @@ import type { } from "../application/AnalysisProvider.js"; import type { ToolContract } from "../contracts/toolContractTypes.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; -import { createEvidence, type Evidence } from "../domain/evidence.js"; +import type { Evidence } from "../domain/evidence.js"; +import { createAnalysisExecutionEvidence } from "../domain/analysisObservation.js"; import { jsonObjectSchema, type JsonValue } from "../domain/jsonValue.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { ok, type Result } from "../domain/result.js"; @@ -16,7 +17,7 @@ import { mcpProgressReporter } from "./mcpProgress.js"; import { logToolExecution } from "./toolLogging.js"; import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; -import { recordSessionEvidenceSources } from "./sessionEvidence.js"; +import { recordEvidenceSources } from "../application/investigation/recordEvidenceSources.js"; import { reportOperationStart, reportOperationEnd, @@ -30,10 +31,6 @@ interface EvidenceToolRegistration { readonly analysisForAdmission?: ( analysis: AnalysisOperationPort, ) => AnalysisOperationPort; - readonly sourceEvidence?: ( - operation: Exclude, - result: import("../domain/jsonValue.js").JsonValue, - ) => readonly Evidence[]; readonly parametersForExecution?: ( operation: Exclude, parameters: Readonly>, @@ -94,36 +91,21 @@ export const registerEvidenceTools = ( await reportOperationEnd(progress, contract.name, execution.ok); if (!execution.ok) return server.delivery.toCallToolResult(execution, contract); - const sourceEvidence = - options.sourceEvidence?.(contract.name, execution.value.result) ?? - []; - const evidence = createEvidence( - execution.value.subject === undefined - ? options.activeTarget?.() - : (execution.value.subject ?? undefined), - execution.value.provider, + const sourceEvidence = execution.value.sourceEvidence ?? []; + const evidence = createAnalysisExecutionEvidence( + contract.name, + options.parametersForEvidence?.(contract.name, parameters) ?? + parameters, { - operation: contract.name, - parameters: - options.parametersForEvidence?.(contract.name, parameters) ?? - parameters, - result: execution.value.result, - ...(execution.value.analysisProfile === undefined - ? {} - : { analysisProfile: execution.value.analysisProfile }), - rawResult: execution.value.rawResult, - limitations: execution.value.limitations, - ...(execution.value.relationshipCoverage === undefined - ? {} - : { - relationshipCoverage: - execution.value.relationshipCoverage, - }), - locations: execution.value.locations, - evidenceLinks: sourceEvidence.map(({ evidence_id: id }) => id), + ...execution.value, + subject: + execution.value.subject === undefined + ? (options.activeTarget?.() ?? null) + : execution.value.subject, }, + "observed", ); - const sourceRecorded = recordSessionEvidenceSources( + const sourceRecorded = recordEvidenceSources( options.recordEvidence, sourceEvidence, ); diff --git a/src/server/registerFunctionComparisonTool.ts b/src/server/registerFunctionComparisonTool.ts index 462a098bf5..4817c183b5 100644 --- a/src/server/registerFunctionComparisonTool.ts +++ b/src/server/registerFunctionComparisonTool.ts @@ -9,7 +9,7 @@ import { FUNCTION_COMPARISON_PROVIDER } from "../domain/comparisonEvidenceIdenti import { jsonValueSchema } from "../domain/jsonValue.js"; import type { RecordUnknownInput } from "../domain/residualUnknown.js"; import { recordDerivedEvidence } from "./recordDerivedEvidence.js"; -import { recordSessionEvidenceSources } from "./sessionEvidence.js"; +import { recordEvidenceSources } from "../application/investigation/recordEvidenceSources.js"; import { runDerivedOperation } from "./runDerivedOperation.js"; import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; import { withAdmittedAnalysis } from "./analysisAdmission.js"; @@ -68,7 +68,7 @@ export const registerFunctionComparisonTool = ( const rightEvidence = right.evidence; const leftIds = [leftEvidence.evidence_id]; const rightIds = [rightEvidence.evidence_id]; - const recordedSources = recordSessionEvidenceSources( + const recordedSources = recordEvidenceSources( (evidence) => session.recordEvidence(evidence), [leftEvidence, rightEvidence], ); diff --git a/src/server/registerManagedWorkflowTools/compareManagedMembers.ts b/src/server/registerManagedWorkflowTools/compareManagedMembers.ts index 0c3f451ff2..5cde172ce1 100644 --- a/src/server/registerManagedWorkflowTools/compareManagedMembers.ts +++ b/src/server/registerManagedWorkflowTools/compareManagedMembers.ts @@ -1,7 +1,7 @@ import { workflowUnknownInput } from "../../application/investigation/WorkflowUnknown.js"; import type { EvidenceMcpServer } from "../EvidenceMcpServer.js"; import { runAdmittedToolOperation } from "../admittedToolOperation.js"; -import { recordSessionEvidenceSources } from "../sessionEvidence.js"; +import { recordEvidenceSources } from "../../application/investigation/recordEvidenceSources.js"; import { executeManagedMemberComparison } from "../../application/managed/ManagedMemberComparisonService.js"; import { logToolExecution } from "../toolLogging.js"; @@ -33,7 +33,7 @@ export const registerCompareManagedMembers = ( ); if (!result.ok) return server.delivery.toCallToolResult(result, compareContract); - const recorded = recordSessionEvidenceSources( + const recorded = recordEvidenceSources( options.recordEvidence, result.value.sources, ); diff --git a/src/server/registerManagedWorkflowTools/projectManagedApplicationGraph.ts b/src/server/registerManagedWorkflowTools/projectManagedApplicationGraph.ts index a8690a7b13..7b185fa241 100644 --- a/src/server/registerManagedWorkflowTools/projectManagedApplicationGraph.ts +++ b/src/server/registerManagedWorkflowTools/projectManagedApplicationGraph.ts @@ -1,6 +1,6 @@ import type { EvidenceMcpServer } from "../EvidenceMcpServer.js"; import { runAdmittedToolOperation } from "../admittedToolOperation.js"; -import { recordSessionEvidenceSources } from "../sessionEvidence.js"; +import { recordEvidenceSources } from "../../application/investigation/recordEvidenceSources.js"; import { projectManagedGraphEvidence } from "../../application/managed/ManagedApplicationGraphService.js"; import { readManagedGraphProjection } from "../../domain/managed/managedApplicationGraph.js"; @@ -39,7 +39,7 @@ export const registerProjectManagedApplicationGraph = ( ); if (!result.ok) return server.delivery.toCallToolResult(result, graphContract); - const recordedSources = recordSessionEvidenceSources( + const recordedSources = recordEvidenceSources( options.recordEvidence, [ request.value.sources.artifact?.evidence, diff --git a/src/server/registerManagedWorkflowTools/verifyManagedNativeBoundaries.ts b/src/server/registerManagedWorkflowTools/verifyManagedNativeBoundaries.ts index 23f6ca7d42..d420c22dad 100644 --- a/src/server/registerManagedWorkflowTools/verifyManagedNativeBoundaries.ts +++ b/src/server/registerManagedWorkflowTools/verifyManagedNativeBoundaries.ts @@ -1,7 +1,7 @@ import { workflowUnknownInput } from "../../application/investigation/WorkflowUnknown.js"; import type { EvidenceMcpServer } from "../EvidenceMcpServer.js"; import { runAdmittedToolOperation } from "../admittedToolOperation.js"; -import { recordSessionEvidenceSources } from "../sessionEvidence.js"; +import { recordEvidenceSources } from "../../application/investigation/recordEvidenceSources.js"; import { executeManagedNativeBoundaries } from "../../application/managed/ManagedNativeVerificationService.js"; import { logToolExecution } from "../toolLogging.js"; @@ -39,7 +39,7 @@ export const registerVerifyManagedNativeBoundaries = ( nativeVerificationContract, ); const execution = result.value; - const recordedSources = recordSessionEvidenceSources( + const recordedSources = recordEvidenceSources( options.recordEvidence, execution.sourceEvidence, ); diff --git a/src/server/registerNativeFunctionMatchingTool.ts b/src/server/registerNativeFunctionMatchingTool.ts index cd9e783a57..2a80c2734f 100644 --- a/src/server/registerNativeFunctionMatchingTool.ts +++ b/src/server/registerNativeFunctionMatchingTool.ts @@ -6,7 +6,7 @@ import { summarizeRetainedAnalysis } from "../application/analysisView/AnalysisV import { withAdmittedAnalysis } from "./analysisAdmission.js"; import { createEvidenceToolHandler } from "./contractToolHandler.js"; import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; -import { recordSessionEvidenceSources } from "./sessionEvidence.js"; +import { recordEvidenceSources } from "../application/investigation/recordEvidenceSources.js"; import { ok } from "../domain/result.js"; import type { Logger } from "pino"; @@ -25,7 +25,7 @@ export const registerNativeFunctionMatchingTool = ( async (input, options) => { const result = await matchNativeFunctions(input, options, evidenceById); if (!result.ok) return result; - const recorded = recordSessionEvidenceSources( + const recorded = recordEvidenceSources( recordEvidence, result.value.sources, ); diff --git a/src/server/registerProcessComparisonTool.ts b/src/server/registerProcessComparisonTool.ts index 956654f3a6..b711860a9d 100644 --- a/src/server/registerProcessComparisonTool.ts +++ b/src/server/registerProcessComparisonTool.ts @@ -18,7 +18,7 @@ import { parseProcessCaptureEvidence } from "../application/process/ProcessEvide import type { RecordUnknownInput } from "../domain/residualUnknown.js"; import { err, type Result } from "../domain/result.js"; import { recordDerivedEvidence } from "./recordDerivedEvidence.js"; -import { recordSessionEvidenceSources } from "./sessionEvidence.js"; +import { recordEvidenceSources } from "../application/investigation/recordEvidenceSources.js"; import { runDerivedOperation } from "./runDerivedOperation.js"; import { PROCESS_PROVIDER } from "../domain/process/processEvidenceProvider.js"; import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; @@ -120,7 +120,7 @@ export const registerProcessComparisonTool = ( if (!computed.ok) return server.delivery.toCallToolResult(computed, contract); const { comparison, evidence } = computed.value; - const recordedSources = recordSessionEvidenceSources( + const recordedSources = recordEvidenceSources( (evidence) => session.recordEvidence(evidence), [leftRecord, rightRecord], ); diff --git a/src/server/registerWasmTools.ts b/src/server/registerWasmTools.ts index 59e0a48e4e..b929e759c0 100644 --- a/src/server/registerWasmTools.ts +++ b/src/server/registerWasmTools.ts @@ -1,7 +1,7 @@ import { compareWasmInterfaces } from "../application/wasm/WasmInterfaceComparisonService.js"; import { COMPARE_WASM_INTERFACES_CONTRACT } from "../contracts/wasm/wasmComparisonContracts.js"; import { resolvePairedEvidenceRequest } from "../application/EvidenceInputResolver.js"; -import { recordSessionEvidenceSources } from "./sessionEvidence.js"; +import { recordEvidenceSources } from "../application/investigation/recordEvidenceSources.js"; import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; import type { WasmArtifactService } from "../application/wasm/WasmArtifactService.js"; @@ -47,7 +47,7 @@ export const registerWasmTools = ( if (!sources.ok) return sources; const result = await compareWasmInterfaces(sources.value, options); if (!result.ok) return result; - const recorded = recordSessionEvidenceSources(recordEvidence, [ + const recorded = recordEvidenceSources(recordEvidence, [ sources.value.left, sources.value.right, ]); diff --git a/tests/boundary/mcp/artifactMcp.test.ts b/tests/boundary/mcp/artifactMcp.test.ts index 74abc4a027..2227cec5df 100644 --- a/tests/boundary/mcp/artifactMcp.test.ts +++ b/tests/boundary/mcp/artifactMcp.test.ts @@ -5,7 +5,13 @@ import { } from "../../../src/contracts/investigationExamples.js"; import { toolContract } from "../../../src/contracts/toolContracts.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; -import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; +import { + createEvidence, + parseEvidence, + type Evidence, +} from "../../../src/domain/evidence.js"; +import { EvidenceIntegrityError } from "../../../src/domain/evidenceErrors.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { jsonValueSchema } from "../../../src/domain/jsonValue.js"; import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js"; import { @@ -15,14 +21,17 @@ import { occurrenceIdForLocation, } from "../../../src/domain/artifactIdentity.js"; import { artifactInspectionResultSchema } from "../../../src/domain/artifactInspection.js"; -import { ok as resultOk } from "../../../src/domain/result.js"; +import { + ok as resultOk, + err as resultErr, +} from "../../../src/domain/result.js"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { createPackageWithOptions } from "@electron/asar"; import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; -import { expect, it } from "vitest"; +import { expect, it, vi, type MockInstance } from "vitest"; import { z } from "zod"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -35,6 +44,7 @@ import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js"; import { ARTIFACT_COMPARISON_EXAMPLE } from "../../../src/contracts/artifactComparisonExample.js"; import { createServer } from "../../../src/server/createServer.js"; +import { createArtifactBinarySession } from "../../../src/composition/binary.js"; import { observed } from "../../fixtures/analysisExecution.js"; import { cArchiveExecutable } from "../../../src/artifacts/pyinstaller/CArchive.fixture.js"; import { compareUnicodeCodePoints } from "../../../src/domain/unicodeCodePointOrder.js"; @@ -46,6 +56,80 @@ const integrityMessage = (unpacked: boolean): string => : "Artifact bytes contradict declared integrity." } If expected, rerun inspect_artifact with integrity_policy=record-and-continue (CLI: --integrity-policy record-and-continue) to retain observed bytes as untrusted; otherwise get a fresh copy.`; +it("refuses an artifact parent and summary when its actual inventory source cannot be recorded", async () => { + const root = await createTestTempDirectory( + "rea-artifact-source-refusal-mcp-", + ); + const archive = join(root, "fixture.zip"); + const zip = new ZipWriter(new Uint8ArrayWriter()); + await zip.add("main.js", new TextReader("console.log('source');\n")); + await writeFile(archive, await zip.close()); + const session = await createArtifactBinarySession({}); + const server = createServer({ kind: "session", session }); + const client = new Client({ name: "artifact-source-refusal", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const attempted: Evidence[] = []; + const failure = new EvidenceIntegrityError( + "controlled inventory source recording refusal", + ); + const nativeRecord = session.recordEvidence.bind(session); + let recording: MockInstance | undefined; + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const opened = await client.callTool({ + name: "open_binary", + arguments: { path: archive }, + }); + expect(opened.isError).not.toBe(true); + const before = session.exportEvidenceBundle().records; + recording = vi + .spyOn(session, "recordEvidence") + .mockImplementation((evidence) => { + attempted.push(evidence); + return evidence.operation === "inventory_artifact" + ? resultErr(failure) + : nativeRecord(evidence); + }); + const inspected = await client.callTool({ + name: "inspect_artifact", + arguments: { detail: "summary" }, + }); + expect(inspected.isError).toBe(true); + expect(parseMcpToolError(inspected)).toMatchObject({ + error: projectAnalysisError(failure), + }); + expect(attempted.map(({ operation }) => operation)).toEqual([ + "inventory_artifact", + ]); + expect(attempted[0]?.normalized_result).toMatchObject({ + occurrences: expect.arrayContaining([ + expect.objectContaining({ logical_path: "main.js" }), + ]), + }); + expect(session.exportEvidenceBundle().records).toEqual(before); + } finally { + recording?.mockRestore(); + const closed = await Promise.allSettled([ + client.close(), + server.close(), + session.close(), + ]); + const failures: unknown[] = []; + for (const result of closed) + if (result.status === "rejected") failures.push(result.reason); + const sessionClosed = closed[2]; + if (sessionClosed.status === "fulfilled" && !sessionClosed.value.ok) + failures.push(sessionClosed.value.error); + if (failures.length > 0) + throw new AggregateError( + failures, + "Artifact recorder refusal cleanup failed", + ); + } +}); + it.each([ { unpacked: false, label: "embedded" }, { unpacked: true, label: "unpacked" }, diff --git a/tests/boundary/providers/native/artifactProvider.directAnalysis.test.ts b/tests/boundary/providers/native/artifactProvider.directAnalysis.test.ts index 1cb7b9239b..9f032b327c 100644 --- a/tests/boundary/providers/native/artifactProvider.directAnalysis.test.ts +++ b/tests/boundary/providers/native/artifactProvider.directAnalysis.test.ts @@ -10,6 +10,8 @@ import { createArtifactBinarySession } from "../../../../src/composition/binary. import { thinMach } from "../../../../src/domain/binaryTarget.fixture.js"; import { interfaceBuilderAnalysisSchema } from "../../../../src/domain/apple/interfaceBuilderGraph.js"; import { artifactInspectionResultSchema } from "../../../../src/domain/artifactInspection.js"; +import { createAnalysisExecutionEvidence } from "../../../../src/domain/analysisObservation.js"; +import { immutableJsonSnapshotMember } from "../../../../src/domain/immutableJson.js"; import { parseEvidence } from "../../../../src/domain/evidence.js"; const { runProviderAnalysis } = createDirectAnalysis({}); @@ -75,4 +77,44 @@ it("inspects an artifact through the direct CLI workflow without an REA grant", expect.objectContaining({ logical_path: "main.js" }), ]), }); + + const staticSession = await createArtifactBinarySession({}); + try { + const opened = await staticSession.open(archive); + if (!opened.ok) throw opened.error; + const executed = await staticSession.execute("inspect_artifact", {}); + if (!executed.ok) throw executed.error; + const sessionInspection = artifactInspectionResultSchema.parse( + executed.value.result, + ); + expect(sessionInspection.evidence_links).toEqual( + sessionInspection.substeps.map( + ({ evidence: sourceEvidence }) => sourceEvidence.evidence_id, + ), + ); + const substeps = immutableJsonSnapshotMember( + executed.value.result, + "substeps", + ); + const sources = executed.value.sourceEvidence; + expect(sources).toHaveLength(sessionInspection.substeps.length); + if (sources === undefined) + throw new Error("Missing committed inspection sources"); + for (const [index, sourceEvidence] of sources.entries()) { + const step = immutableJsonSnapshotMember(substeps, String(index)); + expect(sourceEvidence).toBe( + immutableJsonSnapshotMember(step, "evidence"), + ); + } + const parent = createAnalysisExecutionEvidence( + "inspect_artifact", + {}, + executed.value, + "observed", + ); + expect(parent.evidence_links).toEqual(sessionInspection.evidence_links); + } finally { + const closed = await staticSession.close(); + if (!closed.ok) throw closed.error; + } }); diff --git a/tests/composition/directAnalysisCleanup.test.ts b/tests/composition/directAnalysisCleanup.test.ts index 10389c2399..173d0aedab 100644 --- a/tests/composition/directAnalysisCleanup.test.ts +++ b/tests/composition/directAnalysisCleanup.test.ts @@ -1,6 +1,8 @@ -import { readFile, readdir, stat } from "node:fs/promises"; +import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { createPackageWithOptions } from "@electron/asar"; +import { createArtifactBinarySession } from "../../src/composition/binary.js"; import { runDirectAnalysis, runManagedProviderExecution, @@ -19,7 +21,9 @@ import { projectAnalysisError, projectAnalysisErrorWithoutCapturedOutput, } from "../../src/domain/analysisErrorProjection.js"; -import { parseEvidence } from "../../src/domain/evidence.js"; +import { parseEvidence, type Evidence } from "../../src/domain/evidence.js"; +import { jsonObjectSchema } from "../../src/domain/jsonValue.js"; +import { artifactInspectionResultSchema } from "../../src/domain/artifactInspection.js"; import { ProviderCleanupError } from "../../src/domain/providerCleanupError.js"; import { ghidraFunctionDossier } from "../../src/domain/ghidraValues.fixture.js"; import { @@ -36,6 +40,7 @@ import { createBinarySessionTargets, createTestBinarySession, } from "../fixtures/binarySession.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; const cleanupError = new ProviderCleanupError( "fixture", @@ -100,6 +105,94 @@ const factories = ( return { dependencies, session }; }; +it.each([false, true])( + "retains actual artifact sources and caller path after final-close cancellation (cleanup fails: %s)", + async (closeFails) => { + const root = await createTestTempDirectory("rea-artifact-final-close-"); + const source = join(root, "source"); + const archive = join(root, "fixture.asar"); + await mkdir(source); + await writeFile(join(source, "main.js"), "console.log('retained');\n"); + await createPackageWithOptions(source, archive, {}); + const session = await createArtifactBinarySession({}); + const nativeClose = session.close.bind(session); + const controller = new AbortController(); + const reason = { + request: "cancel during actual artifact session final close", + }; + let completed: Evidence | undefined; + const close = vi + .spyOn(session, "close") + .mockImplementationOnce(async () => { + completed = session + .exportEvidenceBundle() + .records.find( + ({ operation, caller_input_path }) => + operation === "inspect_artifact" && caller_input_path === archive, + ); + controller.abort(reason); + // A controlled refusal retains the actual static session for finally + // cleanup; successful cleanup runs its native owner here. + return closeFails ? err(cleanupError) : nativeClose(); + }); + const unexpectedSession = () => { + throw new Error("Artifact one-shot must select its static session"); + }; + const dependencies: DirectAnalysisDependencies = { + readConfiguration: () => parseConfig({}), + createArtifactBinarySession: () => session, + createBinarySession: unexpectedSession, + createNativeDispatchBinarySession: unexpectedSession, + createNativeBinarySession: unexpectedSession, + createManagedBinarySession: unexpectedSession, + }; + try { + const result = analysisCliErrorEnvelopeSchema.parse( + await runProviderAnalysis( + dependencies, + archive, + "inspect_artifact", + {}, + undefined, + controller.signal, + ), + ); + expect(result.code).toBe(closeFails ? "cleanup_incomplete" : "cancelled"); + expect(result.category).toBe("cancelled"); + expect(completed).toBeDefined(); + const details = jsonObjectSchema.parse(result.details); + const partial = parseEvidence(details.partial_observation); + expect(partial).toEqual(completed); + expect(partial.operation).toBe("inspect_artifact"); + expect(partial.caller_input_path).toBe(archive); + expect(partial.subject?.local_path).toBe(archive); + const inspection = artifactInspectionResultSchema.parse( + partial.normalized_result, + ); + expect(inspection.subject.root_format).toBe("asar"); + expect(partial.evidence_links).toEqual(inspection.evidence_links); + expect(partial.evidence_links).toEqual( + inspection.substeps.map(({ evidence }) => evidence.evidence_id), + ); + expect(inspection.substeps[0]?.evidence.normalized_result).toMatchObject({ + occurrences: expect.arrayContaining([ + expect.objectContaining({ logical_path: "main.js" }), + ]), + }); + if (closeFails) + expect(details.cleanup_error).toEqual( + projectAnalysisError(cleanupError), + ); + else expect(result.details).not.toHaveProperty("cleanup_error"); + expect((await readdir(root)).sort()).toEqual(["fixture.asar", "source"]); + } finally { + close.mockRestore(); + const closed = await nativeClose(); + if (!closed.ok) throw closed.error; + } + }, +); + describe("one-shot finalization cancellation", () => { it.each(["read_bytes", "analyze_function"] as const)( "retains completed %s Evidence when cancellation arrives during final close",