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",