Files
rea/tests/boundary/process/processFilesystemCoverageAdapters.test.ts
T
morluto a8609d562f fix(process): preserve observation-root failures and omitted symlinks
Keep typed search denial and acquired snapshot facts through cleanup and finalization. Record unfollowed symlink coverage at the snapshot owner, preserve physically enumerated parents, and retain uncertainty beneath omitted links without losing proven sibling deletions.

Update parsed omission contracts, public CLI/MCP regressions and documentation without introducing a new lifecycle or coverage framework.

Fixes #6446
Fixes #6444
2026-10-24 05:05:22 +08:00

632 lines
22 KiB
TypeScript

import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import {
chmod,
lstat,
mkdir,
readFile,
symlink,
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { expect, it, onTestFinished } from "vitest";
import { analysisCliErrorEnvelopeSchema } from "../../../src/contracts/errorSchemas.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { compareProcessCaptures } from "../../../src/domain/process/processComparison.js";
import { parseProcessCapture } from "../../../src/domain/process/processCaptureParsing.js";
import { parseProcessScenario } from "../../../src/domain/process/processScenario.js";
import { FINALIZED_PROCESS_CAPTURE_EXAMPLE } from "../../../src/domain/process/processCaptureExample.js";
import { createServer } from "../../../src/server/createServer.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import {
CAPTURE_SKIP_REASON,
itWithCaptureCapability,
} from "./processCaptureCapability.js";
const exec = promisify(execFile);
const captureViaCli = async (scenario: Record<string, unknown>) => {
const scenarioPath = join(
await createTestTempDirectory("rea-fs-scenario-"),
"scenario.json",
);
await writeFile(scenarioPath, JSON.stringify(scenario));
const { stdout } = await exec(process.execPath, [
"scripts/rea.mjs",
"capture-process",
scenarioPath,
"--format",
"json",
]);
return parseEvidence(JSON.parse(stdout));
};
const callCaptureViaMcp = async (scenario: Record<string, unknown>) => {
const session = createTestBinarySession(() => {
throw new Error("Process capture must not launch a binary provider");
});
const server = createServer({ kind: "session", session });
const client = new Client({ name: "filesystem-coverage", version: "1" });
onTestFinished(async () => {
await client.close();
await server.close();
await session.close();
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
const result = await client.callTool({
name: "capture_process_scenario",
arguments: scenario,
});
await client.ping();
return result;
};
const captureViaMcp = async (scenario: Record<string, unknown>) => {
const result = await callCaptureViaMcp(scenario);
expect(result.isError, JSON.stringify(result)).not.toBe(true);
return parseEvidence(result.structuredContent);
};
it
.skipIf(
CAPTURE_SKIP_REASON ||
process.platform === "win32" ||
process.getuid?.() === 0,
)
.for(["cli", "mcp"] as const)(
"preserves selected-root permission denial and earlier snapshot facts through %s",
{ timeout: 20_000 },
async (adapter, context) => {
const root = await createTestTempDirectory("rea-fs-root-denial-adapter-");
const denied = join(root, "denied");
const selected = join(denied, "unobserved");
const healthy = join(root, "healthy.txt");
const launched = join(root, "launched.txt");
const bytes = "retained before denied root";
await mkdir(denied);
await writeFile(healthy, bytes);
await chmod(denied, 0);
try {
const lookup = await lstat(selected).catch((cause: unknown) => cause);
if (
lookup instanceof Error &&
"code" in lookup &&
lookup.code === "ENOENT"
) {
context.skip("The host does not enforce directory search denial.");
return;
}
if (!(lookup instanceof Error) || !("code" in lookup))
throw new Error("Expected a native selected-root lookup failure");
expect(["EACCES", "EPERM"]).toContain(lookup.code);
const scenario = {
executable: process.execPath,
arguments: [
"-e",
"require('node:fs').writeFileSync('launched.txt','ran')",
],
working_directory: root,
filesystem_observation_paths: [healthy, selected],
};
let output;
if (adapter === "cli") {
const failure = await captureViaCli(scenario).catch(
(cause: unknown) => cause,
);
if (
!(failure instanceof Error) ||
!("stdout" in failure) ||
typeof failure.stdout !== "string"
)
throw new Error("Expected the CLI to refuse capture before launch");
output = analysisCliErrorEnvelopeSchema.parse(
JSON.parse(failure.stdout),
);
} else {
const response = await callCaptureViaMcp(scenario);
output = parseMcpToolError(response).error;
}
expect(output).toMatchObject({
code: "access_denied",
category: "unavailable",
details: {
operation: "capture_process_scenario",
path: selected,
system_code: lookup.code,
boundary: "filesystem-read",
partial_observation: {
observations: {
target_pid: { state: "unavailable" },
filesystem_snapshots: {
before: {
state: "available",
value: {
files: [
expect.objectContaining({
path: "root_0:.",
sha256: createHash("sha256")
.update(bytes)
.digest("hex"),
}),
],
completeRoots: ["root_0"],
coverage: {
enumeration_truncated: true,
enumeration_omissions: [
{
path: "root_1:.",
reason: "entry_unavailable",
system_code: lookup.code,
},
],
},
},
},
},
},
},
},
});
expect(output).toMatchObject({
remediation: { action: expect.stringContaining("read access") },
});
await expect(lstat(launched)).rejects.toMatchObject({ code: "ENOENT" });
await chmod(denied, 0o700);
const evidence = await (
adapter === "cli" ? captureViaCli : captureViaMcp
)(scenario);
expect(
parseProcessCapture(evidence.normalized_result).exit,
).toMatchObject({
code: 0,
reason: "exited",
});
expect(await readFile(launched, "utf8")).toBe("ran");
} finally {
await chmod(denied, 0o700);
}
},
);
it("documents a finalization scenario and exit record that match the contract", async () => {
const document = await readFile("docs/process-capture.md", "utf8");
const blocks = [
...(document.split("### Finalization interval")[1] ?? "").matchAll(
/```json\n([\s\S]*?)\n```/gu,
),
].map((match) => match[1]);
const [scenario, exit] = blocks;
if (scenario === undefined || exit === undefined)
throw new Error("Missing finalization examples");
expect(
parseProcessScenario(JSON.parse(scenario)).finalization_ms,
"the documented scenario is accepted and carries the interval",
).toBe(1_500);
expect(
JSON.parse(exit),
"the documented exit record is the canonical finalized example",
).toEqual(FINALIZED_PROCESS_CAPTURE_EXAMPLE.exit);
});
itWithCaptureCapability.each(["cli", "mcp"] as const)(
"hashes the documented complete report despite omitted terminal output through %s",
async (adapter) => {
const document = await readFile("docs/process-capture.md", "utf8");
const example = document
.split("## Hash a report independently of terminal output")[1]
?.match(/```json\n([\s\S]*?)\n```/u)?.[1];
if (example === undefined)
throw new Error("Missing report capture example");
const scenario = parseProcessScenario(JSON.parse(example));
const root = await createTestTempDirectory("rea-full-report-adapter-");
const evidence = await (adapter === "cli" ? captureViaCli : captureViaMcp)({
...scenario,
executable: process.execPath,
working_directory: root,
filesystem_observation_paths: [join(root, "reports")],
});
const capture = parseProcessCapture(evidence.normalized_result);
const bytes = await readFile(join(root, "reports", "final.json"));
expect(JSON.parse(bytes.toString())).toEqual({
status: "complete",
data: "X".repeat(131072),
});
expect(bytes.length).toBeGreaterThan(scenario.limits.output_bytes);
expect(capture.files_after).toContainEqual(
expect.objectContaining({
path: "root_0:final.json",
size: bytes.length,
sha256: createHash("sha256").update(bytes).digest("hex"),
}),
);
expect(capture.truncation_details.filesystem_after).toMatchObject({
enumeration_truncated: false,
hash_budget_bytes: scenario.limits.file_bytes,
hashed_bytes: bytes.length,
hash_omissions: [],
});
expect(capture.truncation_details.raw_terminal.retained_bytes).toBeLessThan(
capture.truncation_details.raw_terminal.observed_bytes,
);
},
20_000,
);
itWithCaptureCapability.each(["cli", "mcp"] as const)(
"accounts for exact cumulative hash budgets independently at each %s checkpoint",
async (adapter) => {
const runCapture = adapter === "cli" ? captureViaCli : captureViaMcp;
for (const shortfall of [0, 1]) {
const root = await createTestTempDirectory("rea-report-cumulative-");
const periodic = Buffer.from('{"status":"running"}');
const final = Buffer.from('{"status":"complete"}');
await writeFile(join(root, "periodic.json"), periodic);
const budget = periodic.length + final.length - shortfall;
const evidence = await runCapture({
executable: process.execPath,
arguments: [
"-e",
"require('node:fs').writeFileSync('final.json','{\"status\":\"complete\"}')",
],
working_directory: root,
filesystem_observation_paths: [root],
limits: { output_bytes: 1, file_bytes: budget },
});
const capture = parseProcessCapture(evidence.normalized_result);
expect(capture.truncation_details.filesystem_before).toMatchObject({
hashed_bytes: periodic.length,
hash_omissions: [],
});
expect(capture.files_after).toContainEqual(
expect.objectContaining({
path: "root_0:final.json",
sha256: createHash("sha256").update(final).digest("hex"),
}),
);
expect(capture.truncation_details.filesystem_after).toMatchObject({
enumeration_truncated: false,
hashed_bytes: final.length + (shortfall === 0 ? periodic.length : 0),
hash_omissions:
shortfall === 0
? []
: [
{
path: "root_0:periodic.json",
reason: "file_bytes_budget",
remaining_budget_bytes: periodic.length - 1,
},
],
});
expect(
capture.files_after.find(({ path }) => path === "root_0:periodic.json")
?.sha256,
).toBe(
shortfall === 0
? createHash("sha256").update(periodic).digest("hex")
: null,
);
}
},
20_000,
);
itWithCaptureCapability.each(["cli", "mcp"] as const)(
"reports unknown absence instead of deletion in truncated %s capture Evidence",
async (adapter) => {
const root = await createTestTempDirectory("rea-fs-coverage-adapter-");
await writeFile(join(root, "z.txt"), "unchanged");
const scenario = {
executable: process.execPath,
// Delete the known file and overflow the remaining directory capacity.
// Incomplete enumeration still cannot prove its absence, regardless of
// which new entry the filesystem returns first.
arguments: [
"-e",
"const fs=require('node:fs');fs.unlinkSync('z.txt');fs.writeFileSync('a.txt','added');fs.writeFileSync('b.txt','added');",
],
working_directory: root,
filesystem_observation_paths: [root],
limits: { files: 2 },
};
const evidence = await (adapter === "cli" ? captureViaCli : captureViaMcp)(
scenario,
);
const capture = parseProcessCapture(evidence.normalized_result);
expect(capture.truncated).toBe(true);
await expect(readFile(join(root, "z.txt"), "utf8")).rejects.toMatchObject({
code: "ENOENT",
});
expect(
capture.filesystem_effects.find(({ path }) => path === "root_0:z.txt")
?.status,
).toBe("unknown");
expect(
capture.filesystem_checkpoints
.find(({ name }) => name === "after_settlement")
?.effects.find(({ path }) => path === "root_0:z.txt")?.status,
).toBe("unknown");
},
20_000,
);
itWithCaptureCapability.each(["cli", "mcp"] as const)(
"reports unknown content after a same-size rewrite without digests through %s",
async (adapter) => {
const root = await createTestTempDirectory("rea-fs-content-adapter-");
const file = join(root, "changed.txt");
await writeFile(file, "before");
const scenario = {
executable: process.execPath,
arguments: [
"-e",
"require('node:fs').writeFileSync('changed.txt','after!')",
],
working_directory: root,
filesystem_observation_paths: [root],
limits: { file_bytes: 1 },
};
const evidence = await (adapter === "cli" ? captureViaCli : captureViaMcp)(
scenario,
);
const capture = parseProcessCapture(evidence.normalized_result);
expect(await readFile(file, "utf8")).toBe("after!");
const effect = capture.filesystem_effects.find(
({ path }) => path === "root_0:changed.txt",
);
expect(effect).toMatchObject({
status: "unknown",
reason: expect.any(String),
before: { size: 6, sha256: null },
after: { size: 6, sha256: null },
});
if (effect?.status !== "unknown")
throw new Error("Expected unknown file contents");
expect(
capture.filesystem_checkpoints.find(
({ name }) => name === "after_settlement",
)?.effects,
).toContainEqual(effect);
expect(capture.residual_unknowns).toContainEqual({
scope: "filesystem",
reason: effect.reason,
});
expect(capture.limitations).toContain(effect.reason);
expect(
capture.truncation_details.filesystem_after.enumeration_truncated,
).toBe(false);
expect(compareProcessCaptures(capture, capture).filesystem).toBe("unknown");
},
20_000,
);
it.skipIf(CAPTURE_SKIP_REASON || process.platform === "win32")(
"keeps unfollowed descendant absence unknown through MCP Evidence and comparison",
async () => {
const root = await createTestTempDirectory("rea-fs-symlink-adapter-");
const target = await createTestTempDirectory(
"rea-fs-symlink-adapter-target-",
);
await mkdir(join(root, "subtree"));
await writeFile(join(root, "subtree", "z.txt"), "unchanged");
await writeFile(join(target, "z.txt"), "unchanged");
await writeFile(join(root, "removed.txt"), "removed");
const evidence = await captureViaMcp({
executable: process.execPath,
arguments: [
"-e",
"const fs=require('node:fs');fs.rmSync('subtree',{recursive:true});fs.symlinkSync(process.argv[1],'subtree');fs.unlinkSync('removed.txt');",
target,
],
working_directory: root,
filesystem_observation_paths: [root],
});
const capture = parseProcessCapture(evidence.normalized_result);
expect(await readFile(join(root, "subtree", "z.txt"), "utf8")).toBe(
"unchanged",
);
expect(
capture.filesystem_effects.find(
({ path }) => path === "root_0:subtree/z.txt",
)?.status,
).toBe("unknown");
expect(
capture.filesystem_effects.find(
({ path }) => path === "root_0:removed.txt",
)?.status,
).toBe("deleted");
expect(capture.residual_unknowns).toContainEqual({
scope: "filesystem",
reason: expect.any(String),
});
expect(compareProcessCaptures(capture, capture).filesystem).toBe("unknown");
},
20_000,
);
it
.skipIf(CAPTURE_SKIP_REASON || process.platform === "win32")
.each(["cli", "mcp"] as const)(
"retains stable root and nested symlink uncertainty with known sibling effects through %s",
async (adapter) => {
const root = await createTestTempDirectory("rea-fs-stable-link-adapter-");
const reports = join(root, "real", "reports");
await mkdir(reports, { recursive: true });
await symlink("real/reports", join(root, "link"));
await writeFile(join(root, "removed.txt"), "known sibling");
const evidence = await (adapter === "cli" ? captureViaCli : captureViaMcp)({
executable: process.execPath,
arguments: [
"-e",
"const fs=require('node:fs');fs.writeFileSync('link/final.txt','done');fs.unlinkSync('removed.txt');",
],
working_directory: root,
filesystem_observation_paths: [join(root, "link"), reports, root],
});
const capture = parseProcessCapture(evidence.normalized_result);
expect(capture.exit).toMatchObject({ code: 0, reason: "exited" });
expect(await readFile(join(reports, "final.txt"), "utf8")).toBe("done");
for (const path of ["root_0:.", "root_2:link"])
expect(capture.filesystem_effects).toContainEqual(
expect.objectContaining({
path,
status: "unchanged",
after: expect.objectContaining({
type: "symlink",
symlink_target: "real/reports",
}),
}),
);
expect(capture.filesystem_effects).toContainEqual(
expect.objectContaining({
path: "root_2:removed.txt",
status: "deleted",
}),
);
expect(capture.filesystem_effects).toContainEqual(
expect.objectContaining({
path: "root_1:final.txt",
status: "created",
after: expect.objectContaining({
sha256: createHash("sha256").update("done").digest("hex"),
}),
}),
);
expect(
capture.files_after.some(({ path }) => path === "root_0:final.txt"),
).toBe(false);
for (const phase of ["filesystem_before", "filesystem_after"] as const)
expect(capture.truncation_details[phase]).toMatchObject({
enumeration_truncated: true,
enumeration_reasons: ["symlink_not_followed"],
enumeration_omissions: ["root_0:.", "root_2:link"].map((path) => ({
path,
reason: "symlink_not_followed",
system_code: null,
})),
});
expect(capture.residual_unknowns).toContainEqual({
scope: "filesystem",
reason: expect.stringContaining("symlink_not_followed"),
});
expect(compareProcessCaptures(capture, capture).filesystem).toBe("unknown");
},
20_000,
);
itWithCaptureCapability.each(["cli", "mcp"] as const)(
"observes absence when selected roots lose their directory ancestor through %s",
async (adapter) => {
const runCapture = adapter === "cli" ? captureViaCli : captureViaMcp;
// A non-directory ancestor proves that no entry exists below it, for an
// initially absent root and through a symlink to a regular file alike.
for (const replacement of [
"fs.writeFileSync('state','regular file');",
"fs.writeFileSync('target','regular file');fs.symlinkSync('target','state');",
]) {
const root = await createTestTempDirectory("rea-fs-obstructed-root-");
await mkdir(join(root, "state"));
const evidence = await runCapture({
executable: process.execPath,
arguments: [
"-e",
`const fs=require('node:fs');fs.renameSync('state','saved-state');${replacement}`,
],
working_directory: root,
filesystem_observation_paths: [
join(root, "state", "reports"),
join(root, "state"),
],
});
const capture = parseProcessCapture(evidence.normalized_result);
expect(capture.exit).toMatchObject({ code: 0, reason: "exited" });
expect(capture.filesystem_effects).toEqual([
expect.objectContaining({
path: "root_1:.",
status: "modified",
before: expect.objectContaining({ type: "directory" }),
}),
]);
const symlink = replacement.includes("symlinkSync");
expect(capture.truncation_details.filesystem_after).toMatchObject({
enumeration_truncated: symlink,
enumeration_omissions: symlink
? [
{
path: "root_1:.",
reason: "symlink_not_followed",
system_code: null,
},
]
: [],
});
}
},
20_000,
);
it
.skipIf(
CAPTURE_SKIP_REASON ||
process.platform === "win32" ||
process.getuid?.() === 0,
)
.each(["cli", "mcp"] as const)(
"keeps roots behind a newly unsearchable ancestor unobserved through %s",
async (adapter) => {
const root = await createTestTempDirectory("rea-fs-denied-root-");
await mkdir(join(root, "state"));
await writeFile(join(root, "state", "present.json"), "payload");
await symlink("state", join(root, "alias"));
try {
const evidence = await (
adapter === "cli" ? captureViaCli : captureViaMcp
)({
executable: process.execPath,
arguments: ["-e", "require('node:fs').chmodSync('state',0)"],
working_directory: root,
filesystem_observation_paths: [
join(root, "state", "present.json"),
join(root, "state", "absent.json"),
join(root, "alias", "present.json"),
],
});
const capture = parseProcessCapture(evidence.normalized_result);
expect(capture.exit).toMatchObject({ code: 0, reason: "exited" });
// A denied lookup is not absence: the observed file's fate stays
// unknown, and each affected root records why it is incomplete.
expect(capture.filesystem_effects).toEqual(
["root_0:.", "root_2:."].map((path) =>
expect.objectContaining({
path,
status: "unknown",
before: expect.objectContaining({ type: "file" }),
after: null,
}),
),
);
expect(capture.truncation_details.filesystem_after).toMatchObject({
enumeration_truncated: true,
enumeration_reasons: ["entry_unavailable"],
enumeration_omissions: ["root_0:.", "root_1:.", "root_2:."].map(
(path) => ({
path,
reason: "entry_unavailable",
system_code: "EACCES",
}),
),
});
} finally {
await chmod(join(root, "state"), 0o700);
}
},
20_000,
);