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 a0621d2af..b216afa1f 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 acac584bd..462d0a710 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 f895fc9a1..39441cf93 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 dad37c58b..4db71038f 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 b9ac301fd..908ecc82e 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 a450f31af..05e7f2ff5 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 8c7f43a80..e6e5f0433 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 51d8154f1..06ed145a0 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 23ec14742..c0d99083a 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 5b7307042..81123d7b1 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 1bc6c6a64..2f2ea545d 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 c98acc7b7..d2a5580dd 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 9bd9aa9bd..67d02773c 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 6f8b2a505..4ab5b4ff5 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 5db4253a3..c808afceb 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 14eae128a..424a53f60 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 3715ba783..3e9fe67a4 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 e852654ce..a29497aa4 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 52bb0dd62..1a90b3e83 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 6f927b315..59f4910b8 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 462a098bf..4817c183b 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 0c3f451ff..5cde172ce 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 a8690a7b1..7b185fa24 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 23f6ca7d4..d420c22da 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 cd9e783a5..2a80c2734 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 956654f3a..b711860a9 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 59e0a48e4..b929e759c 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 74abc4a02..2227cec5d 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 1cb7b9239..9f032b327 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 10389c239..173d0aeda 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",