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, +);