diff --git a/src/application/binary/AnalysisSnapshotCache.ts b/src/application/binary/AnalysisSnapshotCache.ts index 4db71038ff..99cdc2f0f4 100644 --- a/src/application/binary/AnalysisSnapshotCache.ts +++ b/src/application/binary/AnalysisSnapshotCache.ts @@ -9,7 +9,10 @@ import { import { NoBinaryOpenError } from "../../domain/configurationErrors.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import type { AnalysisInputError } from "../../domain/analysisErrorCore.js"; -import { rebindEvidenceSubjectTargetPath } from "../../domain/evidence.js"; +import { + rebindEvidenceSubjectTargetPath, + type Evidence, +} from "../../domain/evidence.js"; import { err, ok, type Result } from "../../domain/result.js"; import { analysisQueryId, @@ -38,6 +41,10 @@ import { OFFICIAL_TOOL_CONTRACTS } from "../../contracts/officialToolContracts.j import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js"; import type { EvidenceMetadataReplacement } from "../investigation/EvidenceLedger.js"; +import { + snapshotReplayBinding, + type SnapshotReplayBinding, +} from "./AnalysisSnapshotReplayBinding.js"; const RELATIONSHIP_OPERATIONS: ReadonlySet = new Set([ "procedure_callers", @@ -96,7 +103,9 @@ export const requiresNativeSourceAdmissionBeforeReplay = ( /** Bounded in-memory cache for one immutable binary identity. */ export class AnalysisSnapshotCache { readonly #entries = new Map(); - readonly #evidenceIds = new Map(); + // Null means imported provenance cannot be faithfully replayed. It is distinct + // from an entry recorded before an Evidence ID was assigned. + readonly #replayBindings = new Map(); readonly #workflowEntries = new Map(); #target: AnalysisSnapshotTarget | undefined; #binding: AnalysisSnapshotBinding | undefined; @@ -145,11 +154,17 @@ export class AnalysisSnapshotCache { if (!this.#entries.has(entry.query_id)) imported += 1; this.#entries.set(entry.query_id, entry); stagedQueryIds.add(entry.query_id); - this.#evidenceIds.delete(entry.query_id); + this.#replayBindings.delete(entry.query_id); } // Snapshot validation authenticated these records. Recover the original // identity before lookup adds replay caveats; do not mint a new record. - const evidenceIds = new Map(); + const evidenceByQuery = new Map(); + const records = new Map( + snapshot.evidence_bundle.records.map((record) => [ + record.evidence_id, + record, + ]), + ); const ambiguous = new Set(); for (const evidence of snapshot.evidence_bundle.records) { const queryId = analysisQueryId( @@ -165,14 +180,21 @@ export class AnalysisSnapshotCache { !evidenceMatchesEntry(evidence, entry, snapshot) ) continue; - const previous = evidenceIds.get(queryId); - if (previous !== undefined && previous !== evidence.evidence_id) + const previous = evidenceByQuery.get(queryId); + if ( + previous !== undefined && + previous.evidence_id !== evidence.evidence_id + ) ambiguous.add(queryId); - else evidenceIds.set(queryId, evidence.evidence_id); + else evidenceByQuery.set(queryId, evidence); } - for (const [queryId, evidenceId] of evidenceIds) { - if (ambiguous.has(queryId)) this.#evidenceIds.delete(queryId); - else this.#evidenceIds.set(queryId, evidenceId); + for (const [queryId, evidence] of evidenceByQuery) { + this.#replayBindings.set( + queryId, + ambiguous.has(queryId) + ? null + : snapshotReplayBinding(evidence, records), + ); } for (const entry of snapshot.workflow_entries) { if (!this.#workflowEntries.has(entry.query_id) && !this.#hasCapacity()) @@ -187,7 +209,29 @@ export class AnalysisSnapshotCache { refreshEvidenceMetadata( replacements: readonly EvidenceMetadataReplacement[], ): void { - if (this.#target === undefined || this.#binding === undefined) return; + if ( + replacements.length === 0 || + this.#target === undefined || + this.#binding === undefined + ) + return; + const currentRecords = new Map( + replacements.map(({ current }) => [current.evidence_id, current]), + ); + for (const [queryId, binding] of this.#replayBindings) { + if (binding === null || binding.sourceEvidence === undefined) continue; + const sources = binding.sourceEvidence; + if (!sources.some((source) => currentRecords.has(source.evidence_id))) + continue; + this.#replayBindings.set(queryId, { + evidenceId: binding.evidenceId, + sourceEvidence: Object.freeze( + sources.map( + (source) => currentRecords.get(source.evidence_id) ?? source, + ), + ), + }); + } const header = { target: this.#target, binding: this.#binding }; for (const { previous, current } of replacements) { if ( @@ -379,6 +423,10 @@ export class AnalysisSnapshotCache { ); const cached = this.#entries.get(queryId); if (cached === undefined) return undefined; + const binding = this.#replayBindings.get(queryId); + // Do not turn a refused provenance selection into a fresh, unlinked record. + // The provider must produce a new observation before this query can replay. + if (binding === null) return undefined; const cachedSubject = cached.execution.subject; const subject = cachedSubject == null @@ -394,9 +442,8 @@ export class AnalysisSnapshotCache { }, target, ); - const evidenceId = this.#evidenceIds.get(queryId); - return structuredClone({ - ...(evidenceId === undefined ? {} : { evidenceId }), + const execution: AnalysisExecution = structuredClone({ + ...(binding === undefined ? {} : { evidenceId: binding.evidenceId }), result: cached.execution.result, rawResult: cached.execution.raw_result, provider: cached.execution.provider, @@ -421,6 +468,9 @@ export class AnalysisSnapshotCache { locations: cached.execution.locations, subject: subject ?? null, }); + return binding?.sourceEvidence === undefined + ? execution + : { ...execution, sourceEvidence: binding.sourceEvidence }; } /** Record one successful immutable call unless the cache is full. */ @@ -468,8 +518,11 @@ export class AnalysisSnapshotCache { }); this.#entries.set(entry.query_id, entry); if (execution.evidenceId === undefined) - this.#evidenceIds.delete(entry.query_id); - else this.#evidenceIds.set(entry.query_id, execution.evidenceId); + this.#replayBindings.delete(entry.query_id); + else + this.#replayBindings.set(entry.query_id, { + evidenceId: execution.evidenceId, + }); } /** Forget all target-bound entries. */ @@ -481,7 +534,7 @@ export class AnalysisSnapshotCache { #clearEntries(): void { this.#entries.clear(); - this.#evidenceIds.clear(); + this.#replayBindings.clear(); this.#workflowEntries.clear(); } diff --git a/src/application/binary/AnalysisSnapshotReplayBinding.ts b/src/application/binary/AnalysisSnapshotReplayBinding.ts new file mode 100644 index 0000000000..b80a1f869f --- /dev/null +++ b/src/application/binary/AnalysisSnapshotReplayBinding.ts @@ -0,0 +1,27 @@ +import type { Evidence } from "../../domain/evidence.js"; + +/** Authenticated observation identity and its immediate source records. */ +export interface SnapshotReplayBinding { + readonly evidenceId: string; + readonly sourceEvidence?: readonly Evidence[]; +} + +/** Preserve direct links using the snapshot's authenticated immutable records. */ +export const snapshotReplayBinding = ( + evidence: Evidence, + records: ReadonlyMap, +): SnapshotReplayBinding | null => { + if (evidence.evidence_links.length === 0) + return { evidenceId: evidence.evidence_id }; + const sources: Evidence[] = []; + for (const id of evidence.evidence_links) { + const source = records.get(id); + // An external link without its source cannot be represented by sourceEvidence. + if (source === undefined) return null; + sources.push(source); + } + return { + evidenceId: evidence.evidence_id, + sourceEvidence: Object.freeze(sources), + }; +}; diff --git a/tests/boundary/filesystem/analysisSnapshotProvenance.test.ts b/tests/boundary/filesystem/analysisSnapshotProvenance.test.ts new file mode 100644 index 0000000000..3b42be0b73 --- /dev/null +++ b/tests/boundary/filesystem/analysisSnapshotProvenance.test.ts @@ -0,0 +1,134 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, it, onTestFinished } from "vitest"; + +import { readAnalysisSnapshot } from "../../../src/application/binary/AnalysisSnapshotFiles.js"; +import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; +import { createEvidence } from "../../../src/domain/evidence.js"; +import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; +import { MANAGED_STATIC_PROVIDER } from "../../../src/domain/investigationProviders.js"; +import { ok } from "../../../src/domain/result.js"; +import { ManagedStaticProvider } from "../../../src/dotnet/ManagedStaticProvider.js"; +import { buildManagedPeFixture } from "../../../src/dotnet/ManagedPe.fixture.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it("reruns ambiguous imported observations without changing their historical Evidence", async () => { + const directory = await createTestTempDirectory("rea-snapshot-provenance-"); + const path = join(directory, "application.exe"); + const originalPath = join(directory, "original.json"); + const ambiguousPath = join(directory, "ambiguous.json"); + const bytes = buildManagedPeFixture(); + const changedBytes = buildManagedPeFixture({ methodName: "Changed" }); + await writeFile(path, bytes); + + const provider = new ManagedStaticProvider(); + // Select a concrete profile for the real static reader at the session boundary. + // Production managed composition normally uses it as a profileless auxiliary. + const profile = createAnalysisProfile(MANAGED_STATIC_PROVIDER, { + fixture: "snapshot-provenance", + }); + const session = createTestBinarySession(provider, { + resolveAnalysisProfile: () => Promise.resolve(ok({ profile })), + }); + onTestFinished(async () => { + const closed = await session.close(); + if (!closed.ok) throw closed.error; + }); + const opened = await session.open(path); + if (!opened.ok) throw opened.error; + const observed = await session.execute("inspect_managed_artifact", {}); + if (!observed.ok) throw observed.error; + const members = await session.execute("inspect_managed_members", {}); + if (!members.ok) throw members.error; + if ( + observed.value.evidenceId === undefined || + members.value.evidenceId === undefined + ) + throw new Error("Real managed observations have no Evidence identity"); + const original = session.evidenceById(observed.value.evidenceId); + const source = session.evidenceById(members.value.evidenceId); + if (original === undefined || source === undefined) + throw new Error("Real managed observations were not retained"); + const saved = session.exportAnalysisSnapshot(); + if (!saved.ok) throw saved.error; + const written = await session.writeAnalysisSnapshot( + saved.value, + originalPath, + false, + ); + if (!written.ok) throw written.error; + + // Evidence import admits a second authenticated observation of the same facts + // with different upstream provenance. Neither ID identifies the cache entry alone. + const alternative = createEvidence(opened.value, provider.identity(), { + operation: original.operation, + parameters: original.parameters, + result: original.normalized_result, + rawResult: original.raw_result, + analysisProfile: profile, + limitations: original.limitations, + locations: original.locations, + evidenceLinks: [source.evidence_id], + }); + expect(alternative.evidence_id).not.toBe(original.evidence_id); + const imported = session.importEvidenceBundle( + createEvidenceBundle([source, alternative]), + ); + if (!imported.ok) throw imported.error; + const closed = await session.closeWithSnapshot(ambiguousPath, false); + if (!closed.ok) throw closed.error; + const originalSnapshot = await readAnalysisSnapshot(originalPath); + const ambiguousSnapshot = await readAnalysisSnapshot(ambiguousPath); + if (!originalSnapshot.ok) throw originalSnapshot.error; + if (!ambiguousSnapshot.ok) throw ambiguousSnapshot.error; + const historicalBytes = await readFile(ambiguousPath); + const historicalEvidence = ambiguousSnapshot.value.evidence_bundle; + + // An unambiguous recorded snapshot remains immutable: replay does not promise + // to re-read bytes after open admitted the selected target. + const control = await session.open(path, { + snapshot: originalSnapshot.value, + }); + if (!control.ok) throw control.error; + await writeFile(path, changedBytes); + const replayed = await session.execute("inspect_managed_artifact", {}); + if (!replayed.ok) throw replayed.error; + expect(replayed.value.result).toEqual(observed.value.result); + expect(replayed.value.limitations).toContain( + "Loaded from a local REA analysis snapshot; this call did not re-run the provider.", + ); + const controlClosed = await session.close(); + if (!controlClosed.ok) throw controlClosed.error; + + await writeFile(path, bytes); + const reopened = await session.open(path, { + snapshot: ambiguousSnapshot.value, + }); + if (!reopened.ok) throw reopened.error; + await writeFile(path, changedBytes); + const ambiguous = await session.execute("inspect_managed_artifact", {}); + expect(ambiguous).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisArtifactChangedError", + operation: "inspect_managed_artifact", + }, + }); + expect(session.exportEvidenceBundle()).toEqual(historicalEvidence); + expect((await readFile(ambiguousPath)).equals(historicalBytes)).toBe(true); + + // Once fresh source bytes are available again, the real reader can observe + // them while the imported records and their upstream links remain retained. + await writeFile(path, bytes); + const fresh = await session.execute("inspect_managed_artifact", {}); + if (!fresh.ok) throw fresh.error; + expect(fresh.value.result).toEqual(observed.value.result); + expect(fresh.value.limitations).not.toContain( + "Loaded from a local REA analysis snapshot; this call did not re-run the provider.", + ); + expect(session.evidenceById(original.evidence_id)).toEqual(original); + expect(session.evidenceById(alternative.evidence_id)).toEqual(alternative); + expect((await readFile(ambiguousPath)).equals(historicalBytes)).toBe(true); +}); diff --git a/tests/boundary/mcp/analysisSnapshotProvenanceMcp.test.ts b/tests/boundary/mcp/analysisSnapshotProvenanceMcp.test.ts new file mode 100644 index 0000000000..365e4ecea6 --- /dev/null +++ b/tests/boundary/mcp/analysisSnapshotProvenanceMcp.test.ts @@ -0,0 +1,236 @@ +import { readFile, unlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, onTestFinished } from "vitest"; + +import { readAnalysisSnapshot } from "../../../src/application/binary/AnalysisSnapshotFiles.js"; +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; +import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; +import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; +import { MANAGED_STATIC_PROVIDER } from "../../../src/domain/investigationProviders.js"; +import { ok } from "../../../src/domain/result.js"; +import { ManagedStaticProvider } from "../../../src/dotnet/ManagedStaticProvider.js"; +import { buildManagedPeFixture } from "../../../src/dotnet/ManagedPe.fixture.js"; +import { silentLogger } from "../../../src/logger.js"; +import { EvidenceMcpServer } from "../../../src/server/EvidenceMcpServer.js"; +import { withAdmittedAnalysis } from "../../../src/server/analysisAdmission.js"; +import { registerEvidenceTools } from "../../../src/server/registerEvidenceTools.js"; +import { withoutDeliveryDetail } from "../../../src/server/retainedSummaryDelivery.js"; +import { createToolResultDelivery } from "../../../src/server/toolResult.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { mcpTest } from "../../support/mcp/mcpFixture.js"; + +const createSnapshotReplayFixture = async (missingSource: boolean) => { + const directory = await createTestTempDirectory("rea-snapshot-source-mcp-"); + const path = join(directory, "application.exe"); + const snapshotPath = join(directory, "historical.json"); + await writeFile(path, buildManagedPeFixture()); + const provider = new ManagedStaticProvider(); + const profile = createAnalysisProfile(MANAGED_STATIC_PROVIDER, { + fixture: "snapshot-source-delivery", + }); + // A concrete profile selects the real static reader for this session-boundary + // integration. Normal managed composition uses a profileless auxiliary route. + const session = createTestBinarySession(provider, { + resolveAnalysisProfile: () => Promise.resolve(ok({ profile })), + }); + onTestFinished(async () => { + const closed = await session.close(); + if (!closed.ok) throw closed.error; + }); + const opened = await session.open(path); + if (!opened.ok) throw opened.error; + const facts = []; + for (const operation of [ + "inspect_managed_members", + "inspect_managed_native_boundaries", + "inspect_managed_artifact", + ] as const) { + const execution = await session.execute(operation, {}); + if (!execution.ok) throw execution.error; + const evidenceId = execution.value.evidenceId; + const evidence = + evidenceId === undefined ? undefined : session.evidenceById(evidenceId); + if (evidence === undefined) + throw new Error("Real managed observation was not retained"); + facts.push(evidence); + } + const [ancestor, sourceFacts, parentFacts] = facts; + if ( + ancestor === undefined || + sourceFacts === undefined || + parentFacts === undefined + ) + throw new Error("Expected three managed observations"); + const linked = (evidence: typeof parentFacts, sourceId: string) => + createEvidence(opened.value, provider.identity(), { + operation: evidence.operation, + parameters: evidence.parameters, + result: evidence.normalized_result, + rawResult: evidence.raw_result, + analysisProfile: profile, + limitations: evidence.limitations, + locations: evidence.locations, + evidenceLinks: [sourceId], + }); + const source = linked(sourceFacts, ancestor.evidence_id); + const parent = linked(parentFacts, source.evidence_id); + const exported = session.exportAnalysisSnapshot(); + if (!exported.ok) throw exported.error; + // Persist a valid historical snapshot whose primitive entries have source + // links. Current record() excludes these because entries alone omit sources. + const written = await session.writeAnalysisSnapshot( + { + ...exported.value, + entries: missingSource + ? exported.value.entries.filter( + (entry) => entry.operation !== source.operation, + ) + : exported.value.entries, + evidence_bundle: createEvidenceBundle( + missingSource ? [ancestor, parent] : [ancestor, source, parent], + ), + }, + snapshotPath, + false, + ); + if (!written.ok) throw written.error; + const closed = await session.close(); + if (!closed.ok) throw closed.error; + const loaded = await readAnalysisSnapshot(snapshotPath); + if (!loaded.ok) throw loaded.error; + const historicalBytes = await readFile(snapshotPath); + const reopened = await session.open(path, { snapshot: loaded.value }); + if (!reopened.ok) throw reopened.error; + await unlink(path); + return { + directory, + snapshotPath, + session, + ancestor, + source, + parent, + historicalBytes, + }; +}; + +for (const missingSource of [false, true]) + mcpTest( + missingSource + ? "declines snapshot replay when its immediate source record is unavailable" + : "preserves immediate and transitive imported snapshot provenance through MCP Evidence delivery", + async ({ mcp }) => { + const { + directory, + snapshotPath, + session, + ancestor, + source, + parent, + historicalBytes, + } = await createSnapshotReplayFixture(missingSource); + + // Exercise the same named-contract production adapter as the official tools, + // directly on the session so managed's independent-service route cannot bypass replay. + const server = new EvidenceMcpServer( + { name: "snapshot-source-delivery", version: "1" }, + { capabilities: {} }, + session.recordEvidence.bind(session), + createToolResultDelivery(undefined), + ); + registerEvidenceTools( + server, + [toolContract("inspect_managed_artifact")], + { + logger: silentLogger, + activeTarget: () => session.activeTarget(), + recordEvidence: session.recordEvidence.bind(session), + withAdmittedAnalysis: withAdmittedAnalysis({ + kind: "session", + session, + }), + parametersForExecution: withoutDeliveryDetail( + new Set(["inspect_managed_artifact"]), + ), + parametersForEvidence: withoutDeliveryDetail( + new Set(["inspect_managed_artifact"]), + ), + }, + ); + const client = await mcp.connect(server); + const response = await client.callTool({ + name: "inspect_managed_artifact", + arguments: {}, + }); + if (missingSource) { + expect(parseMcpToolError(response)).toMatchObject({ + error: { code: "target_unavailable" }, + }); + expect(session.evidenceById(parent.evidence_id)).toEqual(parent); + expect(session.evidenceById(source.evidence_id)).toBeUndefined(); + expect(session.evidenceById(ancestor.evidence_id)).toEqual(ancestor); + expect((await readFile(snapshotPath)).equals(historicalBytes)).toBe( + true, + ); + return; + } + expect(response.isError, JSON.stringify(response.content)).not.toBe(true); + const replayed = parseEvidence(response.structuredContent); + expect(replayed.normalized_result).toEqual(parent.normalized_result); + expect(replayed.evidence_links).toEqual([source.evidence_id]); + expect(replayed.evidence_links).not.toContain(ancestor.evidence_id); + expect(session.evidenceById(replayed.evidence_id)).toEqual(replayed); + expect(session.evidenceById(parent.evidence_id)).toEqual(parent); + expect(session.evidenceById(source.evidence_id)).toEqual(source); + expect(session.evidenceById(ancestor.evidence_id)).toEqual(ancestor); + expect(session.evidenceById(source.evidence_id)?.evidence_links).toEqual([ + ancestor.evidence_id, + ]); + const firstReplay = await session.execute("inspect_managed_artifact", {}); + if (!firstReplay.ok) throw firstReplay.error; + expect(firstReplay.value.sourceEvidence?.[0]).toBe( + session.evidenceForAnalysis(source.evidence_id), + ); + expect(Object.isFrozen(firstReplay.value.sourceEvidence?.[0])).toBe(true); + + if (source.subject === null) + throw new Error("Expected a file-backed source"); + const relocated = { + ...source, + subject: { + ...source.subject, + local_path: join(directory, "relocated.exe"), + }, + }; + const imported = session.importEvidenceBundle( + createEvidenceBundle([relocated]), + ); + if (!imported.ok) throw imported.error; + const refreshed = await session.execute("inspect_managed_artifact", {}); + if (!refreshed.ok) throw refreshed.error; + expect(refreshed.value.sourceEvidence?.[0]).toBe( + session.evidenceForAnalysis(source.evidence_id), + ); + expect(refreshed.value.sourceEvidence?.[0]).toEqual(relocated); + expect(refreshed.value.sourceEvidence?.[0]?.evidence_links).toEqual([ + ancestor.evidence_id, + ]); + expect(Object.isFrozen(refreshed.value.sourceEvidence?.[0])).toBe(true); + const refreshedResponse = await client.callTool({ + name: "inspect_managed_artifact", + arguments: {}, + }); + expect( + refreshedResponse.isError, + JSON.stringify(refreshedResponse.content), + ).not.toBe(true); + expect( + parseEvidence(refreshedResponse.structuredContent).evidence_links, + ).toEqual([source.evidence_id]); + expect(session.evidenceById(source.evidence_id)).toEqual(relocated); + expect((await readFile(snapshotPath)).equals(historicalBytes)).toBe(true); + }, + );