From a8609d562f4d5931ce40ac265705125e4d4d25fb Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 04:57:57 +0800 Subject: [PATCH] fix(process): preserve observation-root failures and omitted symlinks Keep typed search denial and acquired snapshot facts through cleanup and finalization. Record unfollowed symlink coverage at the snapshot owner, preserve physically enumerated parents, and retain uncertainty beneath omitted links without losing proven sibling deletions. Update parsed omission contracts, public CLI/MCP regressions and documentation without introducing a new lifecycle or coverage framework. Fixes #6446 Fixes #6444 --- docs/process-capture.md | 13 +- src/domain/process/processCapture.ts | 2 +- .../process/processCapture.validation.test.ts | 64 +++++ src/domain/process/processCaptureCoverage.ts | 1 + .../process/processCaptureValidation.ts | 31 ++- .../capture/FilesystemRootAdmission.test.ts | 110 +++++++++ src/process/capture/FilesystemSnapshot.ts | 36 ++- .../filesystemObservationRaceCli.test.ts | 16 +- .../processFilesystemCoverageAdapters.test.ts | 227 +++++++++++++++++- 9 files changed, 473 insertions(+), 27 deletions(-) create mode 100644 src/process/capture/FilesystemRootAdmission.test.ts 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, + }, + ] + : [], }); } },