From 25923d372c7426ed7da9d0619ef97c452ae1ea75 Mon Sep 17 00:00:00 2001 From: Salman Almutairi <141588124+salmanalmutairi1@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:57:16 +0300 Subject: [PATCH] fix(process): reject contradictory filesystem capture facts (#7631) * fix(process): reject captures whose top-level filesystem facts contradict their checkpoints A process capture records its filesystem state twice: files_before, files_after and filesystem_effects at the top level, and the initial and final filesystem_checkpoints. Validation never reconciled the two, and comparison reads only the checkpoints. A caller-authored capture recording a created file at the top level while its final checkpoint was empty was therefore accepted and compared as unchanged. Semantic validation now requires the initial checkpoint to match files_before with no effects, and the final checkpoint to match files_after and filesystem_effects. The native producer builds both representations from the same snapshots, so produced captures are unaffected; consistent created-file captures still compare as changed. Fixes #7371 Co-Authored-By: Claude Opus 5.5 * test(process): cover file-list checkpoint mismatches independently of effects Every rejected case in the checkpoint-consistency test also disagreed on effects, so removing both file-list comparisons left it passing. Add initial and final checkpoint cases whose file lists alone disagree while effects and coverage accounting stay valid. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../process/processCapture.validation.test.ts | 69 +++++++++++++++++++ .../process/processCaptureValidation.ts | 16 +++++ 2 files changed, 85 insertions(+) diff --git a/src/domain/process/processCapture.validation.test.ts b/src/domain/process/processCapture.validation.test.ts index a24b983807..9a99f257d2 100644 --- a/src/domain/process/processCapture.validation.test.ts +++ b/src/domain/process/processCapture.validation.test.ts @@ -288,6 +288,75 @@ it("binds unfollowed coverage to observed symlink metadata without inventing an ).toThrow("enumeration_omissions"); }); +it("requires top-level filesystem facts to match their checkpoints", () => { + const baseline = emptyCapture(); + const file = { + path: "root_0:created.txt", + type: "file" as const, + mode: 0o100644, + size: 4, + sha256: "b".repeat(64), + symlink_target: null, + }; + const effect = { + path: file.path, + status: "created" as const, + before: null, + after: file, + }; + const topLevelOnly = { + ...baseline, + files_after: [file], + filesystem_effects: [effect], + truncation_details: { + ...baseline.truncation_details, + filesystem_after: { + ...baseline.truncation_details.filesystem_after, + hashed_bytes: 4, + }, + }, + }; + const consistent = { + ...topLevelOnly, + filesystem_checkpoints: baseline.filesystem_checkpoints.map((checkpoint) => + checkpoint.name === "after_settlement" + ? { ...checkpoint, files: [file], effects: [effect] } + : checkpoint, + ), + }; + expect(parseProcessCapture(consistent)).toEqual(consistent); + const withCheckpointFiles = ( + name: string, + files: (typeof baseline.files_before)[number][], + ) => + consistent.filesystem_checkpoints.map((checkpoint) => + checkpoint.name === name ? { ...checkpoint, files } : checkpoint, + ); + for (const contradictory of [ + topLevelOnly, + { ...consistent, filesystem_effects: [] }, + // File lists alone disagree; effects still match. + { + ...consistent, + filesystem_checkpoints: withCheckpointFiles("after_settlement", []), + }, + { + ...consistent, + filesystem_checkpoints: withCheckpointFiles("before", [file]), + }, + { + ...baseline, + filesystem_checkpoints: baseline.filesystem_checkpoints.map( + (checkpoint) => + checkpoint.name === "before" + ? { ...checkpoint, effects: [effect] } + : checkpoint, + ), + }, + ]) + expect(rejectedPaths(contradictory)).toEqual(["filesystem_checkpoints"]); +}); + it("never considers truncated captures equivalent", () => { const complete = emptyCapture(); const capture = { diff --git a/src/domain/process/processCaptureValidation.ts b/src/domain/process/processCaptureValidation.ts index b64ed1083e..fe6d08f3bd 100644 --- a/src/domain/process/processCaptureValidation.ts +++ b/src/domain/process/processCaptureValidation.ts @@ -1,3 +1,5 @@ +import canonicalize from "canonicalize"; + import { digestProcessCommitment, processScenarioOutputBudgetProjectionSchema, @@ -20,6 +22,11 @@ type RequireInvariant = ( message: string, ) => void; +const sameJsonValue = (left: unknown, right: unknown): boolean => { + const encoded = canonicalize(left); + return encoded !== undefined && encoded === canonicalize(right); +}; + const orderedTimestamps = (values: readonly number[]): boolean => values.every((value, index) => { const previous = values[index - 1]; @@ -254,6 +261,15 @@ const validateLifecycle = ( ), "filesystem_checkpoints", "filesystem snapshot times must be ordered"); require(!capture.filesystem_checkpoints.some(({ truncated }) => truncated) || capture.truncated, "truncated", "filesystem snapshot truncation must propagate to the capture"); + // Comparison reads only the checkpoints, so the top-level copies must not disagree. + const [initial, final] = capture.filesystem_checkpoints; + require(sameJsonValue(initial?.files, capture.files_before) && + initial?.effects.length === 0 && + sameJsonValue(final?.files, capture.files_after) && + sameJsonValue( + final?.effects, + capture.filesystem_effects, + ), "filesystem_checkpoints", "top-level filesystem snapshots and effects must match the initial and final checkpoints"); require(capture.exit.reason === "exited" || capture.exit.code === null, "exit", "deadline termination cannot declare a normal exit code");