fix(snapshot): preserve authenticated replay provenance

This commit is contained in:
morluto committed 2026-10-26 15:15:00 +08:00
1 parent f4987281fa
commit 0c147a4a70
4 files changed
+467 -17

No files matched your search

+70 -17
View File
@@ -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<AnalysisOperation> = 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<string, AnalysisSnapshotEntry>();
readonly #evidenceIds = new Map<string, string>();
// 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<string, SnapshotReplayBinding | null>();
readonly #workflowEntries = new Map<string, AnalysisSnapshotWorkflowEntry>();
#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<string, string>();
const evidenceByQuery = new Map<string, Evidence>();
const records = new Map(
snapshot.evidence_bundle.records.map((record) => [
record.evidence_id,
record,
]),
);
const ambiguous = new Set<string>();
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();
}
@@ -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<string, Evidence>,
): 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),
};
};
@@ -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);
});
@@ -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);
},
);