From f91f2798b4dc6adb61b3ffe28a24a5d55adbd6ff Mon Sep 17 00:00:00 2001 From: You Wish <29498139+Y0UW1SH@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:36:35 -0400 Subject: [PATCH] fix(process): keep unobserved file contents unknown (#1358) --- .../capture/FilesystemCoverage.test.ts | 164 +++++++++++++++++- .../capture/ProcessCaptureLifecycle.ts | 26 ++- .../capture/ProcessFilesystemEffects.ts | 27 ++- .../processFilesystemCoverageAdapters.test.ts | 85 +++++++-- 4 files changed, 267 insertions(+), 35 deletions(-) diff --git a/src/process/capture/FilesystemCoverage.test.ts b/src/process/capture/FilesystemCoverage.test.ts index 36ebfc6de..2eec44bd5 100644 --- a/src/process/capture/FilesystemCoverage.test.ts +++ b/src/process/capture/FilesystemCoverage.test.ts @@ -1,4 +1,11 @@ -import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { + chmod, + mkdir, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; import { join } from "node:path"; import { expect, it } from "vitest"; import { parseProcessScenario } from "../../domain/process/processScenario.js"; @@ -126,6 +133,161 @@ it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( }, ); +it.each([ + { name: "both", beforeOmitted: true, afterOmitted: true, contents: "after!" }, + { + name: "before", + beforeOmitted: true, + afterOmitted: false, + contents: "after!", + }, + { + name: "after", + beforeOmitted: false, + afterOmitted: true, + contents: "after!", + }, + { + name: "both for an untouched file", + beforeOmitted: true, + afterOmitted: true, + contents: "before", + }, +])( + "keeps content equality unknown when $name snapshots omit its digest", + async ({ beforeOmitted, afterOmitted, contents }) => { + const root = await createTestTempDirectory("rea-fs-content-unknown-"); + const budgetFile = join(root, "a.txt"); + const file = join(root, "b.txt"); + await writeFile(file, "before"); + if (beforeOmitted) await writeFile(budgetFile, "budget"); + const scenario = parseProcessScenario({ + executable: process.execPath, + working_directory: root, + filesystem_observation_paths: [root], + limits: { file_bytes: 6 }, + }); + const before = await snapshotRoots(scenario); + if (beforeOmitted && !afterOmitted) await rm(budgetFile); + if (!beforeOmitted && afterOmitted) await writeFile(budgetFile, "budget"); + if (contents !== "before") await writeFile(file, contents); + const after = await snapshotRoots(scenario); + expect(await readFile(file, "utf8")).toBe(contents); + const effect = classifyFilesystemEffects(before, after).find( + ({ path }) => path === "root_0:b.txt", + ); + expect(effect).toMatchObject({ + status: "unknown", + before: { + type: "file", + size: 6, + sha256: beforeOmitted ? null : expect.any(String), + }, + after: { + type: "file", + size: 6, + sha256: afterOmitted ? null : expect.any(String), + }, + reason: expect.stringMatching(/content.*hash|hash.*content/iu), + }); + }, +); + +it("preserves observed content and metadata changes without treating missing hashes as equality", async () => { + const root = await createTestTempDirectory("rea-fs-observed-changes-"); + for (const name of ["changed", "stable", "unhashed", "retyped"]) + await writeFile(join(root, name), "before"); + const scenario = parseProcessScenario({ + executable: process.execPath, + working_directory: root, + filesystem_observation_paths: [root], + }); + const before = await snapshotRoots(scenario); + await writeFile(join(root, "changed"), "after!"); + await writeFile(join(root, "unhashed"), "different size"); + await rm(join(root, "retyped")); + await mkdir(join(root, "retyped")); + const after = await snapshotRoots(scenario); + const effects = classifyFilesystemEffects(before, after); + expect(effects.find(({ path }) => path === "root_0:changed")?.status).toBe( + "modified", + ); + expect(effects.find(({ path }) => path === "root_0:stable")?.status).toBe( + "unchanged", + ); + expect(effects.find(({ path }) => path === "root_0:retyped")?.status).toBe( + "modified", + ); + const noHashes = parseProcessScenario({ + ...scenario, + limits: { file_bytes: 1 }, + }); + const unhashedBefore = await snapshotRoots(noHashes); + await writeFile(join(root, "unhashed"), "size changed again"); + const unhashedAfter = await snapshotRoots(noHashes); + expect( + classifyFilesystemEffects(unhashedBefore, unhashedAfter).find( + ({ path }) => path === "root_0:unhashed", + ), + ).toMatchObject({ + status: "modified", + before: { sha256: null }, + after: { sha256: null }, + }); +}); + +it.skipIf(process.platform === "win32")( + "preserves an observed permission change when file digests are omitted", + async () => { + const root = await createTestTempDirectory("rea-fs-mode-change-"); + const file = join(root, "file"); + await writeFile(file, "before", { mode: 0o600 }); + const scenario = parseProcessScenario({ + executable: process.execPath, + working_directory: root, + filesystem_observation_paths: [file], + limits: { file_bytes: 1 }, + }); + const before = await snapshotRoots(scenario); + await chmod(file, 0o644); + const after = await snapshotRoots(scenario); + expect(classifyFilesystemEffects(before, after)).toMatchObject([ + { status: "modified", before: { sha256: null }, after: { sha256: null } }, + ]); + }, +); + +it.skipIf(process.platform === "win32")( + "compares directories and symlink targets without requiring file content hashes", + async () => { + const root = await createTestTempDirectory("rea-fs-nonfile-effects-"); + await mkdir(join(root, "empty")); + await writeFile(join(root, "a.txt"), "before"); + await writeFile(join(root, "b.txt"), "unused"); + await symlink("a.txt", join(root, "stable-link")); + await symlink("a.txt", join(root, "moved-link")); + const scenario = parseProcessScenario({ + executable: process.execPath, + working_directory: root, + filesystem_observation_paths: [root], + limits: { file_bytes: 1 }, + }); + const before = await snapshotRoots(scenario); + await writeFile(join(root, "a.txt"), "after!"); + await rm(join(root, "moved-link")); + await symlink("b.txt", join(root, "moved-link")); + const after = await snapshotRoots(scenario); + const effects = classifyFilesystemEffects(before, after); + for (const name of ["empty", "stable-link"]) + expect( + effects.find(({ path }) => path === `root_0:${name}`)?.status, + ).toBe("unchanged"); + expect( + effects.find(({ path }) => path === "root_0:moved-link")?.status, + ).toBe("modified"); + }, +); + it("preserves creation when the selected root was observed missing before capture", async () => { const root = await createTestTempDirectory("rea-fs-missing-root-"); const file = join(root, "created.txt"); diff --git a/src/process/capture/ProcessCaptureLifecycle.ts b/src/process/capture/ProcessCaptureLifecycle.ts index af439e9dd..17d262fa7 100644 --- a/src/process/capture/ProcessCaptureLifecycle.ts +++ b/src/process/capture/ProcessCaptureLifecycle.ts @@ -250,11 +250,13 @@ export const buildCaptureResult = ( options.before, options.after, ); - const hasUnknownFilesystemEffects = filesystemEffects.some( - ({ status }) => status === "unknown", - ); - const incompleteFilesystemUnknown = - "Path absence in incomplete enumeration or below an unfollowed symbolic link remains unknown."; + const filesystemUnknowns = [ + ...new Set( + filesystemEffects.flatMap((effect) => + effect.status === "unknown" ? [effect.reason] : [], + ), + ), + ]; return { manifest: options.manifest, normalization: options.scenario.normalization, @@ -291,7 +293,7 @@ export const buildCaptureResult = ( "Filesystem observations are before/after snapshots, not syscall traces.", "Inherited host environment variables are not recorded and may affect results.", ...(!hasFilesystemObservations ? [filesystemObservationUnknown] : []), - ...(hasUnknownFilesystemEffects ? [incompleteFilesystemUnknown] : []), + ...filesystemUnknowns, ...captureCoverageUnknowns(options.truncationDetails).map( ({ reason }) => reason, ), @@ -324,14 +326,10 @@ export const buildCaptureResult = ( }, ] : []), - ...(hasUnknownFilesystemEffects - ? [ - { - scope: "filesystem" as const, - reason: incompleteFilesystemUnknown, - }, - ] - : []), + ...filesystemUnknowns.map((reason) => ({ + scope: "filesystem" as const, + reason, + })), ...(hasSensitiveScriptedInput ? [{ scope: "interaction" as const, reason: sensitiveInputUnknown }] : []), diff --git a/src/process/capture/ProcessFilesystemEffects.ts b/src/process/capture/ProcessFilesystemEffects.ts index 97ae578c4..af67d4cee 100644 --- a/src/process/capture/ProcessFilesystemEffects.ts +++ b/src/process/capture/ProcessFilesystemEffects.ts @@ -29,7 +29,7 @@ const unobservedAbsenceReason = ( return undefined; }; -/** Classify effects only when a snapshot establishes the missing path's absence. */ +/** Classify effects only from observed path absence, metadata, and content hashes. */ export const classifyFilesystemEffects = ( before: ProcessFilesystemSnapshot, after: ProcessFilesystemSnapshot, @@ -84,12 +84,31 @@ export const classifyFilesystemEffects = ( before: beforeFile, after: null, } as const; + const metadataChanged = + beforeFile.type !== afterFile.type || + beforeFile.mode !== afterFile.mode || + beforeFile.size !== afterFile.size || + beforeFile.symlink_target !== afterFile.symlink_target; + if ( + !metadataChanged && + beforeFile.type === "file" && + afterFile.type === "file" && + (beforeFile.sha256 === null || afterFile.sha256 === null) + ) + return { + path, + status: "unknown", + before: beforeFile, + after: afterFile, + reason: + "A file content hash is unavailable in at least one snapshot; matching metadata does not establish unchanged contents.", + } as const; return { path, status: - JSON.stringify(beforeFile) === JSON.stringify(afterFile) - ? "unchanged" - : "modified", + metadataChanged || beforeFile.sha256 !== afterFile.sha256 + ? "modified" + : "unchanged", before: beforeFile, after: afterFile, } as const; diff --git a/tests/boundary/process/processFilesystemCoverageAdapters.test.ts b/tests/boundary/process/processFilesystemCoverageAdapters.test.ts index aa537514f..ec7ce5b4c 100644 --- a/tests/boundary/process/processFilesystemCoverageAdapters.test.ts +++ b/tests/boundary/process/processFilesystemCoverageAdapters.test.ts @@ -17,6 +17,22 @@ import { const exec = promisify(execFile); +const captureViaCli = async (scenario: Record) => { + 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 captureViaMcp = async (scenario: Record) => { const session = createTestBinarySession(() => { throw new Error("Process capture must not launch a binary provider"); @@ -54,22 +70,9 @@ itWithCaptureCapability.each(["cli", "mcp"] as const)( filesystem_observation_paths: [root], limits: { files: 2 }, }; - let evidence; - if (adapter === "cli") { - 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", - ]); - evidence = parseEvidence(JSON.parse(stdout)); - } else evidence = await captureViaMcp(scenario); + const evidence = await (adapter === "cli" ? captureViaCli : captureViaMcp)( + scenario, + ); const capture = parseProcessCapture(evidence.normalized_result); expect(capture.truncated).toBe(true); expect(await readFile(join(root, "z.txt"), "utf8")).toBe("unchanged"); @@ -86,6 +89,56 @@ itWithCaptureCapability.each(["cli", "mcp"] as const)( 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 () => {