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:
Kaoru0822
2026-10-22 00:28:26 -07:00
committed by GitHub
parent c9b4cae9dc
commit b6007e0a58
4 changed files with 285 additions and 31 deletions
+8 -1
View File
@@ -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" },
]);
},
);
+63 -29
View File
@@ -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,
);