Files
rea/tests/boundary/process/processFilesystemCoverageAdapters.test.ts
T

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 { processScenarioSchema } from "../../../src/domain/process/processScenario.js";
import { FINALIZED_PROCESS_CAPTURE_EXAMPLE } from "../../../src/domain/process/processCapture.fixture.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(
processScenarioSchema.parse(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 = processScenarioSchema.parse(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,
);