From b6007e0a58f5acab573eb9f2adcdbe24d7eee6af Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Thu, 22 Oct 2026 00:28:26 -0700 Subject: [PATCH] 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 --- docs/process-capture.md | 9 +- .../capture/FilesystemCoverage.test.ts | 113 ++++++++++++++++++ src/process/capture/FilesystemSnapshot.ts | 92 +++++++++----- .../processFilesystemCoverageAdapters.test.ts | 102 +++++++++++++++- 4 files changed, 285 insertions(+), 31 deletions(-) diff --git a/docs/process-capture.md b/docs/process-capture.md index 19047b96c9..7e7fd48ca5 100644 --- a/docs/process-capture.md +++ b/docs/process-capture.md @@ -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; diff --git a/src/process/capture/FilesystemCoverage.test.ts b/src/process/capture/FilesystemCoverage.test.ts index a2ea110798..4113e66203 100644 --- a/src/process/capture/FilesystemCoverage.test.ts +++ b/src/process/capture/FilesystemCoverage.test.ts @@ -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" }, + ]); + }, +); diff --git a/src/process/capture/FilesystemSnapshot.ts b/src/process/capture/FilesystemSnapshot.ts index 5c0362d36f..abb252fc8a 100644 --- a/src/process/capture/FilesystemSnapshot.ts +++ b/src/process/capture/FilesystemSnapshot.ts @@ -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 => { +): Promise => { 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 => { - // 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(); diff --git a/tests/boundary/process/processFilesystemCoverageAdapters.test.ts b/tests/boundary/process/processFilesystemCoverageAdapters.test.ts index 1513d64593..20dd94f98e 100644 --- a/tests/boundary/process/processFilesystemCoverageAdapters.test.ts +++ b/tests/boundary/process/processFilesystemCoverageAdapters.test.ts @@ -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, +);