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.
This commit is contained in:
morluto
2026-10-25 04:36:05 +08:00
parent 5ea2c280b8
commit 835904e4ee
30 changed files with 394 additions and 139 deletions
+5
View File
@@ -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 }),
};
}
+10 -27
View File
@@ -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 });
};
@@ -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();
@@ -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),
+16 -17
View File
@@ -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 };
@@ -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<null, EvidenceIntegrityError> => {
+5 -1
View File
@@ -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: [],
}),
+4
View File
@@ -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,
+16 -9
View File
@@ -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<readonly Evidence[], EvidenceIntegrityError> => {
@@ -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));
};
@@ -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,
);
@@ -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,
);
@@ -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,
);
@@ -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,
);
@@ -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,
);
@@ -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,
);
@@ -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(
+2 -2
View File
@@ -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],
);
-7
View File
@@ -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;
},
});
};
+2 -2
View File
@@ -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,
);
+15 -33
View File
@@ -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<AnalysisOperation, "health">,
result: import("../domain/jsonValue.js").JsonValue,
) => readonly Evidence[];
readonly parametersForExecution?: (
operation: Exclude<AnalysisOperation, "health">,
parameters: Readonly<Record<string, JsonValue>>,
@@ -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,
);
+2 -2
View File
@@ -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],
);
@@ -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,
);
@@ -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,
@@ -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,
);
@@ -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,
);
+2 -2
View File
@@ -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],
);
+2 -2
View File
@@ -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,
]);
+87 -3
View File
@@ -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<typeof session.recordEvidence> | 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" },
@@ -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;
}
});
@@ -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",