mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(process): keep capture evidence when an admitted root's ancestor changes (#6264)
Final filesystem observation reconstructed root admission from the initial snapshot's root entries, so an initially absent root (no entry) whose ancestor became a regular file failed capture with ENOTDIR after the child completed. A returned initial snapshot now admits every selected root for the run. One tagged path lookup classifies ENOENT and ENOTDIR as observed absence and other unavailable lookups (EACCES, ELOOP, ...) as entry_unavailable omissions that keep the root incomplete. Selected roots that are obstructed or denied before launch still fail capture. Fixes #6213 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -252,7 +252,14 @@ Successfully observed identity replacements use `identity_changed` with a null
|
||||
system code. Unstable symlink targets are omitted; already observed directory
|
||||
metadata remains available. The affected root stays incomplete, so omitted
|
||||
entries do not prove deletion. A path missing at its initial lookup remains an
|
||||
observed absence. Cancellation and unexpected I/O failures still stop capture.
|
||||
observed absence.
|
||||
|
||||
Before launch, a selected root below a non-directory, or one whose lookup is
|
||||
denied or otherwise unavailable, fails capture. After admission, a root or descendant lookup that
|
||||
reports a missing path or a non-directory ancestor is an observed absence. A
|
||||
denied or otherwise unavailable lookup records an `entry_unavailable` omission
|
||||
for that path instead, and its root stays incomplete. Cancellation and
|
||||
unexpected I/O failures still stop capture.
|
||||
|
||||
Terminal frame `data` is the comparison text after the selected normalization.
|
||||
New captures also preserve `raw_data` when normalization changes a PTY chunk;
|
||||
|
||||
@@ -393,3 +393,116 @@ it("preserves creation when the selected root was observed missing before captur
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
|
||||
"rejects only selected roots that cannot be admitted before launch",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-fs-root-admission-");
|
||||
await writeFile(join(root, "file"), "regular file");
|
||||
await mkdir(join(root, "denied"));
|
||||
await chmod(join(root, "denied"), 0);
|
||||
const scenarioFor = (path: string) =>
|
||||
parseProcessScenario({
|
||||
executable: process.execPath,
|
||||
working_directory: root,
|
||||
filesystem_observation_paths: [path],
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
snapshotRoots(scenarioFor(join(root, "file", "child"))),
|
||||
).rejects.toMatchObject({ message: expect.stringContaining("ENOTDIR") });
|
||||
await expect(
|
||||
snapshotRoots(scenarioFor(join(root, "denied", "child"))),
|
||||
).rejects.toMatchObject({ message: expect.stringContaining("EACCES") });
|
||||
// The same lookups after admission are absence and an omission.
|
||||
const admission = await snapshotRoots(scenarioFor(join(root, "missing")));
|
||||
expect(admission.completeRoots).toEqual(["root_0"]);
|
||||
const obstructed = await snapshotRoots(
|
||||
scenarioFor(join(root, "file", "child")),
|
||||
undefined,
|
||||
admission,
|
||||
);
|
||||
expect(obstructed).toMatchObject({
|
||||
files: [],
|
||||
completeRoots: ["root_0"],
|
||||
truncated: false,
|
||||
});
|
||||
const denied = await snapshotRoots(
|
||||
scenarioFor(join(root, "denied", "child")),
|
||||
undefined,
|
||||
admission,
|
||||
);
|
||||
expect(denied).toMatchObject({
|
||||
files: [],
|
||||
completeRoots: [],
|
||||
truncated: true,
|
||||
coverage: {
|
||||
enumeration_truncated: true,
|
||||
enumeration_reasons: ["entry_unavailable"],
|
||||
enumeration_omissions: [
|
||||
{
|
||||
path: "root_0:.",
|
||||
reason: "entry_unavailable",
|
||||
system_code: "EACCES",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await chmod(join(root, "denied"), 0o700);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
|
||||
"records an unsearchable directory's children as omitted rather than failing",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-fs-unsearchable-");
|
||||
const listed = join(root, "listed");
|
||||
await mkdir(listed);
|
||||
await writeFile(join(listed, "child.txt"), "child");
|
||||
// Read without search permission: names enumerate, lookups are denied.
|
||||
await chmod(listed, 0o444);
|
||||
const scenario = parseProcessScenario({
|
||||
executable: process.execPath,
|
||||
working_directory: root,
|
||||
filesystem_observation_paths: [root],
|
||||
});
|
||||
try {
|
||||
const snapshot = await snapshotRoots(scenario);
|
||||
expect(snapshot.files.map(({ path }) => path)).toEqual([
|
||||
"root_0:.",
|
||||
"root_0:listed",
|
||||
]);
|
||||
expect(snapshot.completeRoots).toEqual([]);
|
||||
expect(snapshot.coverage.enumeration_omissions).toEqual([
|
||||
{
|
||||
path: "root_0:listed/child.txt",
|
||||
reason: "entry_unavailable",
|
||||
system_code: "EACCES",
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await chmod(listed, 0o700);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"keeps an admitted root behind a symlink loop unobserved",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-fs-root-loop-");
|
||||
const scenario = parseProcessScenario({
|
||||
executable: process.execPath,
|
||||
working_directory: root,
|
||||
filesystem_observation_paths: [join(root, "loop", "child")],
|
||||
});
|
||||
const before = await snapshotRoots(scenario);
|
||||
await symlink("loop", join(root, "loop"));
|
||||
const after = await snapshotRoots(scenario, undefined, before);
|
||||
expect(after.completeRoots).toEqual([]);
|
||||
expect(after.coverage.enumeration_omissions).toEqual([
|
||||
{ path: "root_0:.", reason: "entry_unavailable", system_code: "ELOOP" },
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -110,24 +110,42 @@ const observeSymlink = async (
|
||||
: err({ reason: "identity_changed", system_code: null });
|
||||
};
|
||||
|
||||
const lstatIfPresent = async (
|
||||
type PathLookup =
|
||||
| { readonly state: "present"; readonly stats: Stats }
|
||||
| {
|
||||
readonly state: "absent" | "unavailable";
|
||||
readonly system_code: string;
|
||||
readonly cause: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Look up one path without following its final component. ENOENT and ENOTDIR
|
||||
* both prove that no entry exists at the path; other unavailable lookups leave
|
||||
* it unobserved rather than absent.
|
||||
*/
|
||||
const lookupPath = async (
|
||||
path: string,
|
||||
previouslyObservedRoot = false,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Stats | undefined> => {
|
||||
): Promise<PathLookup> => {
|
||||
signal?.throwIfAborted();
|
||||
let stats: Stats;
|
||||
try {
|
||||
const stats = await lstat(path);
|
||||
signal?.throwIfAborted();
|
||||
return stats;
|
||||
stats = await lstat(path);
|
||||
} catch (cause: unknown) {
|
||||
const code = filesystemSystemCode(cause);
|
||||
if (code === "ENOENT" || (previouslyObservedRoot && code === "ENOTDIR")) {
|
||||
signal?.throwIfAborted();
|
||||
return undefined;
|
||||
}
|
||||
throw cause;
|
||||
const systemCode = fileUnavailableSystemCode(cause);
|
||||
if (systemCode === undefined) throw cause;
|
||||
signal?.throwIfAborted();
|
||||
return {
|
||||
state:
|
||||
systemCode === "ENOENT" || systemCode === "ENOTDIR"
|
||||
? "absent"
|
||||
: "unavailable",
|
||||
system_code: systemCode,
|
||||
cause,
|
||||
};
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
return { state: "present", stats };
|
||||
};
|
||||
|
||||
/** Reports a complete digest or an explicit per-file reason hashing was omitted. */
|
||||
@@ -323,16 +341,20 @@ export const hashFile = async (
|
||||
);
|
||||
};
|
||||
|
||||
/** Capture bounded, root-aliased filesystem state without following symlinks. */
|
||||
/**
|
||||
* Capture bounded, root-aliased filesystem state without following symlinks.
|
||||
*
|
||||
* Without `admission`, every selected root must be missing or observable, so
|
||||
* an obstructed or denied selection fails before launch. A snapshot returned
|
||||
* from that call admitted every root for the run: later lookups record a
|
||||
* non-directory ancestor as absence and a denied lookup as an omission.
|
||||
*/
|
||||
export const snapshotRoots = async (
|
||||
scenario: ProcessScenario,
|
||||
signal?: AbortSignal,
|
||||
previous?: ProcessFilesystemSnapshot,
|
||||
admission?: ProcessFilesystemSnapshot,
|
||||
retainer?: ProcessCaptureCleanupRetainer,
|
||||
): Promise<ProcessFilesystemSnapshot> => {
|
||||
// A prior root entry establishes same-run admission. Preserve initial
|
||||
// obstruction errors; a later non-directory ancestor makes it absent.
|
||||
const previousPaths = new Set(previous?.files.map(({ path }) => path));
|
||||
const entries: FileState[] = [];
|
||||
const completeRoots: string[] = [];
|
||||
let remainingBytes = scenario.limits.file_bytes;
|
||||
@@ -408,11 +430,20 @@ export const snapshotRoots = async (
|
||||
enumerationReasons.add("depth_limit");
|
||||
return false;
|
||||
}
|
||||
const stats =
|
||||
admitted ??
|
||||
(await lstatIfPresent(path, previousPaths.has(`${rootAlias}:.`), signal));
|
||||
if (stats === undefined) return true;
|
||||
const relativePath = relative(root, path) || ".";
|
||||
const lookup: PathLookup =
|
||||
admitted === undefined
|
||||
? await lookupPath(path, signal)
|
||||
: { state: "present", stats: admitted };
|
||||
if (lookup.state !== "present")
|
||||
return (
|
||||
lookup.state === "absent" ||
|
||||
omitEntry(`${rootAlias}:${relativePath}`, {
|
||||
reason: "entry_unavailable",
|
||||
system_code: lookup.system_code,
|
||||
})
|
||||
);
|
||||
const { stats } = lookup;
|
||||
if (stats.isSymbolicLink()) {
|
||||
// Keep the target as stored: lexical resolution can merge distinct targets.
|
||||
const target = await observeSymlink(path, stats, signal);
|
||||
@@ -589,16 +620,19 @@ export const snapshotRoots = async (
|
||||
root,
|
||||
] of scenario.filesystem_observation_paths.entries()) {
|
||||
const alias = `root_${String(index)}`;
|
||||
const admitted = await lstatIfPresent(
|
||||
root,
|
||||
previousPaths.has(`${alias}:.`),
|
||||
signal,
|
||||
);
|
||||
if (admitted === undefined) {
|
||||
completeRoots.push(alias);
|
||||
const lookup = await lookupPath(root, signal);
|
||||
if (lookup.state !== "present") {
|
||||
if (admission === undefined && lookup.system_code !== "ENOENT")
|
||||
throw lookup.cause;
|
||||
if (lookup.state === "absent") completeRoots.push(alias);
|
||||
else
|
||||
omitEntry(`${alias}:.`, {
|
||||
reason: "entry_unavailable",
|
||||
system_code: lookup.system_code,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (await visit(root, alias, root, 0, admitted))
|
||||
if (await visit(root, alias, root, 0, lookup.stats))
|
||||
completeRoots.push(alias);
|
||||
}
|
||||
const snapshot = buildSnapshot();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { chmod, mkdir, readFile, symlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
@@ -316,3 +316,103 @@ it.skipIf(CAPTURE_SKIP_REASON || process.platform === "win32")(
|
||||
},
|
||||
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" }),
|
||||
}),
|
||||
]);
|
||||
expect(capture.truncation_details.filesystem_after).toMatchObject({
|
||||
enumeration_truncated: false,
|
||||
enumeration_omissions: [],
|
||||
});
|
||||
}
|
||||
},
|
||||
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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user