fix(evidence): retain caller paths across snapshot replay

Preserve the verbatim direct CLI selection in caller_input_path, separate from canonical subject identity and provider/query parameters. Fresh publication uses the existing immutable metadata import path so provider auto-recording cannot discard request provenance. Replay returns the current selection without rewriting stored Evidence or partitioning the cache by alias. Historical records without the optional field still authenticate.

Use the domain semantic projection for ledger agreement, remove the duplicate projection and subject-only replacement names, and preserve request metadata at same-invocation summary delivery. Later retained-view questions do not inherit that context. Existing one-shot parameters.input_path remains for compatibility.

Validation: 149 focused tests, 28 public CLI/MCP cases, all canonical advertised schemas through the pinned SDK, and nine documentation tests passed across the affected checks. Build, typecheck/lint, and formatting passed. Two Windows-only replay cases were skipped on macOS. Native macOS and managed fixtures were exercised; no Hopper/Ghidra/IDA runtime was started.
This commit is contained in:
morluto committed 2026-10-25 02:20:13 +08:00
1 parent 955c7c80ec
commit 9b3a633298
17 files changed
+403 -84

No files matched your search

+8 -1
View File
@@ -465,7 +465,14 @@ an exact result when the target bytes, operation, parameters, provider and
settings match. Mutations and cursor-dependent calls are excluded from the
cache. Snapshot files are local and use owner-only permissions.
Importing an Evidence bundle refreshes retained subject display names and paths.
One-shot binary CLI Evidence reports the exact path argument in `caller_input_path`,
including relative spelling and symlink aliases. Its subject identifies the
inspected artifact; `caller_input_path` does not change the Evidence ID or
snapshot query. A replay reports the current invocation's path while leaving
the saved Evidence unchanged. Provider inspections continue to include the
established `parameters.input_path` field.
Importing an Evidence bundle refreshes retained request and subject display metadata.
It preserves cached analysis results, including observations for other targets.
Snapshot bindings that use a replaced record retain its updated display path.
+5 -5
View File
@@ -53,11 +53,11 @@ satisfy. A plan without execution does not establish runtime behavior.
- Use strict inputs with explicit target identity and required fields. Preserve
meaningful target, action, capture, and output choices; remove ignored options
and repeated permission declarations.
One-shot CLI provider inspections retain the caller's exact target spelling
in Evidence `parameters.input_path`; the subject keeps the canonical inspected
file identity. Keep request provenance separate from provider arguments.
Snapshot entries bind Evidence parameters to provider queries, so changes to
snapshot-backed request provenance must preserve that cache contract.
One-shot binary CLI analysis retains the caller's verbatim target spelling in
Evidence `caller_input_path`, including a result loaded from a snapshot. The
subject keeps the inspected file identity. Provider inspections also retain
the established `parameters.input_path` field for existing consumers. Keep
request provenance separate from provider arguments and snapshot queries.
- Return the requested facts inline with artifact/provider identity, source
locations, Evidence references, coverage, and relevant confidence and
limitations. Use supported selection, summaries or views when they answer
+27 -12
View File
@@ -8,7 +8,11 @@ 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 } from "../domain/evidence.js";
import {
createEvidence,
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";
import {
@@ -209,18 +213,19 @@ export const runProviderAnalysis = async (
);
await dispatchQueuedInterrupts();
if (!original.ok) return cliError(original.error);
const evidence = withEvidenceCallerInputPath(original.value, path);
if (operationSignal.aborted)
return cliError(
new AnalysisCancelledError(tool, {
cause: operationSignal.reason,
partialObservation: original.value,
partialObservation: evidence,
}),
);
const result =
delivery?.evidenceBundlePath === undefined
? original
? ok(evidence)
: await deliverAnalysisEvidence(
original.value,
evidence,
delivery.evidenceBundlePath,
delivery.detail,
operationSignal,
@@ -342,11 +347,14 @@ const runAnalysis = async (
const execution = await session.execute(tool, arguments_, { signal });
if (!execution.ok) return err(execution.error);
return ok(
createAnalysisExecutionEvidence(
tool,
evidenceParameters,
execution.value,
"observed",
withEvidenceCallerInputPath(
createAnalysisExecutionEvidence(
tool,
evidenceParameters,
execution.value,
"observed",
),
path,
),
);
}
@@ -411,7 +419,8 @@ const runAnalysis = async (
: providerIdentityForRoute(route, tool),
evidenceProfile,
});
if (cached !== undefined) return ok(cached);
if (cached !== undefined)
return ok(withEvidenceCallerInputPath(cached, path));
}
}
const openOptions = {
@@ -446,8 +455,14 @@ const runAnalysis = async (
evidenceProfile,
});
if (!executed.ok) return executed;
const { evidence, cachePolicy } = executed.value;
const recorded = session.recordEvidence(evidence);
const { cachePolicy } = executed.value;
const evidence = withEvidenceCallerInputPath(executed.value.evidence, path);
// Provider execution may have already recorded the same semantic Evidence
// without this invocation's request path. Import replaces that metadata
// while retaining the authenticated result and snapshot binding.
const recorded = session.importEvidenceBundle(
createImmutableEvidenceBundle([evidence]),
);
if (!recorded.ok)
return err(recorded.error.retainPartialObservation(evidence));
if (isWorkflowEvidenceTool(tool)) {
+12 -2
View File
@@ -12,7 +12,10 @@ import {
AnalysisCancelledError,
AnalysisInputError,
} from "../domain/analysisErrorCore.js";
import type { Evidence } from "../domain/evidence.js";
import {
withEvidenceCallerInputPath,
type Evidence,
} from "../domain/evidence.js";
import {
createImmutableEvidenceBundle,
type EvidenceBundle,
@@ -109,10 +112,17 @@ export const deliverAnalysisEvidence = async (
)
: ok(evidence);
if (!delivered.ok) return delivered;
// This summary answers the same CLI invocation as its complete parent.
// Later inspect_analysis_view requests are separate questions and do not
// inherit the parent's selected input path.
const selected =
detail === "summary" && evidence.caller_input_path !== undefined
? withEvidenceCallerInputPath(delivered.value, evidence.caller_input_path)
: delivered.value;
const published = await publishEvidenceDelivery({
bundle,
complete: evidence,
delivered: delivered.value,
delivered: selected,
outputPath,
operation: evidence.operation,
signal,
@@ -174,7 +174,7 @@ it.each(["provider", "workflow"] as const)(
};
const nameImport = ledger.import(createEvidenceBundle([nameOnly]));
if (!nameImport.ok) throw nameImport.error;
cache.refreshEvidenceDisplay(nameImport.value.replacements);
cache.refreshEvidenceMetadata(nameImport.value.replacements);
expect(cache.entries()).toEqual(before.value.entries);
expect(cache.workflowEntries()).toEqual(before.value.workflow_entries);
// A different observation for this exact query is not the entry's dependency.
@@ -201,7 +201,7 @@ it.each(["provider", "workflow"] as const)(
createEvidenceBundle([relocate(alternative)]),
);
if (!foreign.ok) throw foreign.error;
cache.refreshEvidenceDisplay(foreign.value.replacements);
cache.refreshEvidenceMetadata(foreign.value.replacements);
expect(cache.entries()).toEqual(before.value.entries);
expect(cache.workflowEntries()).toEqual(before.value.workflow_entries);
// A result may cite several observations; updating their envelopes does not change its payload.
@@ -209,13 +209,13 @@ it.each(["provider", "workflow"] as const)(
createEvidenceBundle(dependencies.map(relocate)),
);
if (!dependentImport.ok) throw dependentImport.error;
cache.refreshEvidenceDisplay(dependentImport.value.replacements);
cache.refreshEvidenceMetadata(dependentImport.value.replacements);
expect(cache.entries()).toEqual(before.value.entries);
expect(cache.workflowEntries()).toEqual(before.value.workflow_entries);
const relocated = relocate(evidence);
const imported = ledger.import(createEvidenceBundle([relocated]));
if (!imported.ok) throw imported.error;
cache.refreshEvidenceDisplay(imported.value.replacements);
cache.refreshEvidenceMetadata(imported.value.replacements);
const after = cache.export(target, profile, ledger.export());
if (!after.ok) throw after.error;
const entries =
@@ -37,7 +37,7 @@ import type {
import { OFFICIAL_TOOL_CONTRACTS } from "../../contracts/officialToolContracts.js";
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
import type { EvidenceDisplayReplacement } from "../investigation/EvidenceLedger.js";
import type { EvidenceMetadataReplacement } from "../investigation/EvidenceLedger.js";
const RELATIONSHIP_OPERATIONS: ReadonlySet<AnalysisOperation> = new Set([
"procedure_callers",
@@ -183,9 +183,9 @@ export class AnalysisSnapshotCache {
return imported;
}
/** Refresh only display bindings backed by the replaced authenticated records. */
refreshEvidenceDisplay(
replacements: readonly EvidenceDisplayReplacement[],
/** Refresh subject bindings backed by replaced authenticated metadata. */
refreshEvidenceMetadata(
replacements: readonly EvidenceMetadataReplacement[],
): void {
if (this.#target === undefined || this.#binding === undefined) return;
const header = { target: this.#target, binding: this.#binding };
@@ -170,7 +170,7 @@ export abstract class BinarySessionRecords {
): Result<number, EvidenceIntegrityError | AnalysisInputError> {
const imported = this.#records.mergeEvidenceBundle(bundle);
if (!imported.ok) return imported;
this.#snapshot.refreshEvidenceDisplay(imported.value.replacements);
this.#snapshot.refreshEvidenceMetadata(imported.value.replacements);
if (imported.value.changed) this.#emitSnapshotChanged();
return ok(imported.value.recordsAdded);
}
@@ -259,7 +259,7 @@ export abstract class BinarySessionRecords {
(bundle) => {
const imported = this.#records.mergeEvidenceBundle(bundle);
if (!imported.ok) return imported;
this.#snapshot.refreshEvidenceDisplay(imported.value.replacements);
this.#snapshot.refreshEvidenceMetadata(imported.value.replacements);
return ok(imported.value.recordsAdded);
},
);
@@ -1,7 +1,10 @@
import { describe, expect, it } from "vitest";
import type { BinaryTarget } from "../../domain/binaryTargetTypes.js";
import { createEvidence } from "../../domain/evidence.js";
import {
createEvidence,
withEvidenceCallerInputPath,
} from "../../domain/evidence.js";
import { createEvidenceBundle } from "../../domain/evidenceBundle.js";
import { recordUnknownInputSchema } from "../../domain/residualUnknown.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
@@ -17,6 +20,32 @@ const TARGET: BinaryTarget = {
};
const PROVIDER = { id: "fixture", name: "Fixture provider", version: "1" };
it("accepts and replaces caller path metadata for the same semantic record", () => {
const original = createEvidence(TARGET, PROVIDER, {
operation: "health",
parameters: {},
result: true,
});
const selected = withEvidenceCallerInputPath(original, "./selected-alias");
const ledger = new EvidenceLedger();
expect(ledger.record(original)).toEqual({ ok: true, value: "added" });
expect(ledger.record(selected)).toEqual({ ok: true, value: "duplicate" });
expect(ledger.import(createEvidenceBundle([selected]))).toEqual({
ok: true,
value: {
recordsAdded: 0,
unknownsAdded: 0,
changed: true,
replacements: [
{ kind: "metadata", previous: original, current: selected },
],
},
});
expect(ledger.get(original.evidence_id)?.caller_input_path).toBe(
"./selected-alias",
);
});
describe("evidence ledger recording", () => {
it("deduplicates and atomically imports bundles", () => {
const evidence = createEvidence(TARGET, PROVIDER, {
@@ -62,7 +91,7 @@ describe("evidence ledger recording", () => {
changed: true,
replacements: [
{
kind: "subject-display",
kind: "metadata",
previous: evidence,
current: relocatedBundleRecord,
},
@@ -202,7 +231,7 @@ describe("evidence ledger metadata imports", () => {
changed: true,
replacements: [
{
kind: "subject-display",
kind: "metadata",
previous: mutation,
current: relocatedMutation,
},
+19 -27
View File
@@ -12,7 +12,10 @@ import {
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
import { UnknownRegistryError } from "../../domain/unknownRegistryError.js";
import type { Evidence } from "../../domain/evidence.js";
import {
evidenceSemanticContent,
type Evidence,
} from "../../domain/evidence.js";
import { safeParseImmutableEvidence } from "../../domain/evidenceValidation.js";
import {
createResidualUnknown,
@@ -30,9 +33,9 @@ import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.
type EvidenceLedgerFailure = EvidenceIntegrityError;
type RecordResult = Result<"added" | "duplicate", EvidenceLedgerFailure>;
/** Same authenticated semantic identity with new subject display metadata. */
export interface EvidenceDisplayReplacement {
readonly kind: "subject-display";
/** Same authenticated semantic identity with new local display/request metadata. */
export interface EvidenceMetadataReplacement {
readonly kind: "metadata";
readonly previous: Evidence;
readonly current: Evidence;
}
@@ -42,7 +45,7 @@ export interface EvidenceImportDelta {
readonly recordsAdded: number;
readonly unknownsAdded: number;
readonly changed: boolean;
readonly replacements: readonly EvidenceDisplayReplacement[];
readonly replacements: readonly EvidenceMetadataReplacement[];
}
type ImportResult = Result<
@@ -103,7 +106,7 @@ export class EvidenceLedger {
// atomic merge without rebuilding and reparsing the entire ledger.
if (bundle.unknowns.length === 0) {
const additions = new Map<string, Evidence>();
const replacements: EvidenceDisplayReplacement[] = [];
const replacements: EvidenceMetadataReplacement[] = [];
for (const evidence of bundle.records) {
const existing = this.#records.get(evidence.evidence_id);
if (existing !== undefined) {
@@ -111,9 +114,9 @@ export class EvidenceLedger {
return err(
new EvidenceIntegrityError("Conflicting evidence record"),
);
if (!displaySubjectsAgree(existing, evidence)) {
if (!displayMetadataAgree(existing, evidence)) {
replacements.push({
kind: "subject-display",
kind: "metadata",
previous: existing,
current: evidence,
});
@@ -133,14 +136,14 @@ export class EvidenceLedger {
});
}
const pending = new Map(this.#records);
const replacements: EvidenceDisplayReplacement[] = [];
const replacements: EvidenceMetadataReplacement[] = [];
for (const evidence of bundle.records) {
const existing = pending.get(evidence.evidence_id);
if (existing !== undefined && !recordsAgree(existing, evidence))
return err(new EvidenceIntegrityError("Conflicting evidence record"));
if (existing !== undefined && !displaySubjectsAgree(existing, evidence))
if (existing !== undefined && !displayMetadataAgree(existing, evidence))
replacements.push({
kind: "subject-display",
kind: "metadata",
previous: existing,
current: evidence,
});
@@ -514,21 +517,10 @@ const unknownRevisionKey = (unknown: ResidualUnknown): string =>
`${unknown.unknown_id}:${String(unknown.revision)}`;
const recordsAgree = (left: Evidence, right: Evidence): boolean =>
canonicalize(withoutLocalPath(left)) ===
canonicalize(withoutLocalPath(right));
canonicalize(evidenceSemanticContent(left)) ===
canonicalize(evidenceSemanticContent(right));
const withoutLocalPath = (evidence: Evidence): Evidence => ({
...evidence,
subject:
evidence.subject === null
? null
: {
...evidence.subject,
name: "<non-identity-name>",
local_path: "<non-identity-local-path>",
},
});
const displaySubjectsAgree = (left: Evidence, right: Evidence): boolean =>
const displayMetadataAgree = (left: Evidence, right: Evidence): boolean =>
left.subject?.name === right.subject?.name &&
left.subject?.local_path === right.subject?.local_path;
left.subject?.local_path === right.subject?.local_path &&
left.caller_input_path === right.caller_input_path;
@@ -44,7 +44,7 @@ it.each(["immutable", "detached"] as const)(
);
if (!merged.ok) throw merged.error;
expect(merged.value.replacements).toEqual([
{ kind: "subject-display", previous: first, current: replacement },
{ kind: "metadata", previous: first, current: replacement },
]);
expect(lookup(first.evidence_id)?.subject?.local_path).toBe("/moved");
expect(
+30
View File
@@ -12,6 +12,7 @@ import {
evidenceSchema,
parseEvidence,
immutableEvidence,
withEvidenceCallerInputPath,
} from "./evidence.js";
import { MAX_JSON_DEPTH, type JsonValue } from "./jsonValue.js";
import { createEvidenceBundle } from "./evidenceBundle.js";
@@ -153,6 +154,35 @@ it("preserves identity and bytes when reusing an owned immutable result", () =>
).toThrow("semantic identifier");
});
it("retains verbatim caller input without changing semantic identity or copying results", () => {
const original = createEvidence(TARGET, PROVIDER, {
operation: "inspect",
parameters: { address: "0x1000" },
result: { nested: ["observed"] },
rawResult: { source: "provider" },
});
const first = withEvidenceCallerInputPath(original, "./alias/../selected");
const second = withEvidenceCallerInputPath(first, "/another/alias");
expect(first.caller_input_path).toBe("./alias/../selected");
expect(second.caller_input_path).toBe("/another/alias");
expect(original.caller_input_path).toBeUndefined();
expect(second.evidence_id).toBe(original.evidence_id);
expect(second.parameters).toEqual({ address: "0x1000" });
expect(second.subject?.local_path).toBe(TARGET.path);
expect(second.normalized_result).toBe(first.normalized_result);
expect(second.raw_result).toBe(first.raw_result);
expect(Object.isFrozen(second)).toBe(true);
expect(withEvidenceCallerInputPath(second, "/another/alias")).toBe(second);
expect(parseEvidence(JSON.parse(JSON.stringify(second)))).toEqual(second);
expect(parseEvidence(JSON.parse(JSON.stringify(original)))).toEqual(original);
expect(
evidenceSchema.safeParse({ ...original, caller_input_path: undefined })
.success,
).toBe(false);
expect(() => withEvidenceCallerInputPath(second, "invalid\0path")).toThrow();
});
it("applies the JSON depth boundary to owned immutable results", () => {
let result: JsonValue = 1;
for (let index = 0; index <= MAX_JSON_DEPTH; index += 1)
+28 -5
View File
@@ -144,6 +144,10 @@ const evidenceBaseSchema = z
predicate_type: z.string().min(1),
operation: z.string().min(1),
parameters: jsonObjectSchema,
/** Verbatim selected path for a direct request; absent on older records. */
caller_input_path: nulFreeStringSchema
.exactOptional()
.describe("Verbatim direct-request path; not part of semantic identity."),
raw_result: jsonValueSchema.nullable(),
normalized_result: jsonValueSchema,
confidence: z.enum(["observed", "derived", "inferred"]),
@@ -257,7 +261,8 @@ export interface EvidenceObservation {
type EvidenceWithoutId = Omit<EvidenceSchemaValue, "evidence_id">;
const semanticProjection = (
/** Content committed by an Evidence ID, excluding local request/display metadata. */
export const evidenceSemanticContent = (
evidence: EvidenceWithoutId,
): Record<string, JsonValue> => ({
subject:
@@ -284,9 +289,9 @@ const semanticProjection = (
evidence_links: evidence.evidence_links,
});
/** Hash semantic content, including raw results and excluding display-subject fields. */
/** Hash semantic content, including raw results and excluding local metadata. */
const computeEvidenceId = (evidence: EvidenceWithoutId): string =>
`ev_${digestCanonicalValue(semanticProjection(evidence), "Evidence")}`;
`ev_${digestCanonicalValue(evidenceSemanticContent(evidence), "Evidence")}`;
/** Recognize an obsolete commitment for diagnosis only; never admit or rewrite it. */
const usesPre66NullCoverageIdentity = (
@@ -294,7 +299,7 @@ const usesPre66NullCoverageIdentity = (
evidenceId: string,
): boolean => {
if (evidence.relationship_coverage !== null) return false;
const legacyProjection = semanticProjection(evidence);
const legacyProjection = evidenceSemanticContent(evidence);
delete legacyProjection.relationship_coverage;
return (
evidenceId ===
@@ -357,6 +362,24 @@ export const rebindEvidenceToTargetPath = (
return reboundEvidence;
};
/** Bind the exact selected request path without changing semantic identity or stored history. */
export const withEvidenceCallerInputPath = (
evidence: Evidence,
rawPath: string,
): Evidence => {
const callerInputPath = nulFreeStringSchema.parse(rawPath);
const owned = immutableEvidence(evidence);
if (owned.caller_input_path === callerInputPath) return owned;
// The authenticated source owns its immutable payloads. Only this small
// envelope changes, so replay does not copy a document-sized result.
const rebound = Object.freeze({
...owned,
caller_input_path: callerInputPath,
});
immutableEvidenceSnapshots.set(rebound, rebound);
return rebound;
};
/** Recognize authenticated Evidence whose complete reachable JSON data is immutable. */
export const isImmutableEvidence = (evidence: Evidence): boolean =>
immutableEvidenceSnapshots.has(evidence);
@@ -418,7 +441,7 @@ export function* createImmutableEvidenceSteps(
observation,
);
const digest = yield* canonicalJsonDigestSteps(
semanticProjection(normalized),
evidenceSemanticContent(normalized),
);
return rememberImmutableEvidence({
...normalized,
@@ -211,6 +211,7 @@ cliTest.skipIf(process.platform !== "darwin")(
const cliEvidence = toolContract(
"inspect_native_dispatch_metadata",
).outputSchema.parse(firstCli.json);
expect(cliEvidence.caller_input_path).toBe(target);
const cliSaved = await readAnalysisSnapshot(cliSnapshot);
if (!cliSaved.ok) throw cliSaved.error;
expect(cliSaved.value.binding.analysis_profile).toEqual(
@@ -218,16 +219,42 @@ cliTest.skipIf(process.platform !== "darwin")(
);
expect(cliSaved.value.entries).toEqual([]);
expect(cliSaved.value.workflow_entries).toHaveLength(1);
expect(
cliSaved.value.evidence_bundle.records.find(
(record) => record.evidence_id === cliEvidence.evidence_id,
)?.caller_input_path,
).toBe(target);
const savedQueryId = cliSaved.value.workflow_entries[0]?.query_id;
const replayCli = await cli.run({
arguments: cliArguments,
arguments: [
"inspect-native-dispatch-metadata",
"minimal.macho",
"--snapshot",
cliSnapshot,
"--json",
],
cwd: root,
environment: isolatedProviderEnvironment,
});
expect(replayCli.exitCode, replayCli.stderr).toBe(0);
const replayEvidence = toolContract(
"inspect_native_dispatch_metadata",
).outputSchema.parse(replayCli.json);
expect(replayEvidence).toEqual({
...cliEvidence,
caller_input_path: "minimal.macho",
});
const replaySaved = await readAnalysisSnapshot(cliSnapshot);
if (!replaySaved.ok) throw replaySaved.error;
expect(replaySaved.value.workflow_entries[0]?.query_id).toBe(savedQueryId);
expect(replaySaved.value.workflow_entries[0]?.parameters).toEqual(
cliSaved.value.workflow_entries[0]?.parameters,
);
expect(
toolContract("inspect_native_dispatch_metadata").outputSchema.parse(
replayCli.json,
),
).toEqual(cliEvidence);
replaySaved.value.evidence_bundle.records.find(
(record) => record.evidence_id === cliEvidence.evidence_id,
)?.caller_input_path,
).toBe(target);
const changedLimitCli = await cli.run({
arguments: [...cliArguments, "--max-records", "1"],
environment: isolatedProviderEnvironment,
@@ -250,6 +277,7 @@ cliTest.skipIf(process.platform !== "darwin")(
});
expect(firstMcp.isError, JSON.stringify(firstMcp)).not.toBe(true);
const mcpEvidence = parseEvidence(firstMcp.structuredContent);
expect(mcpEvidence).not.toHaveProperty("caller_input_path");
const closed = await call("close_binary", {
snapshot_path: mcpSnapshot,
overwrite: true,
@@ -262,6 +290,9 @@ cliTest.skipIf(process.platform !== "darwin")(
);
expect(mcpSaved.value.entries).toEqual([]);
expect(mcpSaved.value.workflow_entries).toHaveLength(1);
expect(mcpSaved.value.evidence_bundle.records[0]).not.toHaveProperty(
"caller_input_path",
);
const reopened = await call("open_binary", {
path: target,
@@ -133,6 +133,7 @@ cliTest(
const omitted = await cli.run({ arguments: arguments_ });
expect(omitted.exitCode).toBe(0);
const complete = parseEvidence(omitted.json);
expect(complete.caller_input_path).toBe(path);
const completeBundlePath = join(root, "complete.json");
const explicit = await cli.run({
arguments: [
@@ -144,12 +145,18 @@ cliTest(
],
});
expect(explicit.exitCode).toBe(0);
expect(parseEvidence(explicit.json).evidence_id).toBe(complete.evidence_id);
expect(parseEvidence(explicit.json)).toMatchObject({
evidence_id: complete.evidence_id,
caller_input_path: path,
});
expect(
parseEvidenceBundle(
JSON.parse(await readFile(completeBundlePath, "utf8")),
).records[0]?.evidence_id,
).toBe(complete.evidence_id);
).records[0],
).toMatchObject({
evidence_id: complete.evidence_id,
caller_input_path: path,
});
const reply = await cli.run({
cwd: root,
@@ -163,6 +170,7 @@ cliTest(
});
expect(reply.exitCode).toBe(0);
const receipt = parseEvidence(reply.json);
expect(receipt.caller_input_path).toBe(path);
const summary = managedMemberSummaryViewResultSchema.parse(
receipt.normalized_result,
);
@@ -171,11 +179,15 @@ cliTest(
expect(bundle.records).toHaveLength(1);
const parent = bundle.records[0]!;
expect(parent.evidence_id).toBe(complete.evidence_id);
expect(parent.caller_input_path).toBe(path);
expect(parent.normalized_result).toEqual(complete.normalized_result);
expect(parent.raw_result).toEqual(complete.raw_result);
expect(parent.subject).toEqual(complete.subject);
expect(parent.locations).toEqual(complete.locations);
expect(parent.parameters).toEqual({ type_names: typeNames });
expect(parent.parameters).toEqual({
type_names: typeNames,
input_path: path,
});
expect(receipt.evidence_links).toEqual([parent.evidence_id]);
const portable = await inspectAnalysisView({
source: { kind: "inline", evidence: parent },
@@ -183,6 +195,7 @@ cliTest(
});
if (!portable.ok) throw portable.error;
expect(receipt.evidence_id).toBe(portable.value.evidence_id);
expect(portable.value).not.toHaveProperty("caller_input_path");
expect(receipt.normalized_result).toEqual(portable.value.normalized_result);
const report = managedMemberInspectionSchema.parse(
parent.normalized_result,
@@ -238,6 +251,7 @@ cliTest(
expect(response.exitCode).toBe(0);
const evidence = parseEvidence(response.json);
expect(evidence.evidence_links).toEqual([parent.evidence_id]);
expect(evidence).not.toHaveProperty("caller_input_path");
expect(evidence.subject).toEqual(parent.subject);
const page = analysisViewResultSchema.parse(evidence.normalized_result);
if (page.kind !== "managed-members") throw new Error("Wrong page kind");
@@ -1,5 +1,5 @@
import { parseConfig } from "../../../src/config/parseConfig.js";
import { symlink, writeFile } from "node:fs/promises";
import { readFile, symlink, writeFile } from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import { describe, expect, it, vi } from "vitest";
@@ -130,7 +130,10 @@ const expectWorkflowQuestionsRetained = (
interface SnapshotProviderTrace {
readonly starts: string[];
readonly calls: string[];
readonly queries: Array<{
readonly operation: string;
readonly parameters: Readonly<Record<string, unknown>>;
}>;
readonly resolutions: string[];
}
@@ -178,8 +181,11 @@ const makeProvider = (
createClient: () => {
trace.starts.push("start");
return {
execute: async (operation) => {
trace.calls.push(operation);
execute: async (operation, parameters) => {
trace.queries.push({
operation,
parameters: structuredClone(parameters),
});
const results: Readonly<Record<string, unknown>> = {
health: null,
read_binary_inventory_summary: {
@@ -247,17 +253,20 @@ const createSnapshotReplayFixture = async (
const snapshotPath = join(directory, snapshotRelativePath);
await writeFile(path, "fixture");
const starts: string[] = [];
const calls: string[] = [];
const queries: SnapshotProviderTrace["queries"] = [];
const resolutions: string[] = [];
return {
path,
snapshotPath,
starts,
calls,
get calls() {
return queries.map(({ operation }) => operation);
},
queries,
resolutions,
createProvider(options: SnapshotProviderOptions = {}): AnalysisProvider {
return makeProvider({ starts, calls, resolutions }, options);
return makeProvider({ starts, queries, resolutions }, options);
},
dependencies(provider: AnalysisProvider): DirectAnalysisDependencies {
return {
@@ -757,6 +766,7 @@ describe("binary overview snapshot replay", () => {
{},
{ snapshotPath: fixture.snapshotPath },
);
expect(parseEvidence(first).caller_input_path).toBe(fixture.path);
expect(fixture.calls.toSorted()).toEqual(operations.toSorted());
expect(fixture.starts).toHaveLength(1);
@@ -802,6 +812,7 @@ describe("binary overview snapshot replay", () => {
const relocatedEvidence = parseEvidence(relocated);
expect(relocatedEvidence).toMatchObject({
evidence_id: firstEvidence.evidence_id,
caller_input_path: relocatedPath,
subject: {
local_path: relocatedPath,
name: basename(relocatedPath),
@@ -822,9 +833,137 @@ describe("binary overview snapshot replay", () => {
(record) => record.evidence_id === firstEvidence.evidence_id,
)?.subject?.local_path,
).toBe(fixture.path);
expect(
persisted.value.evidence_bundle.records.find(
(record) => record.evidence_id === firstEvidence.evidence_id,
)?.caller_input_path,
).toBe(fixture.path);
});
});
it.skipIf(process.platform === "win32")(
"keeps a selected alias separate from the canonical snapshot query and refreshes its replay provenance",
async () => {
const fixture = await createSnapshotReplayFixture(
"rea-snapshot-selected-alias-",
);
const alias = join(dirname(fixture.path), "selected-alias.hop");
await symlink(fixture.path, alias);
const provider = fixture.createProvider({
supportedOperations: ["search_strings"],
});
const dependencies = fixture.dependencies(provider);
const parameters = { pattern: "fixture", document: "fixture" };
const options = { snapshotPath: fixture.snapshotPath };
const first = parseEvidence(
await runDirectAnalysis(
dependencies,
alias,
"search_strings",
parameters,
options,
),
);
expect(first).toMatchObject({
caller_input_path: alias,
parameters,
subject: { local_path: fixture.path },
});
expect(
fixture.queries.filter(({ operation }) => operation === "search_strings"),
).toEqual([{ operation: "search_strings", parameters }]);
const saved = await readAnalysisSnapshot(fixture.snapshotPath);
if (!saved.ok) throw saved.error;
expect(saved.value.entries).toEqual([
expect.objectContaining({
operation: "search_strings",
parameters,
query_id: analysisQueryId(
saved.value.target,
saved.value.binding,
"search_strings",
parameters,
),
}),
]);
expect(saved.value.evidence_bundle.records).toContainEqual(
expect.objectContaining({
evidence_id: first.evidence_id,
caller_input_path: alias,
subject: expect.objectContaining({ local_path: fixture.path }),
}),
);
const originalBytes = await readFile(fixture.snapshotPath);
const starts = [...fixture.starts];
const queries = [...fixture.queries];
const replay = parseEvidence(
await runDirectAnalysis(
dependencies,
fixture.path,
"search_strings",
parameters,
options,
),
);
expect(replay).toMatchObject({
evidence_id: first.evidence_id,
caller_input_path: fixture.path,
parameters,
subject: { local_path: fixture.path },
});
expect(fixture.starts).toEqual(starts);
expect(fixture.queries).toEqual(queries);
expect((await readFile(fixture.snapshotPath)).equals(originalBytes)).toBe(
true,
);
// A file saved before caller_input_path existed still authenticates and
// supplies the current request's spelling without rewriting history.
const legacy = structuredClone(saved.value);
for (const record of legacy.evidence_bundle.records)
Reflect.deleteProperty(record, "caller_input_path");
const written = await withArtifactResourceScope((resources) =>
writeAnalysisSnapshot(legacy, fixture.snapshotPath, true, resources),
);
if (!written.ok) throw written.error;
const historicalBytes = await readFile(fixture.snapshotPath);
const historical = await readAnalysisSnapshot(fixture.snapshotPath);
if (!historical.ok) throw historical.error;
expect(historical.value.evidence_bundle.records).toContainEqual(
expect.objectContaining({ evidence_id: first.evidence_id }),
);
expect(
historical.value.evidence_bundle.records.find(
(record) => record.evidence_id === first.evidence_id,
),
).not.toHaveProperty("caller_input_path");
const historicalReplay = parseEvidence(
await runDirectAnalysis(
dependencies,
alias,
"search_strings",
parameters,
options,
),
);
expect(historicalReplay).toMatchObject({
evidence_id: first.evidence_id,
caller_input_path: alias,
parameters,
subject: { local_path: fixture.path },
});
expect(fixture.starts).toEqual(starts);
expect(fixture.queries).toEqual(queries);
expect((await readFile(fixture.snapshotPath)).equals(historicalBytes)).toBe(
true,
);
},
);
describe("snapshot replay provider identity", () => {
it("uses the resolved provider version when capability metadata omits it", async () => {
const fixture = await createSnapshotReplayFixture("rea-profile-version-");
@@ -27,7 +27,11 @@ import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceSc
import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js";
import { OwnedStagedOutput } from "../../../src/filesystem/OwnedStagedOutput.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
import {
createEvidence,
parseEvidence,
withEvidenceCallerInputPath,
} from "../../../src/domain/evidence.js";
import { writeTextParts } from "../../../src/application/JsonFiles.js";
import { JSON_BYTE_ORDER_MARK_MESSAGE } from "../../../src/application/Utf8JsonInput.js";
import { failFileReadAfterAbort } from "../../support/failFileReadAfterAbort.js";
@@ -49,6 +53,30 @@ const bundle = (result = true) =>
),
]);
it.each([undefined, "../selected-link/./artifact"])(
"keeps same-invocation caller input on delivered summary and saved parent (%s)",
async (callerInputPath) => {
const root = await createTestTempDirectory("rea-delivery-input-path-");
const path = join(root, "bundle.json");
const original = analysisViewLayoutEvidence();
const parent =
callerInputPath === undefined
? original
: withEvidenceCallerInputPath(original, callerInputPath);
const delivered = await deliverAnalysisEvidence(parent, path, "summary");
if (!delivered.ok) throw delivered.error;
expect(delivered.value.caller_input_path).toBe(callerInputPath);
expect(delivered.value.evidence_links).toContain(parent.evidence_id);
expect(original.caller_input_path).toBeUndefined();
const saved = await readEvidenceBundle(path);
if (!saved.ok) throw saved.error;
const savedParent = saved.value.records.find(
(record) => record.evidence_id === parent.evidence_id,
);
expect(savedParent?.caller_input_path).toBe(callerInputPath);
},
);
it.each([0, 9 * 1024 * 1024])(
"preserves the format owner's BOM recovery with %i bytes of padding",
async (padding) => {
@@ -343,7 +343,8 @@ describe("MCP composed workflow snapshot replay", () => {
{ snapshotPath },
);
expect(replay).toEqual(output);
expect(output).not.toHaveProperty("caller_input_path");
expect(replay).toEqual({ ...output, caller_input_path: targetPath });
expect(calls).toEqual(afterAnalysisCalls);
expect(starts).toEqual(afterAnalysisStarts);
});