diff --git a/docs/process-capture.md b/docs/process-capture.md index be1d755c2f..c8bc342b8f 100644 --- a/docs/process-capture.md +++ b/docs/process-capture.md @@ -241,7 +241,12 @@ A missing entry proves creation or deletion only when the corresponding observation root's path enumeration was exhausted. Otherwise its effect has `status: "unknown"`, the available before/after state, and a reason; a null state in this variant means unobserved. Paths below symlinks that REA did not -follow also remain unknown. Partial enumeration of one root does not erase +follow also remain unknown. Each observed symlink records a +`symlink_not_followed` enumeration omission with a null system code, including +a stable selected root. The link's own metadata can remain unchanged while +descendant coverage and filesystem comparison remain unknown. Physical +parent-directory enumeration still proves unrelated sibling absence. +Partial enumeration of one root does not erase known effects in another, and unavailable content hashes do not by themselves make path absence unknown. @@ -261,6 +266,12 @@ 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. +An initial selected-root permission denial returns `access_denied` with the +selected path, native `EACCES` or `EPERM`, and filesystem-read recovery. Capture +does not launch the target. Earlier snapshot facts and the denied root's +omission remain in the partial observation; unresolved read cleanup retains its +own classification until its exact resources are released. + Terminal frame `data` is the comparison text after the selected normalization. New captures also preserve `raw_data` when normalization changes a PTY chunk; otherwise `data` is already the original text. Older captures may lack the diff --git a/src/domain/process/processCapture.ts b/src/domain/process/processCapture.ts index 5005e2730f..2eb2817db3 100644 --- a/src/domain/process/processCapture.ts +++ b/src/domain/process/processCapture.ts @@ -92,7 +92,7 @@ export type FileState = FileStateIdentity & export interface ProcessFilesystemSnapshot { readonly files: readonly FileState[]; readonly truncated: boolean; - /** Root aliases whose path enumeration was exhausted, regardless of hash coverage. */ + /** Physical root enumeration exhausted; unfollowed symlink scopes remain unknown. */ readonly completeRoots: readonly string[]; readonly coverage: FilesystemCoverage; } diff --git a/src/domain/process/processCapture.validation.test.ts b/src/domain/process/processCapture.validation.test.ts index 0bf82669d7..4d5e0b9a4e 100644 --- a/src/domain/process/processCapture.validation.test.ts +++ b/src/domain/process/processCapture.validation.test.ts @@ -141,6 +141,7 @@ it.each([ { path: "root_0:.", reason: "entry_unavailable", system_code: null }, { path: "root_0:.", reason: "identity_changed", system_code: "ENOENT" }, { path: "root_0:.", reason: "directory_unavailable", system_code: "ENOENT" }, + { path: "root_0:.", reason: "symlink_not_followed", system_code: null }, ] as const)("rejects an inconsistent omission %j", (omission) => { expect(() => parseProcessCapture(captureWithEnumerationOmission(omission)), @@ -177,6 +178,69 @@ it("requires entry-unavailability details and matching aggregate reasons", () => ).toThrow("enumeration_omissions"); }); +it("binds unfollowed coverage to observed symlink metadata without inventing an OS failure", () => { + const partial = captureWithEnumerationOmission({ + path: "root_0:.", + reason: "symlink_not_followed", + system_code: null, + }); + const link = { + path: "root_0:.", + type: "symlink" as const, + mode: 0o120777, + size: 7, + sha256: null, + symlink_target: "reports", + }; + const effect = { + path: link.path, + status: "created" as const, + before: null, + after: link, + }; + const capture = { + ...partial, + files_after: [link], + filesystem_effects: [effect], + filesystem_checkpoints: partial.filesystem_checkpoints.map((checkpoint) => + checkpoint.name === "after_settlement" + ? { ...checkpoint, files: [link], effects: [effect] } + : checkpoint, + ), + }; + expect(parseProcessCapture(capture)).toEqual(capture); + for (const changes of [ + { enumeration_omissions: [] }, + { + enumeration_omissions: [ + { + path: link.path, + reason: "symlink_not_followed", + system_code: "ENOENT", + }, + ], + }, + ]) + expect(() => + parseProcessCapture({ + ...capture, + truncation_details: { + ...capture.truncation_details, + filesystem_after: { + ...capture.truncation_details.filesystem_after, + ...changes, + }, + }, + }), + ).toThrow("enumeration_omissions"); + expect(() => + parseProcessCapture({ + ...capture, + files_after: [{ ...link, type: "directory", symlink_target: null }], + }), + ).toThrow("enumeration_omissions"); +}); + it("never considers truncated captures equivalent", () => { const complete = emptyCapture(); const capture = { diff --git a/src/domain/process/processCaptureCoverage.ts b/src/domain/process/processCaptureCoverage.ts index b4c5a3b69d..8551d90e40 100644 --- a/src/domain/process/processCaptureCoverage.ts +++ b/src/domain/process/processCaptureCoverage.ts @@ -15,6 +15,7 @@ const enumerationOmissionReasonSchema = z.enum([ "identity_changed", "directory_unavailable", "entry_unavailable", + "symlink_not_followed", ]); /** Filesystem enumeration and whole-file digest coverage for one checkpoint. */ diff --git a/src/domain/process/processCaptureValidation.ts b/src/domain/process/processCaptureValidation.ts index f01e4bd8d3..94126e13f0 100644 --- a/src/domain/process/processCaptureValidation.ts +++ b/src/domain/process/processCaptureValidation.ts @@ -378,18 +378,25 @@ const validateCoverage = ( coverage.enumeration_omissions.some( ({ reason }) => reason === "entry_unavailable", )) && - coverage.enumeration_omissions.every( - (omission) => - coverage.enumeration_reasons.includes(omission.reason) && - (omission.reason === "directory_unavailable" - ? entryTypes.get(omission.path) === "directory" && - omission.system_code !== null - : (entryTypes.get(omission.path) === undefined || - entryTypes.get(omission.path) === "directory") && - (omission.reason === "entry_unavailable" - ? omission.system_code !== null - : omission.system_code === null)), - ), `truncation_details.${name}.enumeration_omissions`, "every enumeration omission must identify an unavailable entry or retained directory, with a matching reason and system error only for unavailable reads"); + (!coverage.enumeration_reasons.includes("symlink_not_followed") || + coverage.enumeration_omissions.some( + ({ reason }) => reason === "symlink_not_followed", + )) && + coverage.enumeration_omissions.every((omission) => { + if (!coverage.enumeration_reasons.includes(omission.reason)) + return false; + const entryType = entryTypes.get(omission.path); + if (omission.reason === "symlink_not_followed") + return entryType === "symlink" && omission.system_code === null; + if (omission.reason === "directory_unavailable") + return entryType === "directory" && omission.system_code !== null; + return ( + (entryType === undefined || entryType === "directory") && + (omission.reason === "entry_unavailable" + ? omission.system_code !== null + : omission.system_code === null) + ); + }), `truncation_details.${name}.enumeration_omissions`, "every enumeration omission must identify an unavailable entry, retained directory or unfollowed symlink, with a matching reason and system error only for unavailable reads"); require(coverage.hashed_bytes === files .filter((file) => file.type === "file" && file.sha256 !== null) diff --git a/src/process/capture/FilesystemRootAdmission.test.ts b/src/process/capture/FilesystemRootAdmission.test.ts new file mode 100644 index 0000000000..baa5b1fa56 --- /dev/null +++ b/src/process/capture/FilesystemRootAdmission.test.ts @@ -0,0 +1,110 @@ +import { expect, it } from "vitest"; +import { AnalysisCleanupLease } from "../../artifacts/AnalysisCleanupLease.js"; +import { analysisErrorProjectionSchema } from "../../contracts/errorSchemas.js"; +import { AnalysisAccessDeniedError } from "../../domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; +import { emptyProcessCapture } from "../../domain/process/processCapture.fixture.js"; +import { parseProcessScenario } from "../../domain/process/processScenario.js"; +import { ProcessCaptureSnapshotFailure } from "./FilesystemSnapshot.js"; +import { ProcessCaptureError } from "./ProcessCaptureError.js"; +import { prepareProcessCapture } from "./ProcessCaptureLifecycle.js"; + +it.each(["EACCES", "EPERM"] as const)( + "retains %s admission facts through snapshot finalization and exact cleanup retry", + async (systemCode) => { + const selected = "/fixture/denied/selected"; + const native = Object.assign(new Error("selected root lookup denied"), { + code: systemCode, + }); + const primary = new AnalysisAccessDeniedError( + "capture_process_scenario", + selected, + systemCode, + { cause: native }, + ); + const snapshot = { + files: [], + completeRoots: ["root_0"], + truncated: true, + coverage: { + ...emptyProcessCapture().truncation_details.filesystem_before, + enumeration_truncated: true, + enumeration_reasons: ["entry_unavailable" as const], + enumeration_omissions: [ + { + path: "root_1:.", + reason: "entry_unavailable" as const, + system_code: systemCode, + }, + ], + }, + }; + let closes = 0; + const lease = new AnalysisCleanupLease(async () => { + closes++; + }, ["fixture:earlier-snapshot-descriptor"]); + const snapshotFailure = new ProcessCaptureSnapshotFailure({ + cause: primary, + snapshot, + cleanupLeases: [lease], + }); + const scenario = parseProcessScenario({ + executable: process.execPath, + filesystem_observation_paths: ["/fixture/absent", selected], + }); + const failure = await prepareProcessCapture( + scenario, + undefined, + async () => { + throw snapshotFailure; + }, + { + createTemporaryRoot: async () => { + throw new Error("admission must precede allocation"); + }, + cleanup: async () => { + throw new Error("no temporary root was allocated"); + }, + }, + ).catch((cause: unknown) => cause); + if (!(failure instanceof ProcessCaptureError)) + throw new Error("Expected retained snapshot finalization failure"); + expect(primary.cause).toBe(native); + expect(closes).toBe(0); + expect(projectAnalysisError(failure)).toMatchObject({ + code: "cleanup_incomplete", + details: { + resources: ["fixture:earlier-snapshot-descriptor"], + partial_observation: { + observations: { + target_pid: { state: "unavailable" }, + filesystem_snapshots: { + before: { state: "available", value: snapshot }, + }, + }, + }, + }, + }); + await lease.release(); + await lease.release(); + expect(closes).toBe(1); + expect( + analysisErrorProjectionSchema.parse(projectAnalysisError(failure)), + ).toMatchObject({ + code: "access_denied", + category: "unavailable", + details: { + path: selected, + system_code: systemCode, + boundary: "filesystem-read", + partial_observation: { + observations: { + filesystem_snapshots: { + before: { state: "available", value: snapshot }, + }, + }, + }, + }, + }); + }, +); diff --git a/src/process/capture/FilesystemSnapshot.ts b/src/process/capture/FilesystemSnapshot.ts index abb252fc8a..e954502f84 100644 --- a/src/process/capture/FilesystemSnapshot.ts +++ b/src/process/capture/FilesystemSnapshot.ts @@ -7,6 +7,7 @@ import type { ProcessScenario } from "../../domain/process/processScenario.js"; import type { FilesystemCoverage } from "../../domain/process/processCaptureCoverage.js"; import type { Stats } from "node:fs"; import { err, ok, type Result } from "../../domain/result.js"; +import { AnalysisAccessDeniedError } from "../../domain/analysisErrorCore.js"; import type { FileState, ProcessFilesystemSnapshot, @@ -209,6 +210,9 @@ export class ProcessCaptureSnapshotFailure extends ProcessCaptureError { "snapshot handle cleanup failed"; super(message, { cause: primary, + ...(options.cause instanceof AnalysisAccessDeniedError + ? { cleanupPrimary: options.cause } + : {}), ...(cancellation ? { userCategory: "cancelled" as const, reason: "cancelled" as const } : {}), @@ -457,7 +461,16 @@ export const snapshotRoots = async ( sha256: null, symlink_target: target.value, }); - return true; + truncated = true; + enumerationReasons.add("symlink_not_followed"); + enumerationOmissions.push({ + path: `${rootAlias}:${relativePath}`, + reason: "symlink_not_followed", + system_code: null, + }); + // A link leaves its descendants unknown, while a physically enumerated + // parent still proves absence of unrelated sibling names. + return depth > 0; } if (stats.isFile()) { if (remainingBytes < stats.size) { @@ -605,7 +618,8 @@ export const snapshotRoots = async ( files_limit: scenario.limits.files, depth_limit: scenario.limits.filesystem_depth, enumeration_truncated: - completeRoots.length !== scenario.filesystem_observation_paths.length, + completeRoots.length !== scenario.filesystem_observation_paths.length || + enumerationOmissions.length > 0, enumeration_reasons: [...enumerationReasons], enumeration_omissions: enumerationOmissions, hash_budget_bytes: scenario.limits.file_bytes, @@ -622,8 +636,24 @@ export const snapshotRoots = async ( const alias = `root_${String(index)}`; const lookup = await lookupPath(root, signal); if (lookup.state !== "present") { - if (admission === undefined && lookup.system_code !== "ENOENT") + if (admission === undefined && lookup.system_code !== "ENOENT") { + if ( + lookup.system_code === "EACCES" || + lookup.system_code === "EPERM" + ) { + omitEntry(`${alias}:.`, { + reason: "entry_unavailable", + system_code: lookup.system_code, + }); + throw new AnalysisAccessDeniedError( + "capture_process_scenario", + root, + lookup.system_code, + { cause: lookup.cause }, + ); + } throw lookup.cause; + } if (lookup.state === "absent") completeRoots.push(alias); else omitEntry(`${alias}:.`, { diff --git a/tests/boundary/process/filesystemObservationRaceCli.test.ts b/tests/boundary/process/filesystemObservationRaceCli.test.ts index 8eb8de40eb..0b19707c6b 100644 --- a/tests/boundary/process/filesystemObservationRaceCli.test.ts +++ b/tests/boundary/process/filesystemObservationRaceCli.test.ts @@ -63,7 +63,7 @@ const createScenario = async (removeOnSecondRead: boolean) => { }; captureCliTest( - "keeps a stable symlink observation complete through the public CLI", + "retains a stable symlink while reporting its unfollowed scope through the public CLI", async ({ cli }) => { const fixture = await createScenario(false); const result = await cli.run({ @@ -83,7 +83,7 @@ captureCliTest( expect((await lstat(fixture.link)).isSymbolicLink()).toBe(true); expect(result.json).toMatchObject({ normalized_result: { - truncated: false, + truncated: true, files_before: expect.arrayContaining([ expect.objectContaining({ path: "root_0:vanishing-link", @@ -99,7 +99,17 @@ captureCliTest( }), ]), truncation_details: { - filesystem_after: { enumeration_truncated: false }, + filesystem_after: { + enumeration_truncated: true, + enumeration_reasons: ["symlink_not_followed"], + enumeration_omissions: [ + { + path: "root_0:vanishing-link", + reason: "symlink_not_followed", + system_code: null, + }, + ], + }, }, }, }); diff --git a/tests/boundary/process/processFilesystemCoverageAdapters.test.ts b/tests/boundary/process/processFilesystemCoverageAdapters.test.ts index 20dd94f98e..9ded590b07 100644 --- a/tests/boundary/process/processFilesystemCoverageAdapters.test.ts +++ b/tests/boundary/process/processFilesystemCoverageAdapters.test.ts @@ -1,10 +1,18 @@ import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; -import { chmod, mkdir, readFile, symlink, writeFile } from "node:fs/promises"; +import { + chmod, + lstat, + mkdir, + readFile, + symlink, + writeFile, +} from "node:fs/promises"; import { join } from "node:path"; import { promisify } from "node:util"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { expect, it, onTestFinished } from "vitest"; +import { analysisCliErrorEnvelopeSchema } from "../../../src/contracts/errorSchemas.js"; import { parseEvidence } from "../../../src/domain/evidence.js"; import { compareProcessCaptures } from "../../../src/domain/process/processComparison.js"; import { parseProcessCapture } from "../../../src/domain/process/processCaptureParsing.js"; @@ -13,6 +21,7 @@ import { FINALIZED_PROCESS_CAPTURE_EXAMPLE } from "../../../src/domain/process/p import { createServer } from "../../../src/server/createServer.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { CAPTURE_SKIP_REASON, itWithCaptureCapability, @@ -36,7 +45,7 @@ const captureViaCli = async (scenario: Record) => { return parseEvidence(JSON.parse(stdout)); }; -const captureViaMcp = async (scenario: Record) => { +const callCaptureViaMcp = async (scenario: Record) => { const session = createTestBinarySession(() => { throw new Error("Process capture must not launch a binary provider"); }); @@ -55,12 +64,137 @@ const captureViaMcp = async (scenario: Record) => { name: "capture_process_scenario", arguments: scenario, }); - expect(result.isError, JSON.stringify(result)).not.toBe(true); - const evidence = parseEvidence(result.structuredContent); await client.ping(); - return evidence; + return result; }; +const captureViaMcp = async (scenario: Record) => { + const result = await callCaptureViaMcp(scenario); + expect(result.isError, JSON.stringify(result)).not.toBe(true); + return parseEvidence(result.structuredContent); +}; + +it + .skipIf( + CAPTURE_SKIP_REASON || + process.platform === "win32" || + process.getuid?.() === 0, + ) + .for(["cli", "mcp"] as const)( + "preserves selected-root permission denial and earlier snapshot facts through %s", + { timeout: 20_000 }, + async (adapter, context) => { + const root = await createTestTempDirectory("rea-fs-root-denial-adapter-"); + const denied = join(root, "denied"); + const selected = join(denied, "unobserved"); + const healthy = join(root, "healthy.txt"); + const launched = join(root, "launched.txt"); + const bytes = "retained before denied root"; + await mkdir(denied); + await writeFile(healthy, bytes); + await chmod(denied, 0); + try { + const lookup = await lstat(selected).catch((cause: unknown) => cause); + if ( + lookup instanceof Error && + "code" in lookup && + lookup.code === "ENOENT" + ) { + context.skip("The host does not enforce directory search denial."); + return; + } + if (!(lookup instanceof Error) || !("code" in lookup)) + throw new Error("Expected a native selected-root lookup failure"); + expect(["EACCES", "EPERM"]).toContain(lookup.code); + const scenario = { + executable: process.execPath, + arguments: [ + "-e", + "require('node:fs').writeFileSync('launched.txt','ran')", + ], + working_directory: root, + filesystem_observation_paths: [healthy, selected], + }; + let output; + if (adapter === "cli") { + const failure = await captureViaCli(scenario).catch( + (cause: unknown) => cause, + ); + if ( + !(failure instanceof Error) || + !("stdout" in failure) || + typeof failure.stdout !== "string" + ) + throw new Error("Expected the CLI to refuse capture before launch"); + output = analysisCliErrorEnvelopeSchema.parse( + JSON.parse(failure.stdout), + ); + } else { + const response = await callCaptureViaMcp(scenario); + output = parseMcpToolError(response).error; + } + expect(output).toMatchObject({ + code: "access_denied", + category: "unavailable", + details: { + operation: "capture_process_scenario", + path: selected, + system_code: lookup.code, + boundary: "filesystem-read", + partial_observation: { + observations: { + target_pid: { state: "unavailable" }, + filesystem_snapshots: { + before: { + state: "available", + value: { + files: [ + expect.objectContaining({ + path: "root_0:.", + sha256: createHash("sha256") + .update(bytes) + .digest("hex"), + }), + ], + completeRoots: ["root_0"], + coverage: { + enumeration_truncated: true, + enumeration_omissions: [ + { + path: "root_1:.", + reason: "entry_unavailable", + system_code: lookup.code, + }, + ], + }, + }, + }, + }, + }, + }, + }, + }); + expect(output).toMatchObject({ + remediation: { action: expect.stringContaining("read access") }, + }); + await expect(lstat(launched)).rejects.toMatchObject({ code: "ENOENT" }); + await chmod(denied, 0o700); + const evidence = await ( + adapter === "cli" ? captureViaCli : captureViaMcp + )(scenario); + expect( + parseProcessCapture(evidence.normalized_result).exit, + ).toMatchObject({ + code: 0, + reason: "exited", + }); + expect(await readFile(launched, "utf8")).toBe("ran"); + } finally { + await chmod(denied, 0o700); + } + }, +); + it("documents a finalization scenario and exit record that match the contract", async () => { const document = await readFile("docs/process-capture.md", "utf8"); const blocks = [ @@ -317,6 +451,76 @@ it.skipIf(CAPTURE_SKIP_REASON || process.platform === "win32")( 20_000, ); +it + .skipIf(CAPTURE_SKIP_REASON || process.platform === "win32") + .each(["cli", "mcp"] as const)( + "retains stable root and nested symlink uncertainty with known sibling effects through %s", + async (adapter) => { + const root = await createTestTempDirectory("rea-fs-stable-link-adapter-"); + const reports = join(root, "real", "reports"); + await mkdir(reports, { recursive: true }); + await symlink("real/reports", join(root, "link")); + await writeFile(join(root, "removed.txt"), "known sibling"); + const evidence = await (adapter === "cli" ? captureViaCli : captureViaMcp)({ + executable: process.execPath, + arguments: [ + "-e", + "const fs=require('node:fs');fs.writeFileSync('link/final.txt','done');fs.unlinkSync('removed.txt');", + ], + working_directory: root, + filesystem_observation_paths: [join(root, "link"), reports, root], + }); + const capture = parseProcessCapture(evidence.normalized_result); + expect(capture.exit).toMatchObject({ code: 0, reason: "exited" }); + expect(await readFile(join(reports, "final.txt"), "utf8")).toBe("done"); + for (const path of ["root_0:.", "root_2:link"]) + expect(capture.filesystem_effects).toContainEqual( + expect.objectContaining({ + path, + status: "unchanged", + after: expect.objectContaining({ + type: "symlink", + symlink_target: "real/reports", + }), + }), + ); + expect(capture.filesystem_effects).toContainEqual( + expect.objectContaining({ + path: "root_2:removed.txt", + status: "deleted", + }), + ); + expect(capture.filesystem_effects).toContainEqual( + expect.objectContaining({ + path: "root_1:final.txt", + status: "created", + after: expect.objectContaining({ + sha256: createHash("sha256").update("done").digest("hex"), + }), + }), + ); + expect( + capture.files_after.some(({ path }) => path === "root_0:final.txt"), + ).toBe(false); + for (const phase of ["filesystem_before", "filesystem_after"] as const) + expect(capture.truncation_details[phase]).toMatchObject({ + enumeration_truncated: true, + enumeration_reasons: ["symlink_not_followed"], + enumeration_omissions: ["root_0:.", "root_2:link"].map((path) => ({ + path, + reason: "symlink_not_followed", + system_code: null, + })), + }); + expect(capture.residual_unknowns).toContainEqual({ + scope: "filesystem", + reason: expect.stringContaining("symlink_not_followed"), + }); + expect(compareProcessCaptures(capture, capture).filesystem).toBe("unknown"); + }, + 20_000, +); + itWithCaptureCapability.each(["cli", "mcp"] as const)( "observes absence when selected roots lose their directory ancestor through %s", async (adapter) => { @@ -350,9 +554,18 @@ itWithCaptureCapability.each(["cli", "mcp"] as const)( before: expect.objectContaining({ type: "directory" }), }), ]); + const symlink = replacement.includes("symlinkSync"); expect(capture.truncation_details.filesystem_after).toMatchObject({ - enumeration_truncated: false, - enumeration_omissions: [], + enumeration_truncated: symlink, + enumeration_omissions: symlink + ? [ + { + path: "root_1:.", + reason: "symlink_not_followed", + system_code: null, + }, + ] + : [], }); } },