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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Salman AlmutairiandClaude Opus 5.5 authored and GitHub committed 2026-11-05 16:57:16 +08:00
1 parent e1094313f5
commit 25923d372c
2 files changed
+85

No files matched your search

@@ -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 = {
@@ -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");