fix(evidence): preserve source and authenticated observation identity

This commit is contained in:
morluto committed 2026-10-26 16:46:45 +08:00
1 parent 6336fe5bb5
commit 09acf217d9
5 files changed
+224 -23

No files matched your search

+38 -13
View File
@@ -1,9 +1,13 @@
import type { BigIntStats, Dir, Dirent } from "node:fs";
import { opendir, readlink, realpath } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { opendir, readlink, realpath, stat } from "node:fs/promises";
import { basename, dirname, join, sep } from "node:path";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import type { ArtifactCleanupAttempt } from "../artifacts/ArtifactResourceScope.js";
import { OwnedDirectoryHandle } from "../filesystem/OwnedDirectoryHandle.js";
import {
absoluteCallerPath,
appendCallerPath,
} from "../filesystem/callerPath.js";
import { err, ok } from "../domain/result.js";
import {
@@ -455,18 +459,15 @@ const readSymlinkTarget = async (
`Symlink target is not valid UTF-8 (raw target bytes: ${rawTarget.toString("hex")})`,
);
}
const lexicalTarget = resolve(dirname(absolute), target);
if (!isPathWithinRoot(root, lexicalTarget))
return {
status: "read",
kind: "symlink",
path,
target: lexicalTarget,
targetState: "external",
};
const nativeTarget = absoluteCallerPath(target, { cwd: dirname(absolute) });
let canonicalTarget: string;
try {
canonicalTarget = await realpath(lexicalTarget);
// The filesystem resolves parent segments after following symlinks. A
// lexical containment check can both select a decoy and misclassify scope.
// realpath can collapse file/.. on some hosts even though native lookup
// rejects that traversal, so establish existence through stat first.
await stat(nativeTarget);
canonicalTarget = await realpath(nativeTarget);
} catch (cause: unknown) {
const code =
cause instanceof Error ? Reflect.get(cause, "code") : undefined;
@@ -475,7 +476,7 @@ const readSymlinkTarget = async (
status: "read",
kind: "symlink",
path,
target: pathFromRoot(root, lexicalTarget),
target: await missingSymlinkPath(root, nativeTarget),
targetState: "missing",
};
}
@@ -489,6 +490,30 @@ const readSymlinkTarget = async (
};
};
/** Preserve the native spelling when a missing parent prevents canonical lookup. */
const missingSymlinkPath = async (
root: string,
target: string,
): Promise<string> => {
if (basename(target) === "..") return target;
let parent: string;
try {
await stat(dirname(target));
parent = await realpath(dirname(target));
} catch (cause: unknown) {
const code =
cause instanceof Error ? Reflect.get(cause, "code") : undefined;
if (code === "ENOENT" || code === "ENOTDIR") return target;
throw cause;
}
const candidate = appendCallerPath(parent, basename(target));
// A trailing separator requires a directory; relative() would erase it.
if (target.endsWith(sep)) return `${candidate}${sep}`;
return isPathWithinRoot(root, candidate)
? pathFromRoot(root, candidate)
: candidate;
};
const applyExclusion = (
shouldExclude:
| ((path: string, kind: ReferenceSourceExclusionKind) => boolean)
+6 -5
View File
@@ -3,7 +3,8 @@ import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from "@modelcontextprotocol/server";
import type { ToolContract } from "../contracts/toolContractTypes.js";
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
import { evidenceSchema, type Evidence } from "../domain/evidence.js";
import type { Evidence } from "../domain/evidence.js";
import { safeParseEvidence } from "../domain/evidenceValidation.js";
import {
projectAnalysisError,
type AnalysisErrorProjection,
@@ -155,9 +156,9 @@ export class ToolResultDelivery {
): CallToolResult {
if (recordEvidence === undefined)
return this.toErrorToolResult(error, targetContent);
const partial = evidenceSchema.safeParse(error.partialObservation);
if (!partial.success) return this.toErrorToolResult(error, targetContent);
const recorded = recordEvidence(partial.data);
const partial = safeParseEvidence(error.partialObservation);
if (!partial.ok) return this.toErrorToolResult(error, targetContent);
const recorded = recordEvidence(partial.value);
if (!recorded.ok) return this.toErrorToolResult(error, targetContent);
const projected = projectAnalysisError(error);
return this.projectedErrorResult(
@@ -167,7 +168,7 @@ export class ToolResultDelivery {
...projected.details,
partial_observation: {
kind: "retained-evidence",
evidence_id: partial.data.evidence_id,
evidence_id: partial.value.evidence_id,
},
},
},
@@ -0,0 +1,134 @@
import {
mkdir,
readFile,
readlink,
symlink,
unlink,
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { historicalSourceGraphSchema } from "../../../src/domain/referenceSourceGraph.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
cliTest.skipIf(process.platform === "win32")(
"resolves source symlink targets through native traversal across retargeting and missing files",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-reference-native-links-");
const config = await createTestTempDirectory("rea-reference-link-config-");
const outside = await createTestTempDirectory(
"rea-reference-link-outside-",
);
for (const directory of [
join(root, "first"),
join(root, "second"),
outside,
]) {
await mkdir(join(directory, "deep"), { recursive: true });
await writeFile(
join(directory, "source.js"),
"export const selected = true;\n",
);
}
await writeFile(join(root, "source.js"), "export const decoy = true;\n");
const alias = join(root, "alias");
await symlink(join(root, "first/deep"), alias);
await symlink("alias/../source.js", join(root, "selected.js"));
await symlink("alias/../../source.js", join(root, "parent.js"));
await symlink("absent/../source.js", join(root, "missing-parent.js"));
await symlink("source.js/", join(root, "directory-required.js"));
await symlink("source.js/..", join(root, "file-parent.js"));
await symlink("source.js/../source.js", join(root, "file-parent-child.js"));
const run = async () => {
const result = await cli.run({
arguments: ["import-reference-source", root, "--json"],
environment: {
HOME: config,
XDG_CONFIG_HOME: config,
XDG_CACHE_HOME: config,
},
});
expect(result.exitCode, result.stderr + JSON.stringify(result.json)).toBe(
0,
);
return historicalSourceGraphSchema.parse(result.json);
};
const first = await run();
expect(first.entries).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: "selected.js",
target: "first/source.js",
target_state: "internal",
}),
expect.objectContaining({
path: "parent.js",
target: "source.js",
target_state: "internal",
}),
expect.objectContaining({
path: "missing-parent.js",
target: `${root}/absent/../source.js`,
target_state: "missing",
}),
expect.objectContaining({
path: "directory-required.js",
target: `${root}/source.js/`,
target_state: "missing",
}),
expect.objectContaining({
path: "file-parent.js",
target: `${root}/source.js/..`,
target_state: "missing",
}),
expect.objectContaining({
path: "file-parent-child.js",
target: `${root}/source.js/../source.js`,
target_state: "missing",
}),
]),
);
await unlink(alias);
await symlink(join(root, "second/deep"), alias);
const second = await run();
expect(second.entries).toContainEqual(
expect.objectContaining({
path: "selected.js",
target: "second/source.js",
target_state: "internal",
}),
);
await unlink(join(root, "second/source.js"));
const missing = await run();
expect(missing.entries).toContainEqual(
expect.objectContaining({
path: "selected.js",
target: "second/source.js",
target_state: "missing",
}),
);
await unlink(alias);
await symlink(join(outside, "deep"), alias);
const external = await run();
expect(external.entries).toContainEqual(
expect.objectContaining({
path: "selected.js",
target: join(outside, "source.js"),
target_state: "external",
}),
);
expect(
external.entries
.filter((entry) => entry.kind === "file")
.map((entry) => entry.path),
).toEqual(["first/source.js", "source.js"]);
expect(await readlink(join(root, "selected.js"))).toBe(
"alias/../source.js",
);
expect(await readFile(join(root, "source.js"), "utf8")).toBe(
"export const decoy = true;\n",
);
},
);
@@ -317,7 +317,7 @@ it("does not manufacture links for empty or failed composed calls", async () =>
expect(fixture.session.exportEvidenceBundle().records).toEqual([]);
});
it("drops an old cached ID rather than guessing after an ambiguous snapshot replacement", async () => {
it("reruns an ambiguous snapshot binding before selecting composed Evidence links", async () => {
const fixture = await composedWorkflowFixture();
const target = await fixture.open();
const initial = await fixture.session.execute(
@@ -352,6 +352,7 @@ it("drops an old cached ID rather than guessing after an ambiguous snapshot repl
);
if (!before.ok) throw before.error;
expect(before.value.evidenceId).toBe(initial.value.evidenceId);
expect(replay.calls).not.toContain("read_binary_inventory_summary");
expect(replay.session.importAnalysisSnapshot(ambiguous.value).ok).toBe(true);
const after = await replay.session.execute(
"read_binary_inventory_summary",
@@ -359,8 +360,47 @@ it("drops an old cached ID rather than guessing after an ambiguous snapshot repl
);
if (!after.ok) throw after.error;
expect(after.value.result).toEqual(fixture.summary);
expect(after.value.evidenceId).toBeUndefined();
expect(replay.calls).not.toContain("read_binary_inventory_summary");
expect(
replay.calls.filter(
(operation) => operation === "read_binary_inventory_summary",
),
).toHaveLength(1);
expect(after.value.limitations).not.toContain(
"Loaded from a local REA analysis snapshot; this call did not re-run the provider.",
);
if (after.value.evidenceId === undefined)
throw new Error("Fresh provider observation was not committed");
// Unchanged facts reproduce their deterministic identity. The provider call,
// rather than a choice between historical records, establishes this binding.
expect(after.value.evidenceId).toBe(initial.value.evidenceId);
expect(after.value.evidenceId).not.toBe(alternative.evidence_id);
const originalRecord = snapshot.value.evidence_bundle.records.find(
({ evidence_id }) => evidence_id === initial.value.evidenceId,
);
expect(replay.session.evidenceById(after.value.evidenceId)).toEqual(
originalRecord,
);
expect(replay.session.evidenceById(alternative.evidence_id)).toEqual(
alternative,
);
const composed = await executeComposedWorkflow(
replay.session,
async (observed) => {
const execution = await observed.execute(
"read_binary_inventory_summary",
{},
);
return execution.ok ? ok(execution.value.result) : execution;
},
);
if (!composed.ok) throw composed.error;
expect(composed.value.evidenceLinks).toEqual([after.value.evidenceId]);
expect(
replay.calls.filter(
(operation) => operation === "read_binary_inventory_summary",
),
).toHaveLength(1);
});
it("replaces provider-supplied identity with the record actually committed by the session", async () => {
@@ -43,6 +43,7 @@ it.each(operations)(
parameters: {},
result: { completed: true },
},
{ immutable: true },
);
const evidence: Record<ObservationOperation, Evidence> = {
list_browser_targets: makeEvidence("list_browser_targets"),
@@ -154,7 +155,7 @@ it.each(operations)(
},
});
expect(
session.evidenceById(evidence[selectedOperation].evidence_id),
).toEqual(evidence[selectedOperation]);
session.evidenceForAnalysis(evidence[selectedOperation].evidence_id),
).toBe(evidence[selectedOperation]);
},
);