docs(process): verify independent full-report hashing budgets (#1440)
This commit is contained in:
1 parent
ce16f7bc95
commit
bb6fa1c6f5
2 files changed
+148
-2
No files matched your search
+44
-2
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user