docs(process): verify independent full-report hashing budgets (#1440)

This commit is contained in:
Ysopwnable authored and GitHub committed 2026-10-10 13:13:05 +08:00
1 parent ce16f7bc95
commit bb6fa1c6f5
2 files changed
+148 -2

No files matched your search

+44 -2
View File
@@ -159,8 +159,11 @@ Trace assertions use coverage for the sources they select, so an assertion
about complete raw terminal output need not fail because rendered snapshots
were omitted. Captures missing `truncation_details` are rejected on import;
preserve them as historical files and recapture for current comparisons.
These diagnostics use the existing scenario
budgets; they do not introduce a separate file-hashing budget.
`limits.file_bytes` is already the cumulative whole-file hashing budget,
independent of `limits.output_bytes`, which bounds retained terminal content.
Each filesystem checkpoint starts with a fresh `file_bytes` budget across all
selected roots; it is not a per-file allowance. File contents are read in
64 KiB chunks for hashing and are not embedded in capture Evidence.
When the host withholds an unrelated process’s ownership token, REA leaves that
process untouched and records its PID and reason in `cleanup.unverified_processes`
@@ -176,6 +179,45 @@ as well. A newly started unreadable process can prevent verified cleanup even
after the selected command exits. REA preserves this uncertainty and leaves
that process untouched.
## Hash a report independently of terminal output
Select the producer's report directory and give `file_bytes` enough room for
the complete files you need, including other files in those roots. For example,
save this scenario and run `rea capture-process ./scenario.json --json > capture.json`:
```json
{
"executable": "node",
"arguments": [
"-e",
"const fs=require('node:fs');fs.mkdirSync('reports',{recursive:true});fs.writeFileSync('reports/final.json',JSON.stringify({status:'complete',data:'X'.repeat(131072)}));console.log('x'.repeat(8192));"
],
"working_directory": ".",
"filesystem_observation_paths": ["./reports"],
"limits": { "output_bytes": 1024, "file_bytes": 262144 }
}
```
The report exceeds the terminal budget but fits the independent 256 KiB hash
budget. Its complete SHA-256 appears in `normalized_result.files_after` at
`root_0:final.json`; check `truncation_details.filesystem_after` for omissions
and actual bytes hashed. The verbose terminal output is truncated without
preventing the report digest. The same scenario object is the MCP
`capture_process_scenario` input. REA never returns a prefix hash as a full-file
digest: an insufficient remaining budget produces a null digest and an
explicit omission reason.
A digest binds the complete bytes observed at that checkpoint, not the
producer's notion of a finished report. A stable periodic or running report
can also have a complete digest. Choose the final file and verify the
producer's documented completion marker, exit outcome and settlement
observations before calling it final. `after_settlement` names a filesystem
checkpoint, not a guarantee that an application finished successfully or
that an unobserved writer will never change the file. REA checks opened-file
identity/state around hashing and reports observed changes or unavailable
files; cancellation stops the hash. If an external sealer produces a manifest,
keep its completion claims and digest separate from REA's checkpoint facts.
## Compare two captures
Compare saved capture Evidence with:
@@ -1,4 +1,5 @@
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
@@ -7,6 +8,7 @@ import { expect, it, onTestFinished } from "vitest";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { compareProcessCaptures } from "../../../src/domain/process/processComparison.js";
import { parseProcessCapture } from "../../../src/domain/process/processCaptureParsing.js";
import { parseProcessScenario } from "../../../src/domain/process/processScenario.js";
import { createServer } from "../../../src/server/createServer.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
@@ -58,6 +60,108 @@ const captureViaMcp = async (scenario: Record<string, unknown>) => {
return evidence;
};
itWithCaptureCapability.each(["cli", "mcp"] as const)(
"hashes the documented complete report despite omitted terminal output through %s",
async (adapter) => {
const document = await readFile("docs/process-capture.md", "utf8");
const example = document
.split("## Hash a report independently of terminal output")[1]
?.match(/```json\n([\s\S]*?)\n```/u)?.[1];
if (example === undefined)
throw new Error("Missing report capture example");
const scenario = parseProcessScenario(JSON.parse(example));
const root = await createTestTempDirectory("rea-full-report-adapter-");
const evidence = await (adapter === "cli" ? captureViaCli : captureViaMcp)({
...scenario,
executable: process.execPath,
working_directory: root,
filesystem_observation_paths: [join(root, "reports")],
});
const capture = parseProcessCapture(evidence.normalized_result);
const bytes = await readFile(join(root, "reports", "final.json"));
expect(JSON.parse(bytes.toString())).toEqual({
status: "complete",
data: "X".repeat(131072),
});
expect(bytes.length).toBeGreaterThan(scenario.limits.output_bytes);
expect(capture.files_after).toContainEqual(
expect.objectContaining({
path: "root_0:final.json",
size: bytes.length,
sha256: createHash("sha256").update(bytes).digest("hex"),
}),
);
expect(capture.truncation_details.filesystem_after).toMatchObject({
enumeration_truncated: false,
hash_budget_bytes: scenario.limits.file_bytes,
hashed_bytes: bytes.length,
hash_omissions: [],
});
expect(capture.truncation_details.raw_terminal.retained_bytes).toBeLessThan(
capture.truncation_details.raw_terminal.observed_bytes,
);
},
20_000,
);
itWithCaptureCapability.each(["cli", "mcp"] as const)(
"accounts for exact cumulative hash budgets independently at each %s checkpoint",
async (adapter) => {
const runCapture = adapter === "cli" ? captureViaCli : captureViaMcp;
for (const shortfall of [0, 1]) {
const root = await createTestTempDirectory("rea-report-cumulative-");
const periodic = Buffer.from('{"status":"running"}');
const final = Buffer.from('{"status":"complete"}');
await writeFile(join(root, "periodic.json"), periodic);
const budget = periodic.length + final.length - shortfall;
const evidence = await runCapture({
executable: process.execPath,
arguments: [
"-e",
"require('node:fs').writeFileSync('final.json','{\"status\":\"complete\"}')",
],
working_directory: root,
filesystem_observation_paths: [root],
limits: { output_bytes: 1, file_bytes: budget },
});
const capture = parseProcessCapture(evidence.normalized_result);
expect(capture.truncation_details.filesystem_before).toMatchObject({
hashed_bytes: periodic.length,
hash_omissions: [],
});
expect(capture.files_after).toContainEqual(
expect.objectContaining({
path: "root_0:final.json",
sha256: createHash("sha256").update(final).digest("hex"),
}),
);
expect(capture.truncation_details.filesystem_after).toMatchObject({
enumeration_truncated: false,
hashed_bytes: final.length + (shortfall === 0 ? periodic.length : 0),
hash_omissions:
shortfall === 0
? []
: [
{
path: "root_0:periodic.json",
reason: "file_bytes_budget",
remaining_budget_bytes: periodic.length - 1,
},
],
});
expect(
capture.files_after.find(({ path }) => path === "root_0:periodic.json")
?.sha256,
).toBe(
shortfall === 0
? createHash("sha256").update(periodic).digest("hex")
: null,
);
}
},
20_000,
);
itWithCaptureCapability.each(["cli", "mcp"] as const)(
"reports unknown absence instead of deletion in truncated %s capture Evidence",
async (adapter) => {