@sanning/proof 0.19.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +30 -29
  2. package/dist/agent-record.d.ts +62 -0
  3. package/dist/agent-record.js +527 -0
  4. package/dist/agent-schemas.generated.d.ts +15 -0
  5. package/dist/agent-schemas.generated.js +5759 -0
  6. package/dist/case-file.d.ts +32 -89
  7. package/dist/case-file.js +41 -93
  8. package/dist/case.d.ts +339 -822
  9. package/dist/case.js +667 -1160
  10. package/dist/chain.d.ts +19 -47
  11. package/dist/chain.js +78 -205
  12. package/dist/claims.d.ts +211 -0
  13. package/dist/claims.js +1081 -0
  14. package/dist/cli.d.ts +45 -68
  15. package/dist/cli.js +618 -1022
  16. package/dist/compose.js +23 -65
  17. package/dist/crypto.d.ts +7 -8
  18. package/dist/crypto.js +60 -147
  19. package/dist/declaration.d.ts +68 -194
  20. package/dist/declaration.js +175 -518
  21. package/dist/environment.d.ts +9 -30
  22. package/dist/environment.js +17 -66
  23. package/dist/evidence.d.ts +119 -160
  24. package/dist/evidence.js +744 -1103
  25. package/dist/exit-codes.d.ts +11 -27
  26. package/dist/exit-codes.js +24 -59
  27. package/dist/file-match.d.ts +33 -79
  28. package/dist/file-match.js +33 -69
  29. package/dist/grading.d.ts +98 -135
  30. package/dist/grading.js +224 -380
  31. package/dist/handoff.d.ts +62 -160
  32. package/dist/handoff.js +153 -393
  33. package/dist/index.d.ts +14 -10
  34. package/dist/index.js +60 -167
  35. package/dist/json-schema.d.ts +29 -0
  36. package/dist/json-schema.js +298 -0
  37. package/dist/merkle.js +16 -28
  38. package/dist/message-parts.d.ts +56 -212
  39. package/dist/message-parts.js +142 -502
  40. package/dist/side-content.d.ts +11 -22
  41. package/dist/side-content.js +22 -65
  42. package/dist/types.d.ts +17 -0
  43. package/dist/types.js +19 -5
  44. package/dist/verifier.d.ts +40 -114
  45. package/dist/verifier.js +196 -535
  46. package/dist/witness-cache.d.ts +24 -45
  47. package/dist/witness-cache.js +38 -81
  48. package/dist/witness-lookup.d.ts +36 -57
  49. package/dist/witness-lookup.js +89 -139
  50. package/dist/zip-reader.d.ts +18 -33
  51. package/dist/zip-reader.js +19 -34
  52. package/package.json +11 -3
  53. package/src/agent-record.ts +608 -0
  54. package/src/agent-schemas.generated.ts +5764 -0
  55. package/src/case-file.ts +45 -109
  56. package/src/case.ts +814 -1328
  57. package/src/chain.ts +85 -224
  58. package/src/claims.ts +1253 -0
  59. package/src/cli.ts +714 -1176
  60. package/src/compose.ts +33 -79
  61. package/src/crypto.ts +60 -147
  62. package/src/declaration.ts +219 -656
  63. package/src/environment.ts +17 -65
  64. package/src/evidence.ts +958 -1381
  65. package/src/exit-codes.ts +24 -63
  66. package/src/file-match.ts +40 -89
  67. package/src/grading.ts +287 -421
  68. package/src/handoff.ts +175 -427
  69. package/src/index.ts +131 -177
  70. package/src/json-schema.ts +295 -0
  71. package/src/merkle.ts +16 -28
  72. package/src/message-parts.ts +155 -533
  73. package/src/side-content.ts +25 -68
  74. package/src/types.ts +55 -36
  75. package/src/verifier.ts +202 -526
  76. package/src/witness-cache.ts +45 -94
  77. package/src/witness-lookup.ts +107 -177
  78. package/src/zip-reader.ts +20 -38
  79. package/vectors/README.md +52 -34
  80. package/vectors/agent-record.json +2175 -0
  81. package/vectors/case-file-index.json +1 -1
  82. package/vectors/case-split.json +2 -2
  83. package/vectors/chain-reading.json +3 -3
  84. package/vectors/file-match.json +285 -148
  85. package/vectors/jcs-reader.json +6 -6
  86. package/vectors/pack-grading.json +102 -25
  87. package/vectors/sandbox-evidence.json +573 -581
  88. package/vectors/stamp-on-chain.json +18 -18
  89. package/vectors/witness-cache.json +49 -49
  90. package/dist/agent-proof.d.ts +0 -28
  91. package/dist/agent-proof.js +0 -226
  92. package/dist/run-adapters.d.ts +0 -10
  93. package/dist/run-adapters.js +0 -35
  94. package/dist/run.d.ts +0 -209
  95. package/dist/run.js +0 -1405
  96. package/src/agent-proof.ts +0 -264
  97. package/src/run-adapters.ts +0 -44
  98. package/src/run.ts +0 -1598
package/dist/cli.js CHANGED
@@ -1,54 +1,24 @@
1
1
  #!/usr/bin/env node
2
2
  // sanning-proof verify <bundle.json> [--gateways <url,url>]
3
- // (`npx @sanning/proof verify ...` without installing: npm runs a package's
4
- // only binary whatever it is called.)
5
3
  //
6
- // One turnkey verifier for both producer bundles in the stack:
7
- // - sanning.evidence/v1: bodies sanning.anchor.trace/v3, sanning.stamp.trace/v1,
8
- // sanning.stamp.evidence/v2 and sanning.evidence.export/v2
9
- // - sanning.agent.proof/v1: the agent inclusion proof
10
- // Dispatch is by sniffed spec_version; no flag tells the CLI which it is.
4
+ // Verifies sanning.evidence/v1 bundles (bodies sanning.anchor.trace/v3,
5
+ // sanning.stamp.trace/v1, sanning.stamp.evidence/v2, sanning.evidence.export/v2).
11
6
  //
12
- // Exit codes: one table for every verb, `./exit-codes.ts` (0.13.0):
13
- // 0 verified, or the export was written
14
- // 1 failed (bad signature / tamper / broken inclusion / on-chain mismatch)
15
- // 2 usage or malformed (unparseable, unknown spec major, body_hash mismatch)
16
- // 3 UNDETERMINED: partial, pending, incomplete or not_found. A gateway that
17
- // could not be reached reads `partial`; a pack whose records nobody hashed
18
- // reads `incomplete` (see below)
19
- // 4 mixed
20
- // 5 sandbox: a record is signed environment dev; would be 0, or only a
21
- // simulated witness is unchecked (`sandboxExit`)
22
- // `sanningd verify-status` in sanning-io/agent mirrored the 0.12 table, which
23
- // exited 0 on `partial`; it moves with this one.
7
+ // Exit codes, one table for every verb (`./exit-codes.ts`): 0 verified, 1 failed,
8
+ // 2 incomplete, 3 verified sandbox evidence (a record is signed environment dev),
9
+ // 4 could not run. 2 is not a failure: a withheld record or bytes nobody handed
10
+ // over are not proven, never disproven.
24
11
  //
25
- // ── The headline is the PACK verdict, not the bundle status (proof#110) ──
26
- //
27
- // This command used to print `result.status` as its headline and map it to an
28
- // exit code directly. That is the verdict on the signed BUNDLE, and it is not
29
- // the verdict the rest of the product prints. `gradePack` in `./grading.ts` is
30
- // the kernel's one rule for what a verification SURFACE may claim about a pack,
31
- // the browser verify page calls it, and this command referenced it zero times.
32
- // So the same file read `Incomplete` on the page and `VERIFIED`, exit 0, at the
33
- // terminal: two answers to one question, from one kernel, which is the exact
34
- // defect `grading.ts` was brought into the kernel to end.
35
- //
36
- // The two layers stay distinct, deliberately (`grading.ts` says so in its own
37
- // comments, and `incomplete` is a pack verdict in `evidence-bundle.md` §3,
38
- // never a bundle status): the BUNDLE status is still reported, unchanged, on
39
- // its own line. What changed is
40
- // which of the two is the headline and which one the exit code follows.
41
- //
42
- // `payloadHashOk === null` (a withheld record) is semantics-undetermined and
43
- // surfaces as a per-event note, and it does NOT fail the run. It does hold the
44
- // PACK at `incomplete`, because a record nobody hashed is a record nobody
45
- // checked, and exit 3 is how this command says "undetermined".
12
+ // The headline is the pack verdict, read off the six claims (`claims.ts`), the
13
+ // same rule the verify page prints. The report prints the person layer first
14
+ // (the title, one line per claim that is not proven, the next action, the notes,
15
+ // the sandbox line and the witness line), then the detail.
46
16
  import { lstat, open, readFile, readdir, stat, writeFile } from "node:fs/promises";
47
17
  import { constants as fsConstants, realpathSync } from "node:fs";
48
18
  import { createHash, randomBytes } from "node:crypto";
49
19
  import { basename, resolve, sep } from "node:path";
50
20
  import { fileURLToPath } from "node:url";
51
- import { isAgentProofSpec, verifyAgentProof } from "./agent-proof.js";
21
+ import { renderAgentReading } from "./agent-record.js";
52
22
  import { CHAIN_READING_SCOPE, readChainLinks, readChains, renderChainFinding } from "./chain.js";
53
23
  import { VERDICT_DISPLAY_TITLES, caseReliance, readCase, renderCaseNote, renderCaseSummary, renderException, renderWitnessLine, } from "./case.js";
54
24
  import { CASE_FILE_INDEX_NAME, caseFileListedPaths, jsonDepthExceeds, readCaseFileIndex, renderCaseFileFinding, unreadableCaseFileIndex, } from "./case-file.js";
@@ -56,38 +26,27 @@ import { MAX_ZIP_ARCHIVE_BYTES, MAX_ZIP_BYTES, ZipReadError, readZip } from "./z
56
26
  import { composeExport } from "./compose.js";
57
27
  import { isHexField, sha256Hex, utf8 } from "./crypto.js";
58
28
  import { verifyEvidenceBundle } from "./evidence.js";
59
- import { readRuns, renderAccountLine, renderEnclaveLines } from "./run.js";
60
- import { loadInstalledAdapters } from "./run-adapters.js";
61
29
  import { DuplicateKeyError, parseRefusingDuplicates } from "./verifier.js";
62
30
  import { fileMatchToJson, matchCaseFiles, matchFiles, notAccountedFor, renderFileMatch, } from "./file-match.js";
63
31
  import { WITNESS_CACHE_NAME, readWitnessCache, } from "./witness-cache.js";
64
- // The public-record (witness) lookup this command runs with `--gateways`:
65
- // moved to its own pure module (proof#200) so a consumer that is not this CLI,
66
- // the verify page, can call the SAME implementation. `onChainHalf` and
67
- // `witnessLine` are this command's own two building blocks, unchanged; see
68
- // `./witness-lookup.ts` for the exported entry point, `lookupPublicRecord`.
69
- import { onChainHalf, witnessLine } from "./witness-lookup.js";
32
+ import { onChainHalf, onChainOutcome, onChainRow, witnessLine, } from "./witness-lookup.js";
70
33
  import { checkDeclaration, renderDeclarationSummary, renderNote } from "./declaration.js";
71
34
  import { completenessNotes, gradePack, renderCause } from "./grading.js";
72
- import { EXIT_CODES_HELP, EXIT_FAILED, EXIT_MALFORMED, EXIT_SANDBOX, EXIT_UNDETERMINED, EXIT_VERIFIED, exitCodeFor, sandboxExit, } from "./exit-codes.js";
73
- import { environmentMarker, onlySimulatedUnsettled, simulatedOnlyRun, SANDBOX_HEADLINE, SANDBOX_LINE, SANDBOX_TITLE, } from "./environment.js";
74
- // ── What a person reads is escaped at one choke point ──
75
- //
76
- // Every human-readable line this command prints passes through `guard`, which
77
- // `runCli` and `runCase` apply to the streams they are handed. It escapes each
78
- // control character, so a value the audited party wrote (a folder or file
79
- // name, a `contents.json` field, a zip entry name, an exception built from
80
- // one) cannot move the cursor, clear the screen or reorder the line. Before
81
- // this, a case file whose name carried `\x1b[2J\x1b[H` printed "Verified" over
82
- // a FAILED headline. `outJson` is the one way around it, and it carries JSON
83
- // only, which escapes its own control characters. The twin is `Io` in
84
- // `src/sanning_proof/cli.py`, and the two print byte-identical escapes.
35
+ import { EXIT_CODES_HELP, EXIT_COULD_NOT_RUN, EXIT_FAILED, EXIT_VERIFIED, exitCodeFor, } from "./exit-codes.js";
36
+ import { environmentMarker, simulatedOnlyRun } from "./environment.js";
37
+ import { VERDICT_TITLES, bundleProblems, claimsOf, note, orderedNotes, personText, problem, renderFindingNote, renderPerson, restOnSigned, verdictOf, } from "./claims.js";
38
+ // Every human-readable line passes through `guard`, which escapes control
39
+ // characters, so a value the audited party wrote (a file name, a zip entry, a
40
+ // field) cannot move the cursor or overwrite a headline. `outJson` is the one
41
+ // way around it, and JSON escapes its own control characters. Python's `Io` in
42
+ // `cli.py` prints byte-identical escapes.
85
43
  // C0, DEL, C1, the two Unicode line and paragraph separators, and the bidi
86
44
  // embedding, override and isolate controls.
87
45
  const CONTROL_CHARACTERS = /[\x00-\x1f\x7f-\x9f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
88
- /** Each control character in `text` as a visible escape: `\x1b` for ESC,
89
- * `\u202e` for RIGHT-TO-LEFT OVERRIDE. Every other character is unchanged, a
90
- * backslash included, so a line with none is printed as it was. */
46
+ /**
47
+ * Each control character in `text` as a visible escape (`\x1b`, `‮`).
48
+ * Every other character, a backslash included, is unchanged.
49
+ */
91
50
  export function escapeControl(text) {
92
51
  return text.replace(CONTROL_CHARACTERS, (c) => {
93
52
  const code = c.charCodeAt(0);
@@ -96,16 +55,12 @@ export function escapeControl(text) {
96
55
  : `\\u${code.toString(16).padStart(4, "0")}`;
97
56
  });
98
57
  }
99
- // The colors this command writes on a TTY are its own control characters, and
100
- // the choke point would escape them with everything else. So `paint` and
101
- // `mark` write this marker instead, and the choke point turns it into ESC
102
- // after escaping. It is random per process, so no input can spell it.
58
+ // `paint` and `mark` write this marker instead of ESC, so the escaping choke
59
+ // point does not escape our own colors; it becomes ESC after escaping. Random
60
+ // per process, so no input can spell it.
103
61
  const COLOR_MARK = `sgr${randomBytes(16).toString("hex")}:`;
104
62
  const G = `${COLOR_MARK}32m`;
105
63
  const R = `${COLOR_MARK}31m`;
106
- const Y = `${COLOR_MARK}33m`;
107
- const DIM = `${COLOR_MARK}2m`;
108
- const B = `${COLOR_MARK}1m`;
109
64
  const X = `${COLOR_MARK}0m`;
110
65
  function forTerminal(text) {
111
66
  return escapeControl(text).split(COLOR_MARK).join("\x1b[");
@@ -129,8 +84,7 @@ function rawOf(io) {
129
84
  function outJson(io, text) {
130
85
  rawOf(io).out(text);
131
86
  }
132
- /** A block this command wrote itself, such as a usage text: its own newlines
133
- * are kept, and each line is escaped as `out` escapes it. */
87
+ /** A block this command wrote itself: its newlines are kept, each line escaped. */
134
88
  function outBlock(io, text) {
135
89
  rawOf(io).out(text.split("\n").map(forTerminal).join("\n"));
136
90
  }
@@ -140,14 +94,6 @@ function useColor() {
140
94
  return false;
141
95
  return Boolean(process.stdout.isTTY);
142
96
  }
143
- function mark(ok) {
144
- const c = useColor();
145
- if (ok === null)
146
- return c ? `${Y}~${X}` : "~";
147
- if (ok)
148
- return c ? `${G}✓${X}` : "✓";
149
- return c ? `${R}✗${X}` : "✗";
150
- }
151
97
  function paint(s, c) {
152
98
  return useColor() ? `${c}${s}${X}` : s;
153
99
  }
@@ -156,16 +102,13 @@ export async function runCli(argv, stream) {
156
102
  const [command, ...rest] = argv;
157
103
  if (command === undefined || command === "help" || command === "--help" || command === "-h") {
158
104
  printUsage(io);
159
- return command === undefined ? EXIT_MALFORMED : EXIT_VERIFIED;
105
+ return command === undefined ? EXIT_COULD_NOT_RUN : EXIT_VERIFIED;
160
106
  }
161
107
  if (command === "version" || command === "--version" || command === "-v") {
162
108
  io.out("@sanning/proof verify CLI");
163
109
  return EXIT_VERIFIED;
164
110
  }
165
- // `export` composes an attested export (`sanning.evidence.export/v2`). It
166
- // was taught as `bundle` until 0.13.0, and `bundle` is now the one verb that
167
- // builds a pack (`anchor bundle`, DL-59), so this command no longer answers
168
- // to it: `proof bundle` is an unknown command and exits 2, with no alias.
111
+ // `export` composes an attested export (`sanning.evidence.export/v2`).
169
112
  if (command === "export") {
170
113
  if (rest.includes("--json")) {
171
114
  return runJson(rest.filter((a) => a !== "--json"), io, runExport);
@@ -178,7 +121,7 @@ export async function runCli(argv, stream) {
178
121
  if (command !== "verify") {
179
122
  io.err(`unknown command: ${command}`);
180
123
  printUsage(io);
181
- return EXIT_MALFORMED;
124
+ return EXIT_COULD_NOT_RUN;
182
125
  }
183
126
  if (rest.includes("--json")) {
184
127
  return runJson(rest.filter((a) => a !== "--json"), io, runVerify);
@@ -207,23 +150,25 @@ async function runJson(rest, io, verb) {
207
150
  return code;
208
151
  }
209
152
  async function runVerify(rest, io, sink) {
210
- // `--logs <path>` feeds disclosed raw-log bytes for content verification
211
- // (the evidence-bundle path only); `--gateways <url,url>` asks for the
212
- // on-chain re-fetch, as it does on `case` (0.13.0: the gateway list was a
213
- // second positional argument until then, and a second positional is now a
214
- // usage error). The one positional is the bundle.
153
+ // `--logs <path>` supplies disclosed raw-log bytes (evidence-bundle path
154
+ // only); `--gateways <url,url>` asks for the on-chain re-fetch. The one
155
+ // positional is the bundle.
215
156
  let logsPath;
216
157
  let filePaths;
217
158
  let gatewayArg;
218
159
  const positional = [];
219
160
  for (let i = 0; i < rest.length; i++) {
220
161
  const a = rest[i];
162
+ if ((a === "--help" || a === "-h") && positional.length === 0) {
163
+ printUsage(io);
164
+ return EXIT_VERIFIED;
165
+ }
221
166
  if (a === "--gateways") {
222
167
  const next = rest[i + 1];
223
168
  if (next === undefined) {
224
169
  io.err("verify: --gateways requires a comma-separated list of URLs");
225
170
  printUsage(io);
226
- return EXIT_MALFORMED;
171
+ return EXIT_COULD_NOT_RUN;
227
172
  }
228
173
  gatewayArg = next;
229
174
  i++;
@@ -234,7 +179,7 @@ async function runVerify(rest, io, sink) {
234
179
  if (next === undefined) {
235
180
  io.err("verify: --logs requires a path (a logs directory, or a JSON map file)");
236
181
  printUsage(io);
237
- return EXIT_MALFORMED;
182
+ return EXIT_COULD_NOT_RUN;
238
183
  }
239
184
  logsPath = next;
240
185
  i++;
@@ -245,7 +190,7 @@ async function runVerify(rest, io, sink) {
245
190
  if (taken.length === 0) {
246
191
  io.err("verify: --files requires at least one file path");
247
192
  printUsage(io);
248
- return EXIT_MALFORMED;
193
+ return EXIT_COULD_NOT_RUN;
249
194
  }
250
195
  filePaths = [...(filePaths ?? []), ...taken];
251
196
  i += taken.length;
@@ -257,12 +202,12 @@ async function runVerify(rest, io, sink) {
257
202
  if (!bundlePath) {
258
203
  io.err("verify: a bundle file path is required");
259
204
  printUsage(io);
260
- return EXIT_MALFORMED;
205
+ return EXIT_COULD_NOT_RUN;
261
206
  }
262
207
  if (positional.length > 1) {
263
208
  io.err("verify: takes one bundle; pass gateways as --gateways <url,url>");
264
209
  printUsage(io);
265
- return EXIT_MALFORMED;
210
+ return EXIT_COULD_NOT_RUN;
266
211
  }
267
212
  const gateways = (gatewayArg ?? "")
268
213
  .split(",")
@@ -272,7 +217,7 @@ async function runVerify(rest, io, sink) {
272
217
  if (logsPath !== undefined) {
273
218
  logs = await loadLogs(logsPath, io);
274
219
  if (logs === undefined)
275
- return EXIT_MALFORMED;
220
+ return EXIT_COULD_NOT_RUN;
276
221
  for (const n of logs.notes)
277
222
  io.err(`note: ${n}`);
278
223
  }
@@ -283,7 +228,7 @@ async function runVerify(rest, io, sink) {
283
228
  const read = await readOfferedFiles(filePaths);
284
229
  if (typeof read === "string") {
285
230
  io.err(`verify: ${read}`);
286
- return EXIT_MALFORMED;
231
+ return EXIT_COULD_NOT_RUN;
287
232
  }
288
233
  offered = read.offered;
289
234
  skipped = read.skipped;
@@ -294,11 +239,9 @@ async function runVerify(rest, io, sink) {
294
239
  }
295
240
  catch (e) {
296
241
  io.err(`cannot read ${bundlePath}: ${e instanceof Error ? e.message : String(e)}`);
297
- return EXIT_MALFORMED;
242
+ return EXIT_COULD_NOT_RUN;
298
243
  }
299
- // The one reader both commands use, in both kernels: `case` reads each pack's
300
- // bundle.json through it too, so the two commands classify one bundle the
301
- // same way (see `readBundleBytes`).
244
+ // The same reader `case` uses, so both commands classify a bundle alike.
302
245
  let parsed;
303
246
  try {
304
247
  parsed = readBundleBytes(raw);
@@ -313,49 +256,26 @@ async function runVerify(rest, io, sink) {
313
256
  else {
314
257
  io.err(`${bundlePath} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
315
258
  }
316
- return EXIT_MALFORMED;
259
+ return EXIT_COULD_NOT_RUN;
317
260
  }
318
- const specVersion = parsed && typeof parsed === "object" && !Array.isArray(parsed)
319
- ? parsed.spec_version
320
- : undefined;
321
- if (isAgentProofSpec(specVersion)) {
322
- if (content) {
323
- // --logs binds raw logs to anchor-trace events; an agent inclusion proof
324
- // has no such per-event content. Say so rather than silently ignoring it.
325
- io.err("note: --logs has no effect on a sanning.agent.proof/v1 bundle (ignored)");
326
- }
327
- if (offered) {
328
- io.err("note: --files has no effect on a sanning.agent.proof/v1 bundle (ignored)");
329
- }
330
- const result = await verifyAgentProof(parsed, {
331
- gateways,
332
- ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
333
- });
334
- return reportAgentProof(result, bundlePath, gateways, io, sink, parsed);
261
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
262
+ // Not a pack at all: nothing in it names a claim to read.
263
+ io.err(`${bundlePath} is not an evidence bundle: not a JSON object`);
264
+ return EXIT_COULD_NOT_RUN;
335
265
  }
336
- // Default: treat anything else as an evidence bundle. verifyEvidenceBundle
337
- // rejects an unknown major as "malformed" so a genuinely unknown shape still
338
- // exits 2 with a clear message.
266
+ // An evidence bundle; an unknown major is unreadable, and incomplete.
339
267
  const result = await verifyEvidenceBundle(parsed, {
340
268
  gateways,
341
269
  ...(content ? { content } : {}),
342
270
  ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
343
271
  });
344
- // Read the chains from the SAME parsed bundle the verifier saw. Findings are
345
- // evidence about the records in front of us, not a verdict: they are printed
346
- // and they move `exit` only through the pack's own declaration, where a v3
347
- // declaration claims a chain complete and in order and a finding under that
348
- // claim contradicts it (rule 11). Without a claim a chain is not a
349
- // completeness proof, and an exit code is exactly the kind of thing people
350
- // read as one.
351
- //
352
- // A READING THAT COULD NOT BE PRODUCED SAYS SO. This was an unguarded call:
353
- // a throw out of `readChains` would have escaped the command entirely and
354
- // exited 1, which is the code for "failed" and would have named the
355
- // customer's evidence tampered over a fault in this reader. The Python twin
356
- // had the opposite half of the same defect and swallowed the throw into "no
357
- // event chain was found to follow in this bundle" (the gate on proof#154).
358
- // Neither kernel is entitled to either answer, so both now print the third.
272
+ // The same pack read without the bytes handed in beside it, so a disproof that only those
273
+ // bytes make reads as not proven (evidence-bundle.md section 5.1).
274
+ // Offline: what the public record holds does not turn on the bytes beside the pack.
275
+ const bare = content ? await verifyEvidenceBundle(parsed) : null;
276
+ // Chain findings are evidence, not a verdict: they move `exit` only through a
277
+ // v3 declaration's chain claim (rule 11). A reading that cannot be produced
278
+ // says so, rather than exiting 1 (failed) over a fault in this reader.
359
279
  let chains;
360
280
  try {
361
281
  chains = await readChains(parsed);
@@ -363,9 +283,8 @@ async function runVerify(rest, io, sink) {
363
283
  catch (e) {
364
284
  chains = { unreadable: errMsg(e) };
365
285
  }
366
- // The links rule 11 holds a claimed chain to (evidence-bundle.md 5.4.2), from
367
- // the same parsed bundle. Links that cannot be read are none, which reads a
368
- // claim UNKNOWN and never clean.
286
+ // Links for rule 11. Unreadable links are none, which reads a claim unknown,
287
+ // never clean.
369
288
  let links;
370
289
  try {
371
290
  links = "unreadable" in chains ? null : await readChainLinks(parsed);
@@ -373,9 +292,9 @@ async function runVerify(rest, io, sink) {
373
292
  catch {
374
293
  links = null;
375
294
  }
376
- const exit = reportEvidence(result, bundlePath, gateways, io, chains, links, parsed, logs, sink);
377
- // Files handed in beside the pack (evidence-bundle.md 5.7, A29): findings
378
- // beside the verdict, never inside it, so `exit` is untouched.
295
+ const exit = reportEvidence(result, bundlePath, gateways, io, chains, links, parsed, logs, sink, bare);
296
+ // Files handed in beside the pack (evidence-bundle.md §5.7): findings beside
297
+ // the verdict, never inside it.
379
298
  if (offered !== undefined) {
380
299
  const reading = { ...(await matchFiles(parsed, offered, content)), skipped };
381
300
  for (const line of renderFileMatch(reading))
@@ -383,9 +302,8 @@ async function runVerify(rest, io, sink) {
383
302
  if (sink !== null && sink.data !== null)
384
303
  sink.data.files = fileMatchToJson(reading);
385
304
  }
386
- // Coverage is reported only once the bundle parsed far enough to have events
387
- // to compare against; over a MALFORMED bundle there is nothing to say.
388
- if (logs && result.status !== "malformed")
305
+ // Over a malformed bundle there are no events to compare against.
306
+ if (logs && result.unreadable !== true)
389
307
  reportLogsCoverage(logs, result, io);
390
308
  return exit;
391
309
  }
@@ -399,31 +317,26 @@ function takeFilePaths(args, start) {
399
317
  }
400
318
  return out;
401
319
  }
402
- // A file is hashed in pieces this big, so a file is read without being held.
403
320
  const HASH_CHUNK = 1 << 20;
404
- // The most a command reads from the files it finds in folders it was handed, in
405
- // all: the cap the zip reader puts on an archive. A file that would push the
406
- // total past it is skipped as `too_large` and never read past it, so a sparse
407
- // file of a terabyte cannot hold a command for hours (A41). A file a person
408
- // names on the command line is the person's choice and is read at any size.
321
+ // The total a command reads from files found in folders it was handed. A file
322
+ // that would pass it is skipped as `too_large` unread, so a sparse terabyte
323
+ // cannot stall the command. A file named on the command line is read at any
324
+ // size.
409
325
  export const MAX_FOLDER_BYTES = MAX_ZIP_BYTES;
410
- // What `hashFile` returns for a file that would push the total past the cap.
411
326
  export const TOO_LARGE = "too_large";
412
- // A child of a folder, by concatenation. `path.join` collapses `link/..`
413
- // lexically, where the operating system, and the Python command, resolve it
414
- // on disk: the same typed path then names two different folders (proof#219).
327
+ // By concatenation: `path.join` collapses `link/..` lexically, while the OS and
328
+ // the Python command resolve it on disk.
415
329
  function childPath(directory, name) {
416
330
  return directory.endsWith("/") ? `${directory}${name}` : `${directory}/${name}`;
417
331
  }
418
332
  // The SHA-256 of a regular file, read in pieces, or null when it cannot be
419
- // read. Inside a folder the file is opened without following a link and
420
- // without blocking, then checked again, so a file swapped for a link or a pipe
421
- // after the walk listed it is skipped and never followed or waited on. A path a
422
- // person typed (`follow`) is opened as the shell would.
333
+ // read. Inside a folder the file is opened without following a link or
334
+ // blocking, then rechecked, so a file swapped for a link or pipe is skipped. A
335
+ // typed path (`follow`) is opened as the shell would.
423
336
  //
424
- // `budget` is the bytes still allowed across the folders of a run. A file
425
- // larger than it returns TOO_LARGE before a byte is read, and so does a file
426
- // that grows past it while it is read; only a file read whole is charged.
337
+ // `budget` is the bytes still allowed across the run's folders. A file larger
338
+ // than it, or one that grows past it while read, returns TOO_LARGE; only a file
339
+ // read whole is charged.
427
340
  export async function hashFile(path, follow, budget) {
428
341
  const flags = fsConstants.O_RDONLY | (fsConstants.O_NONBLOCK ?? 0) | (follow ? 0 : (fsConstants.O_NOFOLLOW ?? 0));
429
342
  let handle;
@@ -462,15 +375,11 @@ export async function hashFile(path, follow, budget) {
462
375
  await handle.close();
463
376
  }
464
377
  }
465
- // The regular files under a `--files` folder, recursively, hidden files
466
- // included, into `offered` as `<folder>/<path inside>` in UTF-16 code unit
467
- // order of that path; a link or anything that is not a regular file, and a
468
- // file or folder that cannot be read, into `skipped`. Returns the one error
469
- // line, or null. Iterative, bounded as the case walk is.
470
- //
471
- // The files are hashed once the walk is done, in the order they are listed, so
472
- // which file a full `budget` turns away does not depend on how the walk
473
- // happened to reach it.
378
+ // The regular files under a `--files` folder, recursively, hidden ones
379
+ // included, into `offered` as `<folder>/<path inside>` in UTF-16 order; links,
380
+ // non-regular and unreadable entries into `skipped`. Returns the one error line,
381
+ // or null. Files are hashed after the walk, in listed order, so which file the
382
+ // budget turns away does not depend on walk order.
474
383
  async function walkOfferedFolder(typed, offered, skipped, seen, budget) {
475
384
  let base = typed;
476
385
  while (base.length > 0 && (base.endsWith("/") || base.endsWith(sep)))
@@ -547,16 +456,12 @@ async function walkOfferedFolder(typed, offered, skipped, seen, budget) {
547
456
  }
548
457
  return null;
549
458
  }
550
- // Each `--files` path as `{ name, sha256 }`, or the one error line.
551
- //
552
- // A path that is a regular file is one file, its name exactly as typed. A path
553
- // that is a folder stands for the regular files inside it, recursively and
554
- // hidden files included, each named `<folder as typed>/<path inside>` (A40,
555
- // proof#214). The rest is returned as `skipped`, named, never dropped. A path
556
- // that is neither a readable file nor a readable folder is the one error line,
557
- // which names the path and not the operating system's words, which differ by
558
- // kernel. A path typed is followed as a shell would; a link found inside a
559
- // folder is not.
459
+ // Each `--files` path as `{ name, sha256 }`, or the one error line. A file is
460
+ // named as typed; a folder stands for its regular files, recursively, named
461
+ // `<folder as typed>/<path inside>`. The rest is returned as `skipped`, never
462
+ // dropped. A typed path is followed as a shell would; a link inside a folder is
463
+ // not. The error line names the path, not the OS's words, which differ by
464
+ // kernel.
560
465
  export async function readOfferedFiles(paths, cap = MAX_FOLDER_BYTES) {
561
466
  const offered = [];
562
467
  const skipped = [];
@@ -585,15 +490,12 @@ export async function readOfferedFiles(paths, cap = MAX_FOLDER_BYTES) {
585
490
  }
586
491
  // `proof export <source-bundle.json> --attestations <f> --key <f> [--out <out>]`
587
492
  //
588
- // Compose a signed, offline-verifiable sanning.evidence.export/v2 artifact from a
589
- // source sanning.anchor.trace/v3 bundle + operator attestation records + an exporter
590
- // Ed25519 key (evidence-export.md §5, composer). Writes the export to `--out <out>`
591
- // (or stdout when omitted, so it can be piped straight into `proof verify`).
493
+ // Compose a signed, offline-verifiable sanning.evidence.export/v2 artifact
494
+ // (evidence-export.md §5). Writes to `--out`, or stdout so it pipes into
495
+ // `proof verify`.
592
496
  //
593
- // Exit codes: 0 composed OK · 1 the recomputed export is FAILED (a bad source or
594
- // forged/mis-bound attestation — the composer refuses to emit a green artifact
595
- // over failing evidence) · 2 usage / malformed input (missing flag, unreadable
596
- // or non-JSON input, bad exporter key, malformed attestation record).
497
+ // Exit codes: 0 composed; 1 the recomputed export is `failed` (the composer never
498
+ // emits a green artifact over failing evidence); 2 usage or malformed input.
597
499
  async function runExport(rest, io, sink) {
598
500
  if (rest[0] === "--help" || rest[0] === "-h" || rest[0] === "help") {
599
501
  usageExport(io);
@@ -658,9 +560,7 @@ async function runExport(rest, io, sink) {
658
560
  opts.generatedAt = v;
659
561
  }
660
562
  else if (a.startsWith("-")) {
661
- // A flag this verb does not take is a usage error, never a path: `-o`
662
- // was a short form of `--out` until 0.13.0, and read as a path it would
663
- // send the export to stdout while the caller waits for a file.
563
+ // An unknown flag is a usage error, never a path.
664
564
  io.err(`export: unknown option ${a}`);
665
565
  return usageExport(io);
666
566
  }
@@ -683,30 +583,28 @@ async function runExport(rest, io, sink) {
683
583
  }
684
584
  const sourceBundle = await readJson(bundlePath, io);
685
585
  if (sourceBundle === undefined)
686
- return EXIT_MALFORMED;
586
+ return EXIT_COULD_NOT_RUN;
687
587
  const attestationsRaw = await readJson(attestationsPath, io);
688
588
  if (attestationsRaw === undefined)
689
- return EXIT_MALFORMED;
589
+ return EXIT_COULD_NOT_RUN;
690
590
  const attestations = coerceAttestations(attestationsRaw);
691
591
  if (attestations === undefined) {
692
592
  io.err(`export: ${attestationsPath} must be a JSON array of attestation records ` +
693
593
  `(or an object with an "attestations" array)`);
694
- return EXIT_MALFORMED;
594
+ return EXIT_COULD_NOT_RUN;
695
595
  }
696
596
  const privateKey = await readExporterKey(keyPath, io);
697
597
  if (privateKey === undefined)
698
- return EXIT_MALFORMED;
598
+ return EXIT_COULD_NOT_RUN;
699
599
  let composed;
700
600
  try {
701
601
  composed = await composeExport(sourceBundle, attestations, { privateKey }, opts);
702
602
  }
703
603
  catch (e) {
704
- io.err(`export: cannot compose — ${e instanceof Error ? e.message : String(e)}`);
705
- return EXIT_MALFORMED;
604
+ io.err(`export: cannot compose: ${e instanceof Error ? e.message : String(e)}`);
605
+ return EXIT_COULD_NOT_RUN;
706
606
  }
707
- // The composer refuses to hand back a green artifact over failing evidence: a
708
- // FAILED recompute (bad source / forged / mis-bound attestation) exits 1 and
709
- // still writes the artifact so the caller can inspect it.
607
+ // A `failed` recompute exits 1 and still writes the artifact for inspection.
710
608
  const serialized = JSON.stringify(composed.bundle, null, 2) + "\n";
711
609
  if (sink) {
712
610
  sink.data = {
@@ -715,8 +613,7 @@ async function runExport(rest, io, sink) {
715
613
  source_bundle_hash: composed.sourceBundleHash,
716
614
  attestations: attestations.length,
717
615
  attestations_bound: composed.boundAttestations,
718
- // Without --out the artifact rides inside this object, since stdout
719
- // carries the object and not the artifact.
616
+ // Without --out, stdout carries this object, so the artifact rides inside it.
720
617
  export: outPath ? null : composed.bundle,
721
618
  };
722
619
  }
@@ -726,7 +623,7 @@ async function runExport(rest, io, sink) {
726
623
  }
727
624
  catch (e) {
728
625
  io.err(`export: cannot write ${outPath}: ${e instanceof Error ? e.message : String(e)}`);
729
- return EXIT_MALFORMED;
626
+ return EXIT_COULD_NOT_RUN;
730
627
  }
731
628
  io.out(`${paint("exported", composed.status === "failed" ? R : G)} ${outPath} ` +
732
629
  `status ${composed.status} ` +
@@ -759,10 +656,9 @@ function usageExport(io) {
759
656
  io.out(line);
760
657
  io.out("");
761
658
  io.out("export: 1 means the recomputed export is failed; it is still written.");
762
- return EXIT_MALFORMED;
659
+ return EXIT_COULD_NOT_RUN;
763
660
  }
764
- // Read + parse a JSON file; report a clear error and return undefined on any
765
- // failure (mirrors the verify path's read/parse error handling → exit 2).
661
+ // Read and parse a JSON file, or report the error and return undefined (exit 4).
766
662
  async function readJson(path, io) {
767
663
  let raw;
768
664
  try {
@@ -784,8 +680,7 @@ async function readJson(path, io) {
784
680
  return undefined;
785
681
  }
786
682
  }
787
- // Accept either a bare array of attestation records or an object wrapping one
788
- // under `attestations`. Returns undefined for anything else.
683
+ // A bare array of attestation records, or one under `attestations`.
789
684
  function coerceAttestations(raw) {
790
685
  if (Array.isArray(raw))
791
686
  return raw;
@@ -796,9 +691,8 @@ function coerceAttestations(raw) {
796
691
  }
797
692
  return undefined;
798
693
  }
799
- // Read the exporter Ed25519 key: a file that is either a bare 32-byte hex seed
800
- // (whitespace-trimmed) or a JSON object carrying it under `privateKey` / `seed` /
801
- // `private_key`. Validates the 64-hex-char (32-byte) shape.
694
+ // The exporter Ed25519 key: a bare 32-byte hex seed, or a JSON object carrying
695
+ // it under `privateKey`, `seed` or `private_key`.
802
696
  async function readExporterKey(path, io) {
803
697
  let raw;
804
698
  try {
@@ -830,14 +724,10 @@ async function readExporterKey(path, io) {
830
724
  }
831
725
  return seed.toLowerCase();
832
726
  }
833
- // The suffix the directory form reads, matched exactly — lowercase, which is
834
- // what `materializeBundle` writes. Anything else is skipped and NAMED rather
835
- // than guessed at, so a `.JSON` or extensionless file shows up as a note
836
- // instead of a mysteriously undisclosed event.
727
+ // Matched exactly, lowercase. Anything else is skipped and named in a note.
837
728
  const LOG_SUFFIX = ".json";
838
- // Which shape was passed is decided by `stat`, never by the extension: a pack's
839
- // log directory can be called anything, and `logs.json` is a legal directory
840
- // name. `stat` follows symlinks, so a symlinked logs directory works.
729
+ // The shape is decided by `stat`, never by extension. `stat` follows symlinks,
730
+ // so a symlinked logs directory works.
841
731
  async function loadLogs(logsPath, io) {
842
732
  let isDirectory;
843
733
  try {
@@ -849,7 +739,7 @@ async function loadLogs(logsPath, io) {
849
739
  }
850
740
  return isDirectory ? loadLogsDirectory(logsPath, io) : loadLogsMap(logsPath, io);
851
741
  }
852
- // The JSON-map form, unchanged: `{ event_id: <hex | utf8 text> }`.
742
+ // The JSON-map form: `{ event_id: <hex | utf8 text> }`.
853
743
  async function loadLogsMap(logsPath, io) {
854
744
  let logsRaw;
855
745
  try {
@@ -865,8 +755,7 @@ async function loadLogsMap(logsPath, io) {
865
755
  }
866
756
  catch (e) {
867
757
  if (e instanceof DuplicateKeyError) {
868
- // One event id named twice hands the reader two contents for one
869
- // record, and the last one silently won (proof#144).
758
+ // A repeated event id would hand the reader two contents for one record.
870
759
  io.err(`--logs ${logsPath} ${NOT_I_JSON}: ${e.message}`);
871
760
  return undefined;
872
761
  }
@@ -890,8 +779,7 @@ async function loadLogsMap(logsPath, io) {
890
779
  content.set(k, decodeLogValue(v));
891
780
  suppliedIds.push(k);
892
781
  }
893
- // Named, every name, in the one sentence the Python command writes (A42): a
894
- // value that is not a string is never coerced and never dropped unnamed.
782
+ // Every non-string value is named, never coerced or dropped.
895
783
  const notes = notStrings.length === 0
896
784
  ? []
897
785
  : [
@@ -900,52 +788,23 @@ async function loadLogsMap(logsPath, io) {
900
788
  ];
901
789
  return { content, suppliedIds, notes };
902
790
  }
903
- // The directory form, read by the one rule both kernels apply
904
- // (evidence-bundle.md §5.1.4, A42). The twin is `_load_logs_directory` in
905
- // `src/sanning_proof/cli.py`, and `cross-kernel/logs_folder_cli_leg.py` holds
906
- // the two commands to the same stdout, stderr and exit code.
907
- //
908
- // A regular file named `<event_id>.json` is that record's disclosed bytes, and
909
- // nothing else in the folder is read. None of the following is a verification
910
- // failure, because an absent or unusable disclosure is UNDISCLOSED and absence
911
- // is not evidence of tampering (evidence-bundle.md §5.1), and every one of them
912
- // is NAMED in a note, every name, so no file is dropped without being named:
913
- //
914
- // - a file whose name is not a known event id → loaded, binds to nothing,
915
- // named in a post-verify note
916
- // - an event with no corresponding file → undetermined (`~`), noted
917
- // - an empty directory → valid; discloses nothing,
918
- // noted, exit code unchanged
919
- // - a subdirectory → never entered, noted (the
920
- // "you pointed at the pack root, not its logs/" case)
921
- // - a symbolic link → never followed, whatever it
922
- // points at, noted; `case` never follows one under a pack's `logs/` and
923
- // `--files` never follows one in a folder (A40), so a link named
924
- // `<event_id>.json` discloses nothing here either
925
- // - anything that is not a regular file → never opened, noted
926
- // - anything else not named `<event_id>.json` → never read, noted
927
- //
928
- // The Python command read every regular file by its STEM until 0.17.0, so a
929
- // forged `<id>.txt` beside a genuine `<id>.json` failed the pack there and was
930
- // never read here, and the forged-`.json` direction read clean there and
931
- // failed here: whoever assembled the folder chose the verdict.
791
+ // The directory form (evidence-bundle.md §5.1.4; Python's twin is
792
+ // `_load_logs_directory`). Only a regular file named `<event_id>.json` is read:
793
+ // links are never followed and subdirectories never entered. Everything else,
794
+ // and every event with no file, is named in a note, because an absent or
795
+ // unusable disclosure is undisclosed, not a failure (§5.1).
932
796
  //
933
- // The hard stops (exit 2): a `<event_id>.json` file that cannot be read (we were
934
- // pointed at it, so a permissions error must not read as minimal disclosure),
935
- // more than MAX_WALK_ENTRIES names, a name that is not valid UTF-8 (escaping it
936
- // would let two names read as one), and `<event_id>.json` files holding more
937
- // than MAX_LOGS_BYTES in all.
797
+ // Hard stops (exit 4): an unreadable `<event_id>.json` (a permissions error must
798
+ // not read as minimal disclosure), more than MAX_WALK_ENTRIES names, a name that
799
+ // is not valid UTF-8, or more than MAX_LOGS_BYTES in all.
938
800
  export const MAX_LOGS_BYTES = MAX_ZIP_BYTES;
939
801
  export class LogsTooLarge extends Error {
940
802
  }
941
- // A `<event_id>.json` file's bytes. Opened without following a link and without
942
- // blocking, then checked again, so a file swapped for a link or a pipe after the
943
- // folder was listed is refused rather than followed or waited on; and read no
944
- // further than `budget` allows. Two checks, and each is reachable without the
945
- // other: the size `fstat` reports refuses a sparse file before a byte is read,
946
- // and the count of bytes read refuses a file that reports less than it holds
947
- // (a file in /proc reports 0) or grows while it is read. `opener` is for the
948
- // tests, which must reach the second check with the first one passing.
803
+ // A `<event_id>.json` file's bytes, opened without following a link or
804
+ // blocking and then rechecked, so a swapped-in link or pipe is refused. Two
805
+ // independent size checks: `fstat` refuses a sparse file before reading, and
806
+ // the byte count refuses a file that under-reports (as /proc does) or grows.
807
+ // `opener` lets tests reach the second check.
949
808
  export async function readLogFile(path, budget, opener = open) {
950
809
  const flags = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0);
951
810
  const handle = await opener(path, flags);
@@ -975,8 +834,7 @@ export async function readLogFile(path, budget, opener = open) {
975
834
  await handle.close();
976
835
  }
977
836
  }
978
- // `limits` is for the tests, which cannot write a gigabyte or fifty thousand
979
- // names per case; the command always passes the defaults.
837
+ // `limits` is for tests; the command always passes the defaults.
980
838
  export async function loadLogsDirectory(dirPath, io, limits = { bytes: MAX_LOGS_BYTES, entries: MAX_WALK_ENTRIES }) {
981
839
  let raws;
982
840
  try {
@@ -1011,7 +869,7 @@ export async function loadLogsDirectory(dirPath, io, limits = { bytes: MAX_LOGS_
1011
869
  const p = childPath(dirPath, name);
1012
870
  let s;
1013
871
  try {
1014
- // `lstat`, never `stat`: a link in the folder is never followed.
872
+ // `lstat`: a link in the folder is never followed.
1015
873
  s = await lstat(p);
1016
874
  }
1017
875
  catch {
@@ -1030,15 +888,13 @@ export async function loadLogsDirectory(dirPath, io, limits = { bytes: MAX_LOGS_
1030
888
  notRegular.push(name);
1031
889
  continue;
1032
890
  }
1033
- // Not named `<event_id>.json`, including a file named exactly `.json`,
1034
- // whose event id would be the empty string.
891
+ // Includes a file named exactly `.json`, whose event id would be empty.
1035
892
  if (!name.endsWith(LOG_SUFFIX) || name.length === LOG_SUFFIX.length) {
1036
893
  notNamed.push(name);
1037
894
  continue;
1038
895
  }
1039
896
  const eventId = name.slice(0, -LOG_SUFFIX.length);
1040
897
  try {
1041
- // Bytes, exactly as they sit on disk.
1042
898
  content.set(eventId, await readLogFile(p, budget));
1043
899
  }
1044
900
  catch (e) {
@@ -1056,7 +912,7 @@ export async function loadLogsDirectory(dirPath, io, limits = { bytes: MAX_LOGS_
1056
912
  const notes = [];
1057
913
  if (dirs.length > 0) {
1058
914
  notes.push(`--logs ${dirPath}: skipped ${count(dirs.length, "subdirectory", "subdirectories")} ` +
1059
- `(${quoted(dirs)}) — the directory form reads <event_id>${LOG_SUFFIX} files ` +
915
+ `(${quoted(dirs)}); the directory form reads <event_id>${LOG_SUFFIX} files ` +
1060
916
  `and does not recurse`);
1061
917
  }
1062
918
  if (links.length > 0) {
@@ -1075,22 +931,18 @@ export async function loadLogsDirectory(dirPath, io, limits = { bytes: MAX_LOGS_
1075
931
  `not named <event_id>${LOG_SUFFIX} (${quoted(notNamed)})`);
1076
932
  }
1077
933
  if (suppliedIds.length === 0) {
1078
- notes.push(`--logs ${dirPath} disclosed nothing (no <event_id>${LOG_SUFFIX} files) — every event ` +
1079
- `stays undisclosed, which is undetermined, not failed`);
934
+ notes.push(`--logs ${dirPath} disclosed nothing (no <event_id>${LOG_SUFFIX} files); every event ` +
935
+ `stays undisclosed, which is not checked, not failed`);
1080
936
  }
1081
937
  return { content, suppliedIds, notes };
1082
938
  }
1083
- // What the side input covered, reported once the bundle's own event ids are
1084
- // known. Both directions are ADVISORY — neither moves the verdict or the exit
1085
- // code — and both exist because the silent version of each has already cost
1086
- // somebody an afternoon:
939
+ // What the side input covered, once the bundle's event ids are known. Advisory
940
+ // only:
1087
941
  //
1088
- // - an entry matching no event binds to nothing. A typo'd id, or a directory
1089
- // scoped to a different bundle, otherwise looks identical to minimal
1090
- // disclosure.
1091
- // - an event whose content stayed undetermined disclosed no usable bytes.
1092
- // That is UNDISCLOSED, never a failure (evidence-bundle.md §5.1): absence
1093
- // of a disclosure is not evidence of tampering.
942
+ // - an entry matching no event binds to nothing (a typo or the wrong
943
+ // directory otherwise looks like minimal disclosure).
944
+ // - an event whose content stayed undetermined disclosed no usable bytes:
945
+ // undisclosed, never a failure (evidence-bundle.md §5.1).
1094
946
  function reportLogsCoverage(logs, result, io) {
1095
947
  const eventIds = new Set(result.events.map((e) => e.eventId));
1096
948
  const unmatched = logs.suppliedIds.filter((id) => !eventIds.has(id)).sort();
@@ -1098,12 +950,12 @@ function reportLogsCoverage(logs, result, io) {
1098
950
  io.err(`note: --logs supplied ${count(unmatched.length, "entry", "entries")} matching no event ` +
1099
951
  `in the bundle, ignored: ${quoted(unmatched)}`);
1100
952
  }
1101
- const undetermined = result.events.filter((e) => e.contentOk === null);
1102
- if (undetermined.length > 0) {
1103
- io.err(`note: ${undetermined.length} of ${result.events.length} event(s) have undetermined ` +
1104
- `content: no bytes disclosed, or no committed content_hash (message_hash for a ` +
953
+ const unchecked = result.events.filter((e) => e.contentOk === null);
954
+ if (unchecked.length > 0) {
955
+ io.err(`note: ${unchecked.length} of ${result.events.length} event(s) have content that ` +
956
+ `was not checked: no bytes disclosed, or no committed content_hash (message_hash for a ` +
1105
957
  `hand-off record) to bind them to. ` +
1106
- `Undisclosed, not failed: ${sample(undetermined.map((e) => e.eventId))}`);
958
+ `Undisclosed, not failed: ${sample(unchecked.map((e) => e.eventId))}`);
1107
959
  }
1108
960
  }
1109
961
  function errMsg(e) {
@@ -1112,42 +964,23 @@ function errMsg(e) {
1112
964
  function count(n, one, many) {
1113
965
  return `${n} ${n === 1 ? one : many}`;
1114
966
  }
1115
- // Every name, quoted. Every one, not the first few: a file left out of a note
1116
- // is a file dropped without being named.
967
+ // Every name, quoted: a file left out of a note is a file dropped unnamed.
1117
968
  function quoted(names) {
1118
969
  return names.map((n) => JSON.stringify(n)).join(", ");
1119
970
  }
1120
- // First few names, quoted, with a tail count — enough to recognise the mistake
1121
- // without pasting a whole directory into the terminal.
971
+ // The first few names, quoted, with a count of the rest.
1122
972
  function sample(names, max = 3) {
1123
973
  const shown = names.slice(0, max).map((n) => JSON.stringify(n)).join(", ");
1124
974
  return names.length > max ? `${shown}, +${names.length - max} more` : shown;
1125
975
  }
1126
- // The --logs MAP lane decides each disclosed value's encoding: a clean,
1127
- // even-length hex string is hex bytes; anything else is utf8 text. (The
1128
- // verifier itself only distinguishes "string ⇒ hex" from "Uint8Array ⇒ raw
1129
- // bytes", so the human-facing encoding choice is made here. The DIRECTORY lane
1130
- // never reaches this: its bytes are already bytes.)
1131
- //
1132
- // EITHER CASE, through the one charset rule (proof#146). This test was
1133
- // `/^[0-9a-f]+$/`, lower case only, and it was the last copy of the hex rule
1134
- // in either kernel that answered differently from the others: the Python CLI
1135
- // passes every string to the kernel, and the kernel decodes it with the
1136
- // case-open `hexToBytes` on BOTH sides. So a disclosure map written in
1137
- // upper-case hex was read as 4 bytes of evidence there and as 8 bytes of TEXT
1138
- // here, and this command told the auditor their genuine, correctly disclosed
1139
- // evidence had FAILED: `proof verify --logs map.json` exited 3 in Python and
1140
- // 1 here, on one pack and one map. Telling an auditor their evidence is forged
1141
- // is the worst direction to be wrong in, which is the argument the Python
1142
- // twin's own docstring already makes about this exact lane.
1143
- //
1144
- // Pinned by `cross-kernel/run_cli.sh`'s `logs/hex-map-upper`, beside the
1145
- // lowercase lane that existed before and only ever exercised the one spelling
1146
- // the two commands happened to agree on.
976
+ // The map form's encoding: a non-empty, even-length hex string (either case,
977
+ // through the shared charset rule) is hex; anything else is UTF-8 text. Python
978
+ // decodes the same way, so a map in upper-case hex is evidence in both
979
+ // kernels, never text in one and a failure in the other.
1147
980
  function decodeLogValue(v) {
1148
981
  if (v.length > 0 && v.length % 2 === 0 && isHexField(v, v.length))
1149
- return v; // hex passthrough
1150
- return utf8(v); // raw utf8 bytes
982
+ return v;
983
+ return utf8(v);
1151
984
  }
1152
985
  function printUsage(io) {
1153
986
  io.out("Usage: sanning-proof <command> [args]");
@@ -1164,10 +997,10 @@ function printUsage(io) {
1164
997
  io.out("");
1165
998
  io.out("Verify a sanning.evidence/v1 bundle (body sanning.anchor.trace/v3,");
1166
999
  io.out("sanning.stamp.trace/v1, sanning.stamp.evidence/v2 or the attested export");
1167
- io.out("sanning.evidence.export/v2) or a sanning.agent.proof/v1 inclusion bundle,");
1000
+ io.out("sanning.evidence.export/v2),");
1168
1001
  io.out("fully offline. Pass --gateways <url,url> to also re-fetch each");
1169
1002
  io.out("checkpoint and stamp on-chain. Only arweave witnesses are re-fetched;");
1170
- io.out("a witness of any other kind is not checked and the run exits 3.");
1003
+ io.out("a witness of any other kind is not checked, and the witness line says so.");
1171
1004
  io.out("");
1172
1005
  io.out("--json Print the verdict, the causes and the exit code as");
1173
1006
  io.out(" JSON, for a script, instead of the report.");
@@ -1191,9 +1024,9 @@ function printUsage(io) {
1191
1024
  io.out("--logs <path> Bind disclosed raw logs to each event's committed");
1192
1025
  io.out(" content_hash, or message_hash for a hand-off record.");
1193
1026
  io.out(" Either shape works, and which one you");
1194
- io.out(" passed is decided by stat(), not by the extension:");
1027
+ io.out(" passed is determined by stat(), not by the extension:");
1195
1028
  io.out("");
1196
- io.out(" a DIRECTORY of <event_id>.json files — the file's");
1029
+ io.out(" a DIRECTORY of <event_id>.json files; the file's");
1197
1030
  io.out(" raw bytes ARE the content, nothing is decoded.");
1198
1031
  io.out(" This is a materialized pack's logs/ directory:");
1199
1032
  io.out(" sanning-proof verify <pack>/bundle.json --logs <pack>/logs");
@@ -1207,36 +1040,27 @@ function printUsage(io) {
1207
1040
  io.out("");
1208
1041
  io.out(" In-body events[].content is verified automatically");
1209
1042
  io.out(" and takes precedence. An event with no disclosed");
1210
- io.out(" bytes is undetermined (~), never a failure.");
1043
+ io.out(" bytes is not checked, never a failure.");
1211
1044
  io.out("");
1212
- io.out("The headline is the PACK verdict, the same one the verify page prints:");
1213
- io.out("a record nobody hashed leaves the pack INCOMPLETE even when the signed");
1214
- io.out("bundle itself is verified. Both verdicts are printed when they differ.");
1045
+ io.out("The headline is the pack verdict, the same one the verify page prints, read");
1046
+ io.out("off six claims: signed, unchanged, witnessed, complete, agreed and follows the");
1047
+ io.out("rules. A record nobody hashed leaves the pack incomplete even when the signed");
1048
+ io.out("bundle itself verifies. The report prints one line for each claim that is");
1049
+ io.out("disproven or not proven, and one next action.");
1215
1050
  io.out("");
1216
1051
  for (const line of EXIT_CODES_HELP.split("\n"))
1217
1052
  io.out(line);
1218
1053
  io.out("");
1219
- io.out("verify: an unreachable gateway reads partial, and a pack with a record");
1220
- io.out("nobody hashed reads incomplete; both exit 3, and neither is a failure.");
1054
+ io.out("verify: a pack with a record nobody hashed reads incomplete and exits 2, which");
1055
+ io.out("is not a failure. A gateway that cannot be reached moves nothing: the witness");
1056
+ io.out("line says so.");
1221
1057
  }
1222
1058
  /**
1223
- * The disclosure account this command can honestly make, from what the VERIFIER
1224
- * determined rather than from what the loader happened to read.
1225
- *
1226
- * `accountDisclosure` is offered by `grading.ts` as a convenience and is
1227
- * explicitly "NOT THE ONLY WAY TO BUILD AN ACCOUNT". It reads the side map
1228
- * only, and this command has a second disclosure lane the side map cannot see:
1229
- * `events[].content`, carried INSIDE the bundle and bound by the verifier
1230
- * without any `--logs` at all. Accounting off the side map would file every
1231
- * in-body disclosure as `missing` and print a shortfall the pack does not have,
1232
- * which is the shape of manufactured accusation `grading.ts` keeps warning
1233
- * about.
1234
- *
1235
- * So `present` is "the verifier reached a determination for this record's
1236
- * content" and `missing` is "it could not", which is the same tri-state that
1237
- * decides the verdict, so the causes printed can never contradict the headline.
1238
- * `extra` still comes from the side input, because bytes that bound to nothing
1239
- * are a fact about the pack that only the loader knows.
1059
+ * The disclosure account, from what the verifier determined rather than what
1060
+ * the loader read. `accountDisclosure` sees only the side map and would file
1061
+ * every in-body `events[].content` disclosure as missing. Here `present` and
1062
+ * `missing` follow the same tri-state as the verdict, so the causes printed
1063
+ * cannot contradict the headline; `extra` comes from the side input.
1240
1064
  */
1241
1065
  function cliDisclosureAccount(result, logs) {
1242
1066
  const expected = result.events.map((e) => e.eventId);
@@ -1245,398 +1069,309 @@ function cliDisclosureAccount(result, logs) {
1245
1069
  expected,
1246
1070
  present: result.events.filter((e) => e.contentOk !== null).map((e) => e.eventId),
1247
1071
  missing: result.events.filter((e) => e.contentOk === null).map((e) => e.eventId),
1248
- // The loader hard-stops (exit 2) on a file it selected and could not read,
1249
- // and NAMES everything it skipped as a note, so nothing reaches here as an
1250
- // unusable disclosure bound to a named record.
1072
+ // The loader hard-stops on an unreadable selected file and names every
1073
+ // skip, so nothing unusable reaches here.
1251
1074
  unreadable: [],
1252
1075
  extra: (logs?.suppliedIds ?? []).filter((id) => !known.has(id)),
1253
1076
  };
1254
1077
  }
1255
- function reportEvidence(result, path, gateways, io, chains, links, parsed, logs, sink) {
1256
- if (result.status === "malformed") {
1257
- if (sink)
1258
- sink.data = verifyJson(result, null, null, null);
1259
- io.err(paint("MALFORMED", R) + ` ${path}`);
1260
- for (const e of result.errors)
1261
- io.err(` - ${e}`);
1262
- return EXIT_MALFORMED;
1263
- }
1264
- io.out(paint("Evidence bundle", B) + ` ${path}`);
1265
- io.out(` body_type ${result.bodyType ?? "<unknown>"}`);
1266
- io.out(` wrapper sig ${mark(result.signatureOk)} body_hash ${mark(result.bodyHashOk)} spec ${mark(result.specVersionOk)}`);
1078
+ // The chain reading could not be produced, which is not a reading of nothing.
1079
+ function chainsUnreadableText(reason) {
1080
+ return ("this kernel could not read the event chains in this bundle " +
1081
+ `(${reason}), so the chain evidence is unknown here. That is ` +
1082
+ "not the same as there being none to read.");
1083
+ }
1084
+ /**
1085
+ * One note per record-layer finding (agent-events.md section 10.1), in the order each
1086
+ * is first met, counted over the records that carry it, its kind the finding's name. A
1087
+ * finding never moves a verdict.
1088
+ */
1089
+ function findingNotes(readings) {
1090
+ const counts = new Map();
1091
+ for (const reading of readings) {
1092
+ if (reading === null)
1093
+ continue;
1094
+ for (const finding of new Set(reading.findings)) {
1095
+ counts.set(finding, (counts.get(finding) ?? 0) + 1);
1096
+ }
1097
+ }
1098
+ return [...counts].map(([f, n]) => note(f, renderFindingNote(f, n)));
1099
+ }
1100
+ /** The witness line without its `Witness:` label, which the person layer prints. */
1101
+ function witnessText(line) {
1102
+ return renderWitnessLine(line).slice("Witness: ".length);
1103
+ }
1104
+ /**
1105
+ * The witness line of one pack read by `verify`: the lookup `verifyEvidenceBundle` ran
1106
+ * with the gateways, in the words the case reading uses.
1107
+ */
1108
+ function verifyWitness(result, gateways) {
1109
+ // No lookup ran: none was asked for, or the run read no witness to look up (a wrapper that
1110
+ // does not hold, a body this kernel does not read).
1111
+ if (gateways.length === 0 || result.checkpoints.length + (result.stamps ?? []).length === 0) {
1112
+ return witnessLine({ checked: false, gateways: [], packs: [], verdict: "not_checked" });
1113
+ }
1114
+ const { row, answered } = onChainRow("", result);
1115
+ return witnessLine({
1116
+ answered: gateways.filter((g) => answered.has(g)),
1117
+ checked: true,
1118
+ gateways: [...gateways],
1119
+ packs: [row],
1120
+ verdict: onChainOutcome([row]),
1121
+ });
1122
+ }
1123
+ function reportEvidence(result, path, gateways, io, chains, links, parsed, logs, sink, bare = null) {
1124
+ // What the pack says it holds, against what it does (evidence-bundle.md section
1125
+ // 5.4). A pack with no declaration leaves `complete` out of scope, and a note says
1126
+ // so. A pack that carries a declaration this reader cannot read is incomplete: the
1127
+ // holder chose that field, so unknown is the honest answer. A v3 chain claim is
1128
+ // checked against this command's links (rule 11); unreadable links read the claim
1129
+ // unknown.
1130
+ const declaration = checkDeclaration(parsed, logs?.content, links);
1131
+ const grade = gradePack(result, cliDisclosureAccount(result, logs), completenessNotes(parsed), declaration);
1132
+ // Sandbox evidence: read from the signed envelopes the pack holds. A label, never a
1133
+ // verdict: a sandbox pack that verifies exits 3.
1134
+ const marker = environmentMarker([parsed]);
1135
+ const agents = result.events.filter((ev) => ev.agent).map((ev) => ev.agent);
1136
+ const inScope = [];
1137
+ const notInScope = { agreed: "no_handoff" };
1138
+ if (declaration.present)
1139
+ inScope.push("complete");
1140
+ else
1141
+ notInScope.complete = "no_declaration";
1142
+ if (agents.length > 0)
1143
+ inScope.push("follows_rules");
1144
+ else
1145
+ notInScope.follows_rules = "no_agent_records";
1146
+ let problems = [...grade.problems];
1147
+ if (result.unreadable === true) {
1148
+ // A pack this reader cannot read says nothing about the bytes beside it: its own
1149
+ // problem is the one line, as `case` reads it.
1150
+ problems = bundleProblems(result, 0);
1151
+ }
1152
+ else if (bare !== null && grade.verdict === "failed") {
1153
+ // No disproof rests on bytes nobody signed: `bare` is the same pack read without the
1154
+ // bytes handed in beside it, as `case` reads it.
1155
+ const signed = gradePack(bare, cliDisclosureAccount(bare, undefined), completenessNotes(parsed), checkDeclaration(parsed, undefined, links));
1156
+ problems = restOnSigned(problems, signed.problems);
1157
+ }
1158
+ const eventIds = result.events.map((ev) => ev.eventId);
1159
+ const claims = claimsOf(problems, inScope, {
1160
+ notInScope,
1161
+ labels: (_pack, record) => [null, record !== null ? (eventIds[record] ?? null) : null],
1162
+ });
1163
+ const verdict = verdictOf(claims);
1164
+ const exit = exitCodeFor(verdict.status, marker);
1165
+ const unreadable = "unreadable" in chains ? chains.unreadable : null;
1166
+ const summaries = "unreadable" in chains ? [] : chains.chains;
1167
+ const findings = "unreadable" in chains ? [] : chains.findings;
1168
+ const notes = (result.notes ?? []).map((n) => note("co_signatures_present", n));
1169
+ if (!declaration.present && result.unreadable !== true) {
1170
+ notes.push(note("pack_undeclared", renderDeclarationSummary(declaration)));
1171
+ }
1172
+ notes.push(...declaration.notes.map((n) => note(n.kind, renderNote(n))));
1173
+ if (unreadable !== null)
1174
+ notes.push(note("chains_unreadable", chainsUnreadableText(unreadable)));
1175
+ notes.push(...findings.map((f) => note(f.kind, renderChainFinding(f, "this bundle"))));
1176
+ notes.push(...findingNotes(agents));
1177
+ const witness = verifyWitness(result, gateways);
1178
+ const person = renderPerson(verdict, claims, orderedNotes(notes), marker, witnessText(witness), witness.state);
1179
+ for (const line of personText(person))
1180
+ io.out(line);
1181
+ io.out("");
1182
+ io.out(`Evidence bundle ${path}`);
1183
+ io.out(` body_type ${result.bodyType || "<unknown>"}`);
1184
+ // Both verdicts, whenever they differ: a pack can be incomplete over an impeccable
1185
+ // bundle, and the headline alone would read it as broken.
1186
+ if (verdict.status !== result.status) {
1187
+ io.out(` bundle the signed bundle is "${result.status}"; the pack ` +
1188
+ `around it is "${verdict.status}"`);
1189
+ }
1267
1190
  if (result.checkpoints.length > 0) {
1268
1191
  io.out("");
1269
- io.out(paint(" Checkpoints", DIM));
1192
+ io.out(" Checkpoints");
1270
1193
  for (const cp of result.checkpoints) {
1271
- const onChain = cp.onChainOk === null ? "" : ` on-chain ${mark(cp.onChainOk)}`;
1272
- io.out(` ${mark(cp.ok)} ${shortTx(cp.checkpointRef)} envelope ${mark(cp.envelopeOk)} root ${mark(cp.merkleRootOk)}${onChain}`);
1194
+ io.out(` ${cp.ok ? "OK" : "XX"} ${cp.checkpointRef || "<no ref>"}`);
1273
1195
  for (const e of cp.errors)
1274
- io.out(paint(` - ${e}`, R));
1275
- }
1276
- }
1277
- // Stamps (evidence-bundle.md §5.2 / §5.3). Printed rather than summarised,
1278
- // because for a stamp body the WITNESS is the entire independent part
1279
- // (§5.2.4) — and until now this reporter showed nothing about it at all, so a
1280
- // §5.3 pack printed a rollup line claiming an on-chain scope with no visible
1281
- // on-chain result anywhere above it.
1282
- //
1283
- // The on-chain column distinguishes all four states in the same three
1284
- // characters the checkpoint block uses, plus its absence: `✓` confirmed,
1285
- // `✗` mismatched, `~` asked and unreachable, and NO COLUMN AT ALL when
1286
- // nothing was checked. A reader must never have to infer "not checked" from
1287
- // a symbol that also means "could not be reached".
1288
- if (result.stamps && result.stamps.length > 0) {
1196
+ io.out(` - ${e}`);
1197
+ }
1198
+ }
1199
+ // The stamps. For a stamp body the witness is the entire independent part (section 5.2.4).
1200
+ // No on-chain column when nothing was checked: "not checked" must not be inferred
1201
+ // from a word that also means "we asked and nobody answered".
1202
+ const stamps = result.stamps ?? [];
1203
+ if (stamps.length > 0) {
1289
1204
  io.out("");
1290
- io.out(paint(" Stamps", DIM));
1291
- for (const st of result.stamps) {
1292
- const witness = st.anchorRef === null
1293
- ? paint("~ no witness", Y)
1294
- : `witness ${st.anchorRef.kind}:${shortTx(st.anchorRef.id)}`;
1295
- const onChain = st.onChain === null ? "" : ` on-chain ${mark(st.onChainOk)} (${st.onChain.rollup})`;
1296
- io.out(` ${mark(st.ok)} ${shortTx(st.stampRef)} bytes ${mark(st.stampRefOk && st.bodyOk)}` +
1297
- ` ranges ${mark(st.subtreesOk)} ${witness}${onChain}`);
1205
+ io.out(" Stamps");
1206
+ for (const st of stamps) {
1207
+ const witness = st.anchorRef === null ? "no witness" : `witness ${st.anchorRef.kind}:${st.anchorRef.id}`;
1208
+ const onChain = st.onChain === null ? "" : ` on-chain ${st.onChain.rollup}`;
1209
+ io.out(` ${st.ok ? "OK" : "XX"} ${st.stampRef || "<no stamp_ref>"} ` +
1210
+ `bytes ${st.stampRefOk && st.bodyOk ? "OK" : "XX"} ` +
1211
+ `ranges ${st.subtreesOk ? "OK" : "XX"} ${witness}${onChain}`);
1298
1212
  for (const e of st.errors)
1299
- io.out(paint(` - ${e}`, R));
1213
+ io.out(` - ${e}`);
1300
1214
  }
1301
1215
  }
1302
1216
  if (result.events.length > 0) {
1303
1217
  io.out("");
1304
- io.out(paint(" Events", DIM));
1218
+ io.out(" Events");
1305
1219
  for (const ev of result.events) {
1306
- // `~` is "not determined", and the words after it must say only that.
1307
- // This read "record withheld", which is a statement about the PRODUCER:
1308
- // it says the customer chose minimum disclosure and kept these bytes
1309
- // back, which is legitimate and expected. An event whose envelope was
1310
- // never an envelope produces the same `null` on this dimension, and
1311
- // there the sentence is simply untrue: the record may be sitting in the
1312
- // bundle, and nothing about the producer has been established either
1313
- // way. A renderer cannot tell the two apart from a tri-state, so it
1314
- // states what the verifier did and leaves WHY to the error lines below,
1315
- // which now name the malformed envelope instead of a signature check
1316
- // that never ran (proof#76).
1317
- const binding = ev.payloadBindingOk === null
1318
- ? paint("~ record not checked", Y)
1319
- : `record ${mark(ev.payloadBindingOk)}`;
1320
- // Show the content (logs) segment only when content was disclosed and
1321
- // evaluated (non-null) — undisclosed content is the default and would just
1322
- // be noise on every line.
1323
- const logs = ev.contentOk === null ? "" : ` logs ${mark(ev.contentOk)}`;
1324
- io.out(` ${mark(ev.ok)} ${shortId(ev.eventId)} envelope ${mark(ev.envelopeOk)} ${binding} inclusion ${mark(ev.inclusionOk)}${logs}`);
1220
+ io.out(` ${ev.ok ? "OK" : "XX"} ${ev.eventId || "<no event_id>"}`);
1325
1221
  for (const e of ev.errors)
1326
- io.out(paint(` - ${e}`, R));
1327
- }
1328
- }
1329
- reportChains(chains, io);
1330
- if (result.export) {
1331
- reportExport(result.export, io);
1332
- }
1333
- // What the pack SAYS it holds, against what it does (evidence-bundle.md
1334
- // §5.4). A pack that deviates from its own declaration FAILS here and exits
1335
- // 1, which is the point: every other check in this command is a check on a
1336
- // record that is present, so a trimmed hand-over passes all of them.
1337
- //
1338
- // A pack with NO declaration exits exactly as it did before §5.4. The check
1339
- // answers `partial` and the line below says so in words, and pack grading
1340
- // keeps its own verdict: every pack built before §5.4 carries no
1341
- // declaration, and re-grading them all as undetermined would be a change to
1342
- // their verdicts rather than a new check. A pack that DOES
1343
- // carry a declaration this reader cannot read is a different case and exits
1344
- // 3: the holder chose that field, so UNKNOWN is the honest answer.
1345
- //
1346
- // A v3 declaration that claims a chain is checked against this command's
1347
- // own chain links (rule 11, evidence-bundle.md 5.4.2 v1.36, A45). Links
1348
- // that could not be read are passed as none, which reads the claim UNKNOWN
1349
- // and never clean.
1350
- const declaration = checkDeclaration(parsed, logs?.content, links);
1351
- const grade = gradePack(result, cliDisclosureAccount(result, logs), completenessNotes(parsed), declaration);
1352
- // Sandbox evidence: read from the signed envelopes the pack holds.
1353
- const marker = environmentMarker([parsed]);
1354
- // A pack whose records are all signed `dev`, undetermined only because a
1355
- // simulated witness cannot be looked up, would grade `verified` with that
1356
- // witness accepted: it is sandbox evidence, exit 5, never 3.
1357
- const simulatedOnly = grade.verdict === "partial" &&
1358
- onlySimulatedUnsettled(result) &&
1359
- gradePack({ ...result, status: "verified" }, cliDisclosureAccount(result, logs), completenessNotes(parsed), declaration).verdict === "verified";
1360
- const exit = sandboxExit(mapGradeToExit(grade), marker, simulatedOnly);
1361
- io.out("");
1362
- printRollup(grade.verdict, result.status, result.assertedStatus, gateways, result.onChainChecked, io, marker, exit === EXIT_SANDBOX, simulatedOnlyRun(result, gateways));
1363
- io.out(paint(` declaration: ${renderDeclarationSummary(declaration)}`, declaration.verdict === "failed" ? R : declaration.verdict === "verified" ? DIM : Y));
1364
- for (const n of declaration.notes)
1365
- io.out(paint(` note: ${renderNote(n)}`, Y));
1222
+ io.out(` - ${e}`);
1223
+ // The agent reading, with the pack's other records held beside it. Its findings
1224
+ // are notes and move nothing; a reading that is not verified is also a cause
1225
+ // of the pack verdict below.
1226
+ if (ev.agent !== null)
1227
+ io.out(` agent ${renderAgentReading(ev.agent)}`);
1228
+ }
1229
+ }
1366
1230
  for (const e of result.errors)
1367
- io.out(paint(` note: ${e}`, DIM));
1368
- // Every reason the pack is not verified, in the kernel's own words. An
1369
- // `incomplete` with no cause is a surface saying "unknown" and unable to say
1370
- // why, which `grading.ts` calls out by name as a defect of its own.
1371
- for (const c of grade.causes)
1372
- io.out(paint(` - ${renderCause(c, "the pack")}`, Y));
1373
- // The fraction describes the PACK, not the disclosure. It used to divide by
1374
- // the number of records that happened to disclose bytes, so a pack that
1375
- // disclosed two of three records printed `logs: 2/2`, a full score for a
1376
- // partial hand-over, and the one line a reader skims for exactly this.
1377
- if (result.events.length > 0) {
1378
- io.out(paint(` logs: ${grade.recordsVerified}/${result.events.length} record(s) content-verified`, DIM));
1231
+ io.err(` - ${e}`);
1232
+ // The chain block: the chains walked (their findings are notes, printed above); no
1233
+ // chain to follow; or a reading that could not be produced, printed as itself.
1234
+ io.out("");
1235
+ if (unreadable !== null) {
1236
+ io.out(` Chain ${chainsUnreadableText(unreadable)}`);
1379
1237
  }
1380
- if (sink)
1381
- sink.data = verifyJson(result, grade, declaration, chains, marker);
1382
- return exit;
1383
- }
1384
- /**
1385
- * What `verify --json` prints for an evidence bundle, the same keys and the
1386
- * same words as `_verify_json` in the Python twin. `grade`, `declaration` and
1387
- * `chains` are null for a bundle no verdict could be rendered over.
1388
- */
1389
- function verifyJson(result, grade, declaration, chains, marker = null) {
1390
- let chainBlock = null;
1391
- if (chains !== null) {
1392
- chainBlock =
1393
- "unreadable" in chains
1394
- ? { unreadable: chains.unreadable, chains: 0, findings: [] }
1395
- : {
1396
- unreadable: null,
1397
- chains: chains.chains.length,
1398
- findings: chains.findings.map((f) => renderChainFinding(f, "this bundle")),
1399
- };
1238
+ else if (summaries.length === 0) {
1239
+ io.out(" Chain no event chain was found to follow in this " +
1240
+ "bundle, so nothing was checked here");
1400
1241
  }
1401
- return {
1402
- verdict: grade ? grade.verdict : result.status,
1403
- bundle_status: result.status,
1404
- asserted_status: result.assertedStatus ?? null,
1405
- body_type: result.bodyType ?? null,
1406
- on_chain_checked: result.onChainChecked,
1407
- declaration: declaration ? declaration.verdict : null,
1408
- declaration_notes: declaration ? declaration.notes.map(renderNote) : [],
1409
- causes: grade ? grade.causes.map((c) => renderCause(c, "the pack")) : [],
1410
- notes: [...result.errors],
1411
- records: grade
1412
- ? { presented: result.events.length, content_verified: grade.recordsVerified }
1413
- : null,
1414
- chains: chainBlock,
1415
- // Present only where the pack holds an envelope signed `dev`
1416
- // (envelope-spec §2 MAY), so a production pack's JSON is unchanged.
1417
- ...(marker !== null ? { environment_marker: marker } : {}),
1418
- };
1419
- }
1420
- /**
1421
- * The chain block: what following each record's pointer to the record before it
1422
- * showed, over the records THIS bundle presents.
1423
- *
1424
- * Three deliberate choices, all of them about not overclaiming.
1425
- *
1426
- * 1. It prints the chains it READ, not just the findings. "3 chains, nothing to
1427
- * report" and silence are different statements, and a reader that quietly
1428
- * matched no records at all would produce the second one while covering
1429
- * nothing.
1430
- * 2. It prints the scope sentence whenever it read a chain, findings or not.
1431
- * "Delete an event and the next pointer dangles" is true; "so the bundle is
1432
- * complete" does not follow, and two customer-facing documents had already
1433
- * made that leap. The limit travels with the result or it gets dropped.
1434
- * 3. It does not touch the exit code. Findings are evidence; the exit code is
1435
- * the verdict surface, and the one exit-code table (`./exit-codes.ts`) reads
1436
- * the verdict and nothing else. A dangling link is something for a person to look at, not a machine
1437
- * decision this check is entitled to make.
1438
- */
1439
- function reportChains(reading, io) {
1440
- if ("unreadable" in reading) {
1441
- io.out("");
1442
- io.out(paint(` Event chains this kernel could not read the event chains in this bundle ` +
1443
- `(${reading.unreadable}), so the chain evidence is UNKNOWN here. That is not ` +
1444
- `the same as there being none to read.`, Y));
1445
- return;
1242
+ else {
1243
+ io.out(" Event chains");
1244
+ for (const c of summaries) {
1245
+ const where = c.namespace === undefined ? "record" : `${c.namespace} metadata`;
1246
+ io.out(` ${c.chainKey} ${c.events} record${c.events === 1 ? "" : "s"} present` +
1247
+ ` (linked in ${where})`);
1248
+ }
1249
+ io.out(` ${CHAIN_READING_SCOPE}`);
1446
1250
  }
1447
- if (reading.chains.length === 0)
1448
- return;
1449
- io.out("");
1450
- io.out(paint(" Event chains", DIM));
1451
- for (const c of reading.chains) {
1452
- const where = c.namespace === undefined ? "record" : `${c.namespace} metadata`;
1453
- io.out(paint(` ${c.chainKey} ${count(c.events, "record", "records")} present (linked in ${where})`, DIM));
1251
+ if (simulatedOnlyRun(result, gateways)) {
1252
+ io.out(` Gateways ${gateways.join(", ")}; a simulated witness is not ` +
1253
+ "on a chain, so nothing was looked up");
1454
1254
  }
1455
- for (const f of reading.findings) {
1456
- io.out(paint(` ! ${renderChainFinding(f, "this bundle")}`, Y));
1255
+ else if (gateways.length > 0) {
1256
+ io.out(` Gateways ${gateways.join(", ")}`);
1457
1257
  }
1458
- io.out(paint(` ${CHAIN_READING_SCOPE}`, DIM));
1459
- }
1460
- // Render the export-specific block (evidence-export.md §5): source-bundle
1461
- // linkage, cached-vs-recomputed verdict agreement, and each embedded operator
1462
- // attestation (RSA-PSS sig · operator-address binding · data_hash binding).
1463
- function reportExport(exp, io) {
1464
- io.out("");
1465
- io.out(paint(" Attested export", DIM));
1466
- io.out(` source linkage ${mark(exp.sourceLinkageOk)} verdict agreement ${mark(exp.verdictAgreementOk)}`);
1467
- if (exp.attestations.length > 0) {
1468
- io.out(paint(" Operator attestations", DIM));
1469
- for (const a of exp.attestations) {
1470
- const subj = a.subjectRefOk === null ? "" : ` subject ${mark(a.subjectRefOk)}`;
1471
- io.out(` ${mark(a.ok)} ${shortId(a.operator)} sig ${mark(a.signatureOk)}` +
1472
- ` operator ${mark(a.operatorAddressBound)} data_hash ${mark(a.dataHashBound)}${subj}`);
1473
- for (const e of a.errors)
1474
- io.out(paint(` - ${e}`, R));
1475
- }
1258
+ else {
1259
+ io.out(" Gateways none supplied; the on-chain half was not checked");
1260
+ }
1261
+ io.out(` declaration ${renderDeclarationSummary(declaration)}`);
1262
+ // Every reason the pack is not verified, in the kernel's own words.
1263
+ for (const c of grade.causes)
1264
+ io.out(` - ${renderCause(c, "the pack")}`);
1265
+ // The fraction describes the pack, not the disclosure.
1266
+ if (result.events.length > 0) {
1267
+ io.out(` logs ${grade.recordsVerified}/${result.events.length} ` +
1268
+ "record(s) content-verified");
1476
1269
  }
1477
- }
1478
- function reportAgentProof(result, path, gateways, io, sink, parsed) {
1479
- const marker = result.status === "malformed" ? null : environmentMarker([parsed]);
1480
1270
  if (sink) {
1481
- sink.data = {
1482
- verdict: result.status,
1483
- bundle_status: result.status,
1484
- spec_version: "sanning.agent.proof/v1",
1485
- on_chain_checked: result.onChainChecked,
1486
- notes: [...result.errors],
1487
- ...(marker !== null ? { environment_marker: marker } : {}),
1488
- };
1271
+ sink.data = verifyJson(result, grade, declaration, chains, marker, verdict, claims, person);
1489
1272
  }
1490
- if (result.status === "malformed") {
1491
- io.err(paint("MALFORMED", R) + ` ${path}`);
1492
- for (const e of result.errors)
1493
- io.err(` - ${e}`);
1494
- return EXIT_MALFORMED;
1495
- }
1496
- io.out(paint("Agent inclusion proof", B) + ` ${path}`);
1497
- io.out(` spec_version sanning.agent.proof/v1`);
1498
- io.out(` checkpoint ${shortTx(result.checkpointTxId ?? "")} envelope ${mark(result.checkpointOk)}`);
1499
- const onChain = result.onChainOk === null ? "" : ` on-chain ${mark(result.onChainOk)}`;
1500
- io.out(` leaf index ${result.leafIndex} / ${result.leafCount ?? "?"} inclusion ${mark(result.inclusionOk)}${onChain}`);
1501
- io.out("");
1502
- // An agent inclusion proof carries no records and no disclosed bytes, so
1503
- // there is no pack to grade: the bundle status IS the headline here.
1504
- const exit = sandboxExit(exitCodeFor(result.status), marker);
1505
- printRollup(result.status, result.status, null, gateways, result.onChainChecked, io, marker, exit === EXIT_SANDBOX);
1506
- for (const e of result.errors)
1507
- io.out(paint(` note: ${e}`, DIM));
1508
1273
  return exit;
1509
1274
  }
1510
- function printRollup(headline, bundleStatus, asserted, gateways, onChainChecked, io, marker = null, sandbox = false, simulatedOnly = false) {
1511
- // Three scopes, not two. A run with gateways whose only witnesses are
1512
- // simulated looked nothing up (`simulatedOnlyRun`): saying "on-chain" there
1513
- // claims a check that never happened, and saying "no gateway re-fetch
1514
- // requested" denies the flag the reader passed. Every other run that is not
1515
- // counted as checked on a chain, a run that stopped early among them, keeps
1516
- // the second scope as before.
1517
- const scope = onChainChecked
1518
- ? `verified offline + on-chain against ${gateways.length} gateway(s)`
1519
- : simulatedOnly
1520
- ? "verified offline; a simulated witness is not on a chain, so nothing was looked up"
1521
- : "verified offline (no gateway re-fetch requested)";
1522
- let head;
1523
- switch (sandbox ? "sandbox" : headline) {
1524
- // Sandbox evidence (exit 5) never prints VERIFIED.
1525
- case "sandbox":
1526
- head = paint(SANDBOX_HEADLINE, Y);
1527
- break;
1528
- case "verified":
1529
- head = paint("VERIFIED", G);
1530
- break;
1531
- // Undetermined, in both of its words. Neither is an accusation, so neither
1532
- // is printed in the color this command uses for one.
1533
- case "partial":
1534
- case "pending":
1535
- case "incomplete":
1536
- head = paint(headline.toUpperCase(), Y);
1537
- break;
1538
- case "failed":
1539
- head = paint("FAILED", R);
1540
- break;
1541
- default:
1542
- head = paint(headline.toUpperCase(), R);
1543
- }
1544
- io.out(`${head} ${paint(scope, DIM)}`);
1545
- if (marker !== null)
1546
- io.out(paint(` ${SANDBOX_LINE}`, Y));
1547
- // Both verdicts, whenever they differ, because they answer different
1548
- // questions: §3 grades the signed bundle, the pack verdict grades the bundle
1549
- // plus whatever bytes travelled beside it. A pack can be incomplete while the
1550
- // bundle it carries is impeccable, and a reader told only the headline would
1551
- // read a sound bundle as a broken one.
1552
- if (headline !== bundleStatus) {
1553
- io.out(paint(` (the signed bundle is "${bundleStatus}"; the pack around it is "${headline}")`, DIM));
1554
- }
1555
- if (asserted && asserted !== bundleStatus) {
1556
- io.out(paint(` (the asserting party said "${asserted}"; the recomputed verdict is "${bundleStatus}", ` +
1557
- `and the recomputed verdict wins)`, DIM));
1558
- }
1275
+ function agentJson(reading, where = {}) {
1276
+ return {
1277
+ ...where,
1278
+ verdict: reading.verdict,
1279
+ reason: reading.reason ?? null,
1280
+ at: reading.at ?? null,
1281
+ reasons: (reading.reasons ?? []).map((r) => ({ reason: r.reason, at: r.at })),
1282
+ findings: [...reading.findings],
1283
+ rendered: renderAgentReading(reading),
1284
+ };
1559
1285
  }
1286
+ const CAUSE_FIELDS = [
1287
+ ["checkpoint_ref", "checkpointRef"],
1288
+ ["signed", "signed"],
1289
+ ["presented", "presented"],
1290
+ ["count", "count"],
1291
+ ["of", "of"],
1292
+ ["first_event_id", "firstEventId"],
1293
+ ["reason", "reason"],
1294
+ ["deviation", "deviation"],
1295
+ ["at", "at"],
1296
+ ["listed_event_id", "listedEventId"],
1297
+ ];
1560
1298
  /**
1561
- * The exit code is the PACK verdict's, through the one table (`./exit-codes.ts`).
1562
- *
1563
- * `incomplete` and `partial` are 3: undetermined, which is what 3 means for an
1564
- * unreachable gateway too, and emphatically not 1: the bytes being absent is
1565
- * never reported as tamper (`evidence-bundle.md` §5.1). Until 0.13.0 `partial`
1566
- * exited 0 here unless a gateway was unreachable, while `case` exited 3 on it.
1299
+ * One pack grade cause in `--json`: its kind, every value it carries, and the sentence
1300
+ * the report prints for it.
1567
1301
  */
1568
- function mapGradeToExit(grade) {
1569
- return exitCodeFor(grade.verdict);
1302
+ function causeJson(cause) {
1303
+ const out = { kind: cause.kind };
1304
+ for (const [name, field] of CAUSE_FIELDS) {
1305
+ const value = cause[field];
1306
+ if (value !== undefined && value !== null)
1307
+ out[name] = value;
1308
+ }
1309
+ out.rendered = renderCause(cause, "the pack");
1310
+ return out;
1570
1311
  }
1571
- // ─── What `proof case` reads (proof#141) ─────────────────────────────────────
1572
- //
1573
- // Directories on disk, split into packs. This lives in the CLI module, and not
1574
- // in a kernel module, because it is the command's own read of files a person
1575
- // named on the command line, the same class of read as `verify`'s read of
1576
- // `bundle.json`, and it reads `bundle.json` with `verify`'s own reader,
1577
- // `readBundleBytes`.
1578
- //
1579
- // The case READING is `readCase`, and nothing here touches it. This turns the
1580
- // directories a reader typed into a list of packs. Its twin is the same section
1581
- // of `src/sanning_proof/cli.py`, and both are held to one table,
1582
- // `vectors/case-split.json`, whose rows are directory trees.
1583
- //
1584
- // ── Directories only ──
1585
- //
1586
- // A `.zip` named on the command line is read as the folder `unzip` would make,
1587
- // by the reader in `zip-reader.ts` and nothing else, one reader restated in both
1588
- // kernels because two zip libraries disagree on the edges. A `.zip` INSIDE a
1589
- // folder is a file like any other: outside every pack, hashed like any other
1590
- // file and never unzipped, because a reader does not open a zip inside a zip
1591
- // (§5.6.1).
1592
- //
1593
- // ── The walk ──
1594
- //
1595
- // Iterative, never recursive, bytewise-sorted at each level, and bounded:
1596
- //
1597
- // - A directory nested more than MAX_WALK_DEPTH levels below an argument
1598
- // refuses the whole input (exit 2).
1599
- // - More than MAX_WALK_ENTRIES names listed, across every argument, refuses
1600
- // the whole input (exit 2).
1601
- // - A name that is not valid UTF-8 refuses the whole input (exit 2). It is
1602
- // not escaped or replaced: two different names would then read as one.
1603
- // - A symbolic link is never followed, whatever it points at, and anything
1604
- // else that is not a regular file or a directory is never opened. Each is
1605
- // listed as outside every pack and not read, even when it sits under a
1606
- // pack's `logs/` or is named `bundle.json`.
1312
+ /**
1313
+ * What `verify --json` prints for an evidence bundle: the same keys and words as
1314
+ * Python's `_verify_json`. Every reason and count the person layer prints is here, so a
1315
+ * surface can render `person` from this object alone (`renderPerson`).
1316
+ */
1317
+ function verifyJson(result, grade, declaration, chains, marker, verdict, claims, person) {
1318
+ const chainBlock = "unreadable" in chains
1319
+ ? { unreadable: chains.unreadable, chains: 0, findings: [] }
1320
+ : {
1321
+ unreadable: null,
1322
+ chains: chains.chains.length,
1323
+ findings: chains.findings.map((f) => renderChainFinding(f, "this bundle")),
1324
+ };
1325
+ return {
1326
+ // Present only for a pack with an envelope signed `dev` (envelope-spec section 2).
1327
+ ...(marker !== null ? { environment_marker: marker } : {}),
1328
+ verdict,
1329
+ title: VERDICT_TITLES[verdict.status],
1330
+ claims,
1331
+ person,
1332
+ notes: [...person.notes],
1333
+ sandbox: marker !== null,
1334
+ witness: person.witness,
1335
+ witness_state: person.witness_state,
1336
+ bundle_status: result.status,
1337
+ bundle_errors: [...result.errors],
1338
+ body_type: result.bodyType ?? null,
1339
+ on_chain_checked: result.onChainChecked,
1340
+ declaration: declaration.verdict,
1341
+ declaration_notes: declaration.notes.map(renderNote),
1342
+ causes: grade.causes.map(causeJson),
1343
+ records: { presented: result.events.length, content_verified: grade.recordsVerified },
1344
+ chains: chainBlock,
1345
+ events: result.events.map((ev) => ({ event_id: ev.eventId, ok: ev.ok, errors: [...ev.errors] })),
1346
+ agent_records: result.events
1347
+ .filter((ev) => ev.agent !== null)
1348
+ .map((ev) => agentJson(ev.agent, { event_id: ev.eventId })),
1349
+ };
1350
+ }
1351
+ // `proof case` input: directories (or a `.zip` read as the folder `unzip`
1352
+ // makes) split into packs, held to `vectors/case-split.json` in both kernels.
1353
+ // A `.zip` inside a folder is a file like any other (§5.6.1).
1607
1354
  //
1608
- // ── The split: the verify page's rule over the tree ──
1355
+ // The walk is iterative and bytewise-sorted, never follows a link, opens only
1356
+ // regular files and directories, and refuses the whole input (exit 4) past
1357
+ // MAX_WALK_DEPTH or MAX_WALK_ENTRIES or on a name that is not valid UTF-8.
1609
1358
  //
1610
- // The verify page (`sanning-io/verify-page`, `src/lib/case.ts` `splitCase` and
1611
- // `src/lib/pack.ts` `assemble`) is the other surface that reads a case. Both
1612
- // apply the same splitting rule to the same unpacked tree. The page's rule
1613
- // lives in the page, so neither kernel can call it; it is restated here and in
1614
- // the Python twin, and the shared table pins it:
1359
+ // The split restates the verify page's rule:
1615
1360
  //
1616
- // 1. Every regular file whose path ends in `/bundle.json` is a pack, rooted
1617
- // at the directory that holds it.
1618
- // 2. A file belongs to the pack with the LONGEST root it sits under, and only
1619
- // if it is that root's `bundle.json` or under that root's `logs/`.
1620
- // 3. A file under no pack is OUTSIDE: listed, never read as part of a pack, and
1621
- // hashed to be matched against the records (§5.7.6, A41).
1622
- // 4. Roots and outside paths are sorted by UTF-16 code unit (the default
1623
- // `Array.prototype.sort`).
1624
- // 5. Inside a pack, `logs/<event_id>.json` (one level) is disclosed content,
1625
- // and any other file under `logs/` is UNACCOUNTED.
1361
+ // 1. Every regular file at `.../bundle.json` is a pack rooted at its folder.
1362
+ // 2. A file belongs to the pack with the longest root it sits under, only if
1363
+ // it is that root's `bundle.json` or under its `logs/`.
1364
+ // 3. Any other file is outside: listed, and hashed to match against records.
1365
+ // 4. Roots and outside paths sort by UTF-16 code unit.
1366
+ // 5. In a pack, `logs/<event_id>.json` (one level) is disclosed content; any
1367
+ // other file under `logs/` is unaccounted.
1626
1368
  // 6. A pack's `ref` is its root without the trailing slash.
1627
1369
  // 7. No `bundle.json` anywhere refuses.
1628
1370
  //
1629
- // One directory argument is read at `<its name>/<path inside it>`, which is how
1630
- // the page names the files of a dropped folder. Several are read as one folder
1631
- // holding them, each under its own name, and two that would land at one name
1632
- // refuse.
1633
- //
1634
- // Known differences from the page: the page drops a leading byte-order mark from
1635
- // `bundle.json` and `verify`'s reader refuses one; the page reads whatever the
1636
- // browser hands it for a link, a name that is not UTF-8 or a very deep tree,
1637
- // where this command applies the walk's rules above; and the page keys
1638
- // disclosed content by a plain object, so it loses a record named `__proto__`,
1639
- // which this command keeps.
1371
+ // One directory argument reads as `<its name>/<path inside>`; several read as
1372
+ // one folder holding them, and two landing at one name refuse. Unlike the page,
1373
+ // this command refuses a leading BOM in `bundle.json` and keeps a record named
1374
+ // `__proto__`.
1640
1375
  /** The error text for an input with no pack in it, word for word the page's. */
1641
1376
  export const NO_BUNDLE = "No bundle.json found. An evidence pack contains bundle.json plus logs/.";
1642
1377
  /** A directory nested deeper than this below an argument refuses the input. */
@@ -1656,7 +1391,7 @@ const UNZIP_FIRST = "Pass the .zip itself, or unzip it first (for example, unzip
1656
1391
  "case/) and pass the folder.";
1657
1392
  /**
1658
1393
  * The input could not be read as a case. The message is the whole report,
1659
- * identical in both kernels, and the command exits 2 on it. `hint` is an
1394
+ * identical in both kernels, and the command exits 4 on it. `hint` is an
1660
1395
  * optional second line naming a way through.
1661
1396
  */
1662
1397
  export class CaseInputError extends Error {
@@ -1675,11 +1410,10 @@ export class CaseInputError extends Error {
1675
1410
  export class WholeReadRefused extends CaseInputError {
1676
1411
  }
1677
1412
  /**
1678
- * `bundle.json` bytes that `readBundleBytes` will not read. `tooDeep` separates
1679
- * the one refusal that is not a parse error; `detail` is the parser's own words,
1680
- * which differ by kernel and are printed by `verify` only. `duplicate` marks the
1681
- * one parse refusal whose `detail` is kernel wording, identical in both kernels
1682
- * (`duplicate key "<name>"`, proof#144), so both commands print it.
1413
+ * `bundle.json` bytes `readBundleBytes` will not read. `tooDeep` marks the one
1414
+ * refusal that is not a parse error. `detail` is the parser's words, which
1415
+ * differ by kernel, so only `verify` prints it; `duplicate` marks the one
1416
+ * `detail` both kernels word identically.
1683
1417
  */
1684
1418
  export class BundleReadError extends Error {
1685
1419
  tooDeep;
@@ -1692,22 +1426,20 @@ export class BundleReadError extends Error {
1692
1426
  this.duplicate = duplicate;
1693
1427
  }
1694
1428
  }
1695
- // What both commands say about bytes that repeat a member name, before the
1696
- // kernel's own `duplicate key "<name>"`. RFC 8259 JSON permits a repeat and
1697
- // I-JSON (RFC 7493), which JCS is defined over, does not, so "not valid JSON"
1698
- // would be the wrong sentence. `NOT_I_JSON` in the Python twin.
1429
+ // The prefix for a repeated member name. JCS is defined over I-JSON (RFC 7493),
1430
+ // which forbids it, so "not valid JSON" would be the wrong sentence.
1699
1431
  export const NOT_I_JSON = "is not I-JSON (RFC 7493)";
1700
- // `ignoreBOM: true` KEEPS a byte-order mark in the text, as Node's
1701
- // `readFile(path, "utf8")` did for `verify`, so a leading one is not JSON.
1432
+ // `ignoreBOM: true` keeps a byte-order mark in the text, so a leading one is
1433
+ // not JSON.
1702
1434
  const bundleDecoder = new TextDecoder("utf-8", { ignoreBOM: true });
1703
1435
  const nameDecoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
1704
1436
  /**
1705
- * How both `verify` and `case` turn `bundle.json` bytes into JSON, in both
1706
- * kernels (`read_bundle_bytes` in the Python twin). UTF-8 with each ill-formed
1707
- * sequence read as U+FFFD, and a byte-order mark kept; then nesting deeper than
1708
- * MAX_JSON_DEPTH is refused before any parser walks into it; then `JSON.parse`,
1709
- * refusing an object that repeats a member name (I-JSON, RFC 7493; proof#144).
1710
- * Throws `BundleReadError`.
1437
+ * How `verify` and `case` turn `bundle.json` bytes into JSON, in both kernels:
1438
+ * UTF-8 with ill-formed sequences as U+FFFD and a BOM kept; nesting past
1439
+ * MAX_JSON_DEPTH refused before parsing; then `JSON.parse`, refusing a repeated
1440
+ * member name.
1441
+ *
1442
+ * @throws BundleReadError
1711
1443
  */
1712
1444
  export function readBundleBytes(bytes) {
1713
1445
  const text = bundleDecoder.decode(bytes);
@@ -1724,9 +1456,8 @@ export function readBundleBytes(bytes) {
1724
1456
  }
1725
1457
  }
1726
1458
  /**
1727
- * The page's `splitCase` followed by its `assemble` per pack, over the walk's
1728
- * tree. `read` opens a file; it is called for the files a pack owns and for
1729
- * nothing else.
1459
+ * The page's `splitCase` then `assemble` per pack, over the walk's tree. `read`
1460
+ * is called only for the files a pack owns.
1730
1461
  */
1731
1462
  export async function splitCase(tree, read) {
1732
1463
  const roots = [...tree]
@@ -1789,15 +1520,10 @@ export async function splitCase(tree, read) {
1789
1520
  }
1790
1521
  /**
1791
1522
  * The bytes of the `witness-cache.json` beside a pack's `bundle.json`
1792
- * (evidence-bundle 5.6.6, v1.25, A30), or `null`.
1793
- *
1794
- * The split rule is not changed for it: the file stays outside every pack,
1795
- * listed as such, and is read here, for its contents, and hashed with the other
1796
- * outside files (A41). It is read as a cache only where it is a regular file the split left outside every pack, so a file under another
1797
- * pack's `logs/` is that pack's, and a link is never followed. A file that
1798
- * cannot be read is no cache, never a refusal of the drop. One the run's
1799
- * whole-read total has no room for is pushed onto `refused` when it is given,
1800
- * so the output says so.
1523
+ * (evidence-bundle.md §5.6.6), or `null`. Read only where it is a regular file
1524
+ * the split left outside every pack, and it stays outside. An unreadable file
1525
+ * is no cache, never a refusal; one the whole-read total has no room for is
1526
+ * pushed onto `refused` when given.
1801
1527
  */
1802
1528
  export async function witnessCacheBytes(tree, split, pack, budget, refused) {
1803
1529
  const path = pack.bundlePath.slice(0, -"bundle.json".length) + WITNESS_CACHE_NAME;
@@ -1817,31 +1543,28 @@ export async function witnessCacheBytes(tree, split, pack, budget, refused) {
1817
1543
  throw e;
1818
1544
  }
1819
1545
  }
1820
- /** `verify`'s reader, with the refusal named by the pack's path. */
1546
+ /**
1547
+ * `verify`'s reader over one pack's `bundle.json`: the bundle, or an empty object and why
1548
+ * it could not be read. One pack that cannot be read is that pack's problem, never the
1549
+ * case's: every other pack is still read.
1550
+ */
1821
1551
  export function loadBundle(pack) {
1822
1552
  try {
1823
- return readBundleBytes(pack.bundleBytes);
1553
+ return [readBundleBytes(pack.bundleBytes), null];
1824
1554
  }
1825
1555
  catch (e) {
1826
1556
  if (e instanceof BundleReadError && e.tooDeep) {
1827
- throw new CaseInputError(`${pack.bundlePath} nests deeper than ${MAX_JSON_DEPTH} levels`);
1557
+ return [{}, `nests deeper than ${MAX_JSON_DEPTH} levels`];
1828
1558
  }
1829
1559
  if (e instanceof BundleReadError && e.duplicate) {
1830
- throw new CaseInputError(`${pack.bundlePath} ${NOT_I_JSON}: ${e.detail}`);
1560
+ return [{}, `${NOT_I_JSON}: ${e.detail}`];
1831
1561
  }
1832
- throw new CaseInputError(`${pack.bundlePath} is not valid JSON`);
1562
+ return [{}, "is not valid JSON"];
1833
1563
  }
1834
1564
  }
1835
- /**
1836
- * The refusal for a file `case` would hold whole that does not fit in what is
1837
- * left of the 1 GiB a run reads whole. The same words in both kernels.
1838
- */
1565
+ /** The refusal for a file `case` would read whole past the run's 1 GiB. Same words in both kernels. */
1839
1566
  export const wholeReadRefusal = (key) => `${key} cannot be read: it would take case past the 1 GiB it reads whole in one run; refusing to read it`;
1840
- /**
1841
- * Why `contents.json` reads as an unreadable index when the run's whole-read
1842
- * total has no room for it, and when it cannot be read. The same words in both
1843
- * kernels.
1844
- */
1567
+ /** Why `contents.json` reads as unreadable when it is over budget or cannot be read. */
1845
1568
  export const INDEX_OVER_BUDGET = "it would take case past the 1 GiB it reads whole in one run, so it was not read";
1846
1569
  export const INDEX_NOT_READ = "it could not be read";
1847
1570
  /** The note for a `witness-cache.json` the whole-read total had no room for. */
@@ -1849,15 +1572,13 @@ export const witnessCacheRefusedNote = (path) => `${path} was not read as a witn
1849
1572
  /** The refusal for an archive file larger than MAX_ZIP_ARCHIVE_BYTES. */
1850
1573
  export const zipFileRefusal = (arg) => `${arg} is larger than 1.25 GiB on disk; refusing to read it`;
1851
1574
  /**
1852
- * A regular file's bytes. Opened without following a link and without
1853
- * blocking, then checked again, so a file swapped for a link or a pipe after
1854
- * the walk listed it is refused rather than followed or waited on.
1575
+ * A regular file's bytes, opened without following a link or blocking, then
1576
+ * rechecked, so a swapped-in link or pipe is refused.
1855
1577
  *
1856
- * `budget` is the bytes a run may still hold whole from files on disk
1857
- * (MAX_FOLDER_BYTES at the start). A file larger than what is left, or that
1858
- * grows past it while it is read, is refused before it is held, so a file of a
1859
- * terabyte under a pack's own names cannot exhaust memory. A `.zip` entry is
1860
- * already in memory, inside the archive's own cap, and is not charged.
1578
+ * `budget` is the bytes a run may still hold whole (MAX_FOLDER_BYTES at the
1579
+ * start). A file larger than that, or that grows past it, is refused before it
1580
+ * is held. A `.zip` entry is already in memory under the archive cap and is not
1581
+ * charged.
1861
1582
  */
1862
1583
  export async function readFileEntry(entry, key, budget) {
1863
1584
  if (entry.data !== undefined && entry.data !== null)
@@ -1878,8 +1599,8 @@ export async function readFileEntry(entry, key, budget) {
1878
1599
  return new Uint8Array(await handle.readFile());
1879
1600
  if (st.size > budget.n)
1880
1601
  throw new WholeReadRefused(wholeReadRefusal(key));
1881
- // Once, at what the file says it holds and one byte more to see it grow:
1882
- // the bytes are read into the buffer the result is a view of, never copied.
1602
+ // Ask for one byte more than the size, to see it grow; read in place, never
1603
+ // copied.
1883
1604
  const want = Math.min(st.size, budget.n) + 1;
1884
1605
  const buf = Buffer.allocUnsafeSlow(want);
1885
1606
  let total = 0;
@@ -1890,8 +1611,7 @@ export async function readFileEntry(entry, key, budget) {
1890
1611
  total += bytesRead;
1891
1612
  }
1892
1613
  if (total > st.size) {
1893
- // The file grew since it was measured: take the rest in pieces, never
1894
- // past the budget.
1614
+ // It grew: read the rest in pieces, never past the budget.
1895
1615
  const rest = [];
1896
1616
  let all = total;
1897
1617
  while (all <= budget.n) {
@@ -1923,9 +1643,8 @@ export async function readFileEntry(entry, key, budget) {
1923
1643
  }
1924
1644
  }
1925
1645
  /**
1926
- * Every name under `root`, into `tree` at `<prefix><path inside>`. Breadth
1927
- * first from an explicit queue, never by recursion. `seen` is the count of
1928
- * names listed so far, across every argument.
1646
+ * Every name under `root`, into `tree` at `<prefix><path inside>`, breadth
1647
+ * first, without recursion. `seen` counts names across every argument.
1929
1648
  */
1930
1649
  async function walk(root, prefix, arg, tree, seen) {
1931
1650
  const queue = [[root, prefix, 0]];
@@ -1972,8 +1691,7 @@ async function walk(root, prefix, arg, tree, seen) {
1972
1691
  }
1973
1692
  }
1974
1693
  }
1975
- // The name the page would see for a dropped folder: the last segment of the
1976
- // absolute path, so `.` and `pack/` name the directory itself.
1694
+ // The last segment of the absolute path, so `.` and `pack/` name the directory.
1977
1695
  function baseName(path) {
1978
1696
  return basename(resolve(path));
1979
1697
  }
@@ -2018,8 +1736,7 @@ export async function treeFromArguments(paths) {
2018
1736
  }
2019
1737
  let name = baseName(arg);
2020
1738
  if (isZip) {
2021
- // `unzip <name>.zip -d <name>` is the command HOW-TO-VERIFY.txt pins, so
2022
- // the archive is read under the folder that command makes.
1739
+ // Read under the folder `unzip <name>.zip -d <name>` makes.
2023
1740
  name = name.slice(0, -".zip".length);
2024
1741
  if (name.length === 0)
2025
1742
  throw new CaseInputError(`${arg} has no name before .zip; rename it`);
@@ -2037,11 +1754,9 @@ export async function treeFromArguments(paths) {
2037
1754
  return tree;
2038
1755
  }
2039
1756
  /**
2040
- * A case file named on the command line, read as the folder `unzip` would make
2041
- * of it: `<name>.zip` is read at `<name>/<entry>` (5.6.4), so the zip and its
2042
- * unpacked folder give one reading. The archive is read by `readZip` and
2043
- * nothing else; an entry it refuses is listed as outside every pack and never
2044
- * read, as the walk lists a link.
1757
+ * A case file named on the command line, read as the folder `unzip` would make:
1758
+ * `<name>.zip` reads at `<name>/<entry>` (§5.6.4). An entry `readZip` refuses
1759
+ * is listed outside every pack and never read.
2045
1760
  */
2046
1761
  async function unzip(arg, prefix, tree, seen) {
2047
1762
  let data;
@@ -2056,8 +1771,7 @@ async function unzip(arg, prefix, tree, seen) {
2056
1771
  const size = (await handle.stat()).size;
2057
1772
  if (size > MAX_ZIP_ARCHIVE_BYTES)
2058
1773
  throw new CaseInputError(zipFileRefusal(arg));
2059
- // One more byte than the size on disk, so a file that grew is noticed; the
2060
- // archive is read straight into this buffer, once, and never copied.
1774
+ // One byte past the size, to notice growth; read in place, never copied.
2061
1775
  const buf = Buffer.allocUnsafe(size + 1);
2062
1776
  let have = 0;
2063
1777
  for (;;) {
@@ -2103,28 +1817,17 @@ async function unzip(arg, prefix, tree, seen) {
2103
1817
  });
2104
1818
  }
2105
1819
  }
2106
- // ─── `proof case` (proof#141) ────────────────────────────────────────────────
1820
+ // `proof case`: the case reading from a terminal, so an auditor holding several
1821
+ // packs does not depend on Sanning's page. Python's twin is `run_case`;
1822
+ // `cross-kernel/run_case_cli.sh` holds both to the same stdout, `--json` and
1823
+ // exit code. No color, so both print the same bytes.
2107
1824
  //
2108
- // The case reading from a terminal, so an auditor holding several packs does
2109
- // not depend on Sanning's page for the one check the audited party cannot
2110
- // write for itself. The twin is `run_case` in `src/sanning_proof/cli.py`, and
2111
- // the two commands are compared as child processes by
2112
- // `cross-kernel/run_case_cli.sh`: same stdout, same `--json`, same exit code,
2113
- // over every `case/` vector.
2114
- //
2115
- // The reading is `readCase`, entire. This command splits the input into packs
2116
- // (the section above, the verify page's rule) and prints what the kernel said.
2117
- // No color: the two commands print the same bytes.
2118
- // The exit code of a case verdict is the one table's (`./exit-codes.ts`).
2119
- // `mixed` has its own code, 4, because each other code would say something a
2120
- // mixed case has not established: 0 would soften a case with an unsettled or
2121
- // unreadable outcome in it, 3 would hide the outcomes that did settle, and 1
2122
- // would accuse a case in which nothing was proven wrong. A case holding a
2123
- // failure never exits 4: a proven failure keeps the headline (evidence-bundle
2124
- // 5.5.4 v1.16, A17), so it reads `failed` and exits 1.
1825
+ // `mixed` exits 4 because every other code would claim something unproven: 0
1826
+ // softens an unsettled outcome, 3 hides the settled ones, 1 accuses. A proven
1827
+ // failure keeps the headline (evidence-bundle.md §5.5.4) and exits 1.
2125
1828
  export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict over one pack or many, offline
2126
1829
 
2127
- sanning-proof case <folder or case file>... [--gateways <url,url>] [--files <path>...] [--enclave-policy <file>] [--enclave-rpc <url>] [--json]
1830
+ sanning-proof case <folder or case file>... [--gateways <url,url>] [--files <path>...] [--json]
2128
1831
 
2129
1832
  <folder> a pack folder (bundle.json plus logs/), a folder that holds
2130
1833
  several packs, or an unzipped case file. Packs are found by the
@@ -2137,7 +1840,7 @@ export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict ove
2137
1840
  read, even under a pack's logs/. Several arguments are read as
2138
1841
  one folder holding them, each under its own name. A folder
2139
1842
  nested more than 32 levels deep, more than 50000 files and
2140
- folders in all, or a name that is not valid UTF-8 exits 2.
1843
+ folders in all, or a name that is not valid UTF-8 exits 4.
2141
1844
  <case file> a case file named as <name>.zip, read as the folder
2142
1845
  unzip <name>.zip -d <name> would make, so the two give one
2143
1846
  reading. An entry that is encrypted, a symbolic link, stored by
@@ -2148,7 +1851,9 @@ export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict ove
2148
1851
  is listed; the index never moves the verdict.
2149
1852
  --gateways comma-separated gateway URLs. Each pack's witnesses are then
2150
1853
  looked up on the public record, pack by pack, and reported on
2151
- the witness line. The lookup never changes the verdict word.
1854
+ the witness line. Only a lookup that finds other bytes, or
1855
+ another witness time, changes the verdict: it disproves
1856
+ witnessed, and the case reads failed.
2152
1857
  --files files handed in beside the packs, every path up to the next
2153
1858
  option, matched by SHA-256 alone against what every pack's
2154
1859
  signed records commit to. A folder stands for the regular files
@@ -2156,23 +1861,13 @@ export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict ove
2156
1861
  inside it is listed as skipped and never followed. A file under
2157
1862
  a pack's logs/ is offered only when it is named here. The
2158
1863
  verdict never moves.
2159
- --json the full reading as JSON: the verdict, every exception and note
1864
+ --json the full reading as JSON: the verdict, the six claims, the
1865
+ person layer, every exception and note
2160
1866
  with its rendered line, every record of every pack with its
2161
1867
  status (read clean, read flagged, or not read and why), every
2162
1868
  contribution, the packs, each with its index, the files outside
2163
1869
  them, the case file index comparison, the witness line and the
2164
1870
  on-chain half.
2165
- --enclave-policy
2166
- a sanning.enclave-policy/v1 file: what this reader accepts of
2167
- an enclave attestation. Where the packs hold a runner's run, an
2168
- enclave attestation line and a run-account line print beside
2169
- the witness line (DRAFT); neither moves the verdict or the exit
2170
- code. Without a policy, or without an adapter for the provider
2171
- installed, the line reads not_checked.
2172
- --enclave-rpc
2173
- a chain JSON-RPC endpoint: a flare-fcc run also gets the online
2174
- line, read from the contracts the policy names. It moves
2175
- nothing either.
2176
1871
 
2177
1872
  Files outside every pack are matched too, always, so
2178
1873
  sanning-proof case audit-file.zip checks a whole download in one command: each
@@ -2190,7 +1885,7 @@ kernel and graded as a pack; every record every pack supplied is read, in one
2190
1885
  pile, under the hand-off rules of envelope-spec section 4.1; a hand-off record
2191
1886
  with no counterpart among the records read is always named, and when a pack
2192
1887
  signed by the key it names is in the reading, it says that pack holds none,
2193
- unless every candidate it had was accounted for by another record (A20),
1888
+ unless every candidate it had was accounted for by another record,
2194
1889
  which reads as no counterpart and names no pack.
2195
1890
 
2196
1891
  What it does not establish: that the set of packs is complete (a party can
@@ -2201,17 +1896,16 @@ that any witness is on the public record, which the reading never looks up.
2201
1896
  ${EXIT_CODES_HELP}
2202
1897
 
2203
1898
  case: an input that cannot be read as packs, or an unexpected error in this
2204
- command, exits 2. With --gateways, a witness that does not match on chain exits
2205
- 1, and an on-chain half that could not be settled turns 0 into 3.`;
2206
- // What Node writes for a lone surrogate on a UTF-8 stream is U+FFFD. Done here
2207
- // rather than left to the stream, so the lines a test captures in process are
2208
- // the lines a reader sees.
1899
+ command, exits 4. With --gateways, a witness that does not match on chain
1900
+ exits 1; a lookup that could not be settled moves nothing, and the witness line
1901
+ says so.`;
1902
+ // Node writes U+FFFD for a lone surrogate; doing it here makes captured lines
1903
+ // match what a reader sees.
2209
1904
  function noLoneSurrogates(text) {
2210
1905
  return text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "�");
2211
1906
  }
2212
- // Keys sorted at every depth, arrays left in their order: the Python twin
2213
- // writes `json.dumps(sort_keys=True, indent=2, ensure_ascii=False)` and this is
2214
- // that, byte for byte.
1907
+ // Keys sorted at every depth, arrays in order: byte for byte Python's
1908
+ // `json.dumps(sort_keys=True, indent=2, ensure_ascii=False)`.
2215
1909
  function sortKeys(v) {
2216
1910
  if (Array.isArray(v))
2217
1911
  return v.map(sortKeys);
@@ -2225,56 +1919,30 @@ function sortKeys(v) {
2225
1919
  function jsonText(v) {
2226
1920
  return JSON.stringify(sortKeys(v), null, 2);
2227
1921
  }
2228
- /**
2229
- * The case verdict's code, moved only by an on-chain half that was asked for:
2230
- * a witness that does not match is a failure whatever the reading said, and an
2231
- * on-chain half that could not be settled is never a clean 0.
2232
- */
2233
- export function caseExitCode(verdict, onChainVerdict) {
2234
- const code = exitCodeFor(verdict);
2235
- if (onChainVerdict === "failed")
2236
- return EXIT_FAILED;
2237
- if (onChainVerdict === "undetermined" && code === EXIT_VERIFIED)
2238
- return EXIT_UNDETERMINED;
2239
- return code;
2240
- }
2241
1922
  const titleOf = (word) => Object.prototype.hasOwnProperty.call(VERDICT_DISPLAY_TITLES, word)
2242
1923
  ? VERDICT_DISPLAY_TITLES[word]
2243
1924
  : word;
2244
1925
  /**
2245
- * The text report, one line per entry, identical in both kernels. The headline
2246
- * is the verdict's one display title and nothing else sets it (5.5.6, D7); the
2247
- * scope sentence and the witness line are printed with it every time.
1926
+ * The text report, one line per list entry, identical in both kernels. The person
1927
+ * layer comes first (the title, one line per claim that is not proven, the next action,
1928
+ * the notes, the sandbox line and the witness line), then the scope sentence (section
1929
+ * 5.5.6), what a reader may rely on, and the detail.
2248
1930
  */
2249
1931
  function caseReport(data) {
2250
- const lines = [data.title];
2251
- // Sandbox evidence: printed with every headline the marker is on.
2252
- if (data.environment_marker !== undefined)
2253
- lines.push(` ${SANDBOX_LINE}`);
2254
- lines.push(` ${data.summary}`, ` ${data.witness.rendered}`);
2255
- // Where the packs hold a runner's run: its enclave attestation line and its
2256
- // run-account line, beside the witness line.
2257
- const enclave = data.enclave_attestation ?? [];
2258
- const account = data.run_account ?? [];
2259
- for (let i = 0; i < Math.min(enclave.length, account.length); i++) {
2260
- for (const line of enclave[i].rendered)
2261
- lines.push(` ${line}`);
2262
- lines.push(` ${account[i].rendered}`);
2263
- }
2264
- lines.push(` ${data.reliance}`);
1932
+ const lines = personText(data.person);
1933
+ lines.push("");
1934
+ lines.push(data.summary, data.reliance);
2265
1935
  for (const e of data.exceptions)
2266
1936
  lines.push(` - ${e.rendered}`);
2267
- for (const n of data.notes)
2268
- lines.push(` note: ${n.rendered}`);
2269
1937
  lines.push(`packs ${data.packs.length}`);
2270
1938
  for (const p of data.packs) {
2271
1939
  const key = p.signing_key !== null ? p.signing_key : "a key this reader cannot read";
2272
1940
  const grade = p.grade === null
2273
1941
  ? "not graded"
2274
1942
  : p.grade === "verified" && p.environment_marker !== undefined
2275
- ? SANDBOX_TITLE
1943
+ ? "Verified, sandbox"
2276
1944
  : titleOf(p.grade);
2277
- // Each record's status, counted per pack (A43): the JSON lists every one.
1945
+ // Counted per pack; the JSON lists every record.
2278
1946
  const mine = data.records.filter((r) => r.pack_index === p.index);
2279
1947
  const count = (status) => mine.filter((r) => r.status === status).length;
2280
1948
  const records = `records: ${count("read_clean")} read clean, ${count("read_flagged")} read flagged, ` +
@@ -2288,6 +1956,16 @@ function caseReport(data) {
2288
1956
  }
2289
1957
  lines.push(line);
2290
1958
  }
1959
+ // Every agent record that did not read verified, or carries a finding. A
1960
+ // finding is reported beside its record and moves no verdict.
1961
+ const agents = data.records.filter((r) => r.agent !== null);
1962
+ if (agents.length > 0) {
1963
+ const shown = agents.filter((r) => r.agent.verdict !== "verified" || r.agent.findings.length > 0);
1964
+ lines.push(`agent records ${agents.length} read across every pack, ` +
1965
+ `${agents.length - shown.length} verified with no finding`);
1966
+ for (const r of shown)
1967
+ lines.push(` ${r.pack_ref}/${r.event_id} ${r.agent.rendered}`);
1968
+ }
2291
1969
  if (data.outside.length > 0) {
2292
1970
  lines.push(`outside ${data.outside.length} file(s) sit outside every ` +
2293
1971
  "pack, so no verdict here is about them");
@@ -2296,9 +1974,6 @@ function caseReport(data) {
2296
1974
  lines.push(links.has(path) ? ` - ${path} (not a regular file: not followed, not read)` : ` - ${path}`);
2297
1975
  }
2298
1976
  }
2299
- for (const path of data.witness_cache_not_read ?? []) {
2300
- lines.push(`note ${witnessCacheRefusedNote(path)}`);
2301
- }
2302
1977
  for (const c of data.case_file) {
2303
1978
  if (c.index === "absent")
2304
1979
  continue;
@@ -2311,8 +1986,7 @@ function caseReport(data) {
2311
1986
  }
2312
1987
  const oc = data.on_chain;
2313
1988
  if (oc.checked) {
2314
- // A drop whose only witnesses are simulated had nothing looked up on a
2315
- // chain, and the header says so rather than "looked up against".
1989
+ // Only simulated witnesses: nothing was looked up on a chain.
2316
1990
  const simulated = oc.packs.reduce((n, r) => n + (r.simulated ?? 0), 0);
2317
1991
  const onlySimulated = simulated > 0 &&
2318
1992
  oc.packs.every((r) => (r.simulated ?? 0) === r.witnesses && (r.witness_times ?? []).length === 0);
@@ -2333,12 +2007,10 @@ function caseReport(data) {
2333
2007
  return lines;
2334
2008
  }
2335
2009
  /**
2336
- * The index comparison, once per argument (5.6.3): each folder or case file
2337
- * whose root holds `contents.json`. Every file under `packs/` and `report/` is
2338
- * hashed for it, in pieces, as bytes, and nothing is parsed but the index. The
2339
- * files share one total of 1 GiB across the run, charged in path order; a file
2340
- * larger than what is left is reported as too large to hash, never as a match
2341
- * and never as absent. It is never an input to the reading.
2010
+ * The index comparison, once per argument with a `contents.json` at its root
2011
+ * (§5.6.3). Files under `packs/` and `report/` are hashed in pieces, sharing a
2012
+ * 1 GiB total in path order; a file past what is left reports as too large,
2013
+ * never as a match or absent. Never an input to the reading.
2342
2014
  */
2343
2015
  export async function caseFileReadings(tree, whole, cap = MAX_FOLDER_BYTES) {
2344
2016
  const out = [];
@@ -2371,9 +2043,8 @@ export async function caseFileReadings(tree, whole, cap = MAX_FOLDER_BYTES) {
2371
2043
  .filter((key) => key.startsWith(prefix) && tree.get(key).regular)
2372
2044
  .map((key) => [key, key.slice(prefix.length)])
2373
2045
  .filter(([, rel]) => rel.startsWith("packs/") || rel.startsWith("report/"));
2374
- // The files the index lists are charged first, then the rest, each group
2375
- // in UTF-16 order, so a file it does not list cannot use up the total and
2376
- // turn a real mismatch into `too_large`.
2046
+ // Listed files are charged first, so an unlisted file cannot use up the
2047
+ // total and turn a real mismatch into `too_large`.
2377
2048
  for (const [key, rel] of [
2378
2049
  ...candidates.filter(([, rel]) => listed.has(rel)),
2379
2050
  ...candidates.filter(([, rel]) => !listed.has(rel)),
@@ -2419,25 +2090,23 @@ export async function caseFileReadings(tree, whole, cap = MAX_FOLDER_BYTES) {
2419
2090
  }
2420
2091
  return out;
2421
2092
  }
2422
- // One pack's environment marker, as the key its JSON row carries only where
2423
- // the pack holds an envelope signed `dev`.
2093
+ // The pack's environment marker, present only for a pack with a `dev` envelope.
2424
2094
  function packMarker(bundle) {
2425
2095
  const m = environmentMarker([bundle]);
2426
2096
  return m !== null ? { environment_marker: m } : {};
2427
2097
  }
2428
2098
  function caseError(e, asJson, io) {
2429
2099
  if (asJson)
2430
- outJson(io, jsonText({ error: e.message, exit_code: EXIT_MALFORMED, hint: e.hint }));
2100
+ outJson(io, jsonText({ error: e.message, exit_code: EXIT_COULD_NOT_RUN, hint: e.hint }));
2431
2101
  io.err(noLoneSurrogates(`case: ${e.message}`));
2432
2102
  if (e.hint)
2433
2103
  io.err(noLoneSurrogates(`case: ${e.hint}`));
2434
- return EXIT_MALFORMED;
2104
+ return EXIT_COULD_NOT_RUN;
2435
2105
  }
2436
2106
  /**
2437
- * `proof case <folder>... [--gateways <urls>] [--json]`. Every path out of the
2438
- * command is an exit code from the table: an exception nobody expected exits 2,
2439
- * never 1, because 1 would name the evidence failed over a defect in this
2440
- * command.
2107
+ * `proof case <folder>... [--gateways <urls>] [--json]`. An unexpected
2108
+ * exception exits 4, never 1: 1 would call the evidence failed over a defect in
2109
+ * this command.
2441
2110
  */
2442
2111
  export async function runCase(argv, stream) {
2443
2112
  const io = guard(stream);
@@ -2451,25 +2120,15 @@ export async function runCase(argv, stream) {
2451
2120
  "command, not a verdict on the evidence"), argv.includes("--json"), io);
2452
2121
  }
2453
2122
  catch {
2454
- return EXIT_MALFORMED; // a stream that cannot be written
2123
+ return EXIT_COULD_NOT_RUN; // the stream cannot be written
2455
2124
  }
2456
2125
  }
2457
2126
  }
2458
- // The files that sit outside every pack, as files offered (A41, proof#215).
2459
- //
2460
- // Every regular file the split left outside, in the split's order, hashed
2461
- // exactly as a `--files` file is (a folder's file in pieces, a `.zip` entry
2462
- // from the bytes the reader already holds) and named by its path in the tree,
2463
- // `<case name>/<path inside>`, so a `.zip` and the folder `unzip` makes of it
2464
- // name each file alike. A link, a pipe, an entry the zip reader refused (with
2465
- // its own words) and a file that cannot be read are returned as skipped, named
2466
- // and never opened as a pack file. A pack's own files, `bundle.json` and what
2467
- // is under `logs/`, are not outside and are not offered.
2468
- //
2469
- // The files share one total of 1 GiB, the cap the zip reader puts on an
2470
- // archive, charged in that order. A file larger than what is left is skipped as
2471
- // `too_large` and read no further, so a sparse file of a terabyte in a
2472
- // downloaded folder cannot keep `case` from reaching its verdict.
2127
+ // The files outside every pack, as files offered (§5.7), in split order. Each
2128
+ // is hashed as a `--files` file is and named `<case name>/<path inside>`, so a
2129
+ // `.zip` and its unzipped folder name files alike. Links, pipes, refused zip
2130
+ // entries and unreadable files are returned as skipped. They share the 1 GiB
2131
+ // total; a file past it is `too_large` and read no further.
2473
2132
  export async function looseFiles(tree, split, cap = MAX_FOLDER_BYTES) {
2474
2133
  const offered = [];
2475
2134
  const skipped = [];
@@ -2509,69 +2168,11 @@ export async function looseFiles(tree, split, cap = MAX_FOLDER_BYTES) {
2509
2168
  }
2510
2169
  return { offered, skipped };
2511
2170
  }
2512
- // A policy file that is not JSON: read, and unreadable (`policy_unreadable`).
2513
- const POLICY_NOT_JSON = Symbol("policy not JSON");
2514
- async function readEnclavePolicy(path) {
2515
- if (path === null)
2516
- return null;
2517
- let raw;
2518
- try {
2519
- if (!(await stat(path)).isFile())
2520
- throw new CaseInputError(`--enclave-policy ${path} is not a readable file`);
2521
- raw = new Uint8Array(await readFile(path));
2522
- }
2523
- catch (e) {
2524
- if (e instanceof CaseInputError)
2525
- throw e;
2526
- throw new CaseInputError(`--enclave-policy ${path} is not a readable file`);
2527
- }
2528
- try {
2529
- return parseRefusingDuplicates(new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(raw));
2530
- }
2531
- catch {
2532
- return POLICY_NOT_JSON;
2533
- }
2534
- }
2535
- /** The two line members of `--json`: one entry per run, with the lines it prints. */
2536
- function runLines(runs) {
2537
- const enclave = runs.map((r) => ({
2538
- pack_ref: r.packRef,
2539
- pack_index: r.packIndex,
2540
- event_id: r.eventId,
2541
- runner_public_key: r.runnerPublicKey,
2542
- agent_public_key: r.agentPublicKey,
2543
- state: r.enclave.state,
2544
- kinds: r.enclave.kinds,
2545
- notes: r.enclave.notes,
2546
- reason: r.enclave.reason,
2547
- provider: r.enclave.provider,
2548
- evaluation: r.enclave.evaluation,
2549
- evaluated_at: r.enclave.evaluatedAt,
2550
- token_checked: r.enclave.tokenChecked,
2551
- online: r.online !== null ? { state: r.online.state, kinds: r.online.kinds } : null,
2552
- rendered: renderEnclaveLines(r).map(escapeControl),
2553
- }));
2554
- const account = runs.map((r) => ({
2555
- pack_ref: r.packRef,
2556
- pack_index: r.packIndex,
2557
- event_id: r.eventId,
2558
- runner_public_key: r.runnerPublicKey,
2559
- state: r.account.state,
2560
- findings: r.account.findings.map((f) => ({ kind: f.kind, event_id: f.eventId })),
2561
- notes: r.account.notes.map((n) => ({ kind: n.kind, event_id: n.eventId })),
2562
- closing_event_id: r.account.closingEventId,
2563
- reason: r.account.reason,
2564
- rendered: escapeControl(renderAccountLine(r)),
2565
- }));
2566
- return [enclave, account];
2567
- }
2568
2171
  async function caseCommand(argv, io) {
2569
2172
  let asJson = false;
2570
2173
  let gateways = [];
2571
2174
  const paths = [];
2572
2175
  let filePaths;
2573
- let enclavePolicy = null;
2574
- let enclaveRpc = null;
2575
2176
  for (let i = 0; i < argv.length; i++) {
2576
2177
  const arg = argv[i];
2577
2178
  if ((arg === "--help" || arg === "-h" || arg === "help") && paths.length === 0) {
@@ -2586,7 +2187,7 @@ async function caseCommand(argv, io) {
2586
2187
  if (next === undefined) {
2587
2188
  io.err("case: --gateways requires a comma-separated list of URLs");
2588
2189
  outBlock(io, CASE_USAGE);
2589
- return EXIT_MALFORMED;
2190
+ return EXIT_COULD_NOT_RUN;
2590
2191
  }
2591
2192
  gateways = next
2592
2193
  .split(",")
@@ -2599,28 +2200,15 @@ async function caseCommand(argv, io) {
2599
2200
  if (taken.length === 0) {
2600
2201
  io.err("case: --files requires at least one file path");
2601
2202
  outBlock(io, CASE_USAGE);
2602
- return EXIT_MALFORMED;
2203
+ return EXIT_COULD_NOT_RUN;
2603
2204
  }
2604
2205
  filePaths = [...(filePaths ?? []), ...taken];
2605
2206
  i += taken.length;
2606
2207
  }
2607
- else if (arg === "--enclave-policy" || arg === "--enclave-rpc") {
2608
- const next = argv[i + 1];
2609
- if (next === undefined) {
2610
- io.err(arg === "--enclave-policy" ? "case: --enclave-policy requires a policy file" : "case: --enclave-rpc requires a URL");
2611
- outBlock(io, CASE_USAGE);
2612
- return EXIT_MALFORMED;
2613
- }
2614
- if (arg === "--enclave-policy")
2615
- enclavePolicy = next;
2616
- else
2617
- enclaveRpc = next;
2618
- i++;
2619
- }
2620
2208
  else if (arg.startsWith("--")) {
2621
2209
  io.err(`case: unknown option ${arg}`);
2622
2210
  outBlock(io, CASE_USAGE);
2623
- return EXIT_MALFORMED;
2211
+ return EXIT_COULD_NOT_RUN;
2624
2212
  }
2625
2213
  else {
2626
2214
  paths.push(arg);
@@ -2629,7 +2217,7 @@ async function caseCommand(argv, io) {
2629
2217
  if (paths.length === 0) {
2630
2218
  io.err("case: at least one pack is required");
2631
2219
  outBlock(io, CASE_USAGE);
2632
- return EXIT_MALFORMED;
2220
+ return EXIT_COULD_NOT_RUN;
2633
2221
  }
2634
2222
  let offered;
2635
2223
  let skipped = [];
@@ -2640,30 +2228,22 @@ async function caseCommand(argv, io) {
2640
2228
  offered = read.offered;
2641
2229
  skipped = read.skipped;
2642
2230
  }
2643
- let policy;
2644
- try {
2645
- policy = await readEnclavePolicy(enclavePolicy);
2646
- }
2647
- catch (e) {
2648
- if (e instanceof CaseInputError)
2649
- return caseError(e, asJson, io);
2650
- throw e;
2651
- }
2652
2231
  const whole = { n: MAX_FOLDER_BYTES };
2653
2232
  let split;
2654
2233
  let tree;
2655
- let bundles;
2234
+ let loaded;
2656
2235
  try {
2657
2236
  tree = await treeFromArguments(paths);
2658
2237
  split = await splitCase(tree, (entry, key) => readFileEntry(entry, key, whole));
2659
- bundles = split.packs.map((p) => [p, loadBundle(p)]);
2238
+ loaded = split.packs.map((p) => [p, loadBundle(p)]);
2660
2239
  }
2661
2240
  catch (e) {
2662
2241
  if (e instanceof CaseInputError)
2663
2242
  return caseError(e, asJson, io);
2664
2243
  throw e;
2665
2244
  }
2666
- const reading = await readCase(bundles.map(([p, bundle]) => ({ ref: p.ref, bundle, content: p.content })));
2245
+ const bundles = loaded.map(([p, [bundle]]) => [p, bundle]);
2246
+ const reading = await readCase(loaded.map(([p, [bundle, why]]) => ({ ref: p.ref, bundle, content: p.content, unreadable: why })));
2667
2247
  let caseFile;
2668
2248
  try {
2669
2249
  caseFile = await caseFileReadings(tree, whole);
@@ -2678,25 +2258,63 @@ async function caseCommand(argv, io) {
2678
2258
  for (const [p, bundle] of bundles) {
2679
2259
  caches.push(readWitnessCache(bundle, await witnessCacheBytes(tree, split, p, whole, cacheRefused)));
2680
2260
  }
2681
- const confirmedStamps = [];
2682
- const onChain = await onChainHalf(bundles.map(([p, bundle], i) => [p.ref, bundle, caches[i]]), gateways, { ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}) }, confirmedStamps);
2261
+ const onChain = await onChainHalf(bundles.map(([p, bundle], i) => [p.ref, bundle, caches[i]]), gateways, { ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}) });
2683
2262
  const witness = witnessLine(onChain, caches.flat());
2684
- // Sandbox evidence: over every envelope every pack holds, and per
2685
- // pack for its row. Only a case that would otherwise exit 0 exits 5.
2263
+ // Sandbox evidence, over every pack and per pack for its row. A label, never a
2264
+ // verdict: a case that verifies over a `dev` record exits 3.
2686
2265
  const marker = environmentMarker(bundles.map(([, b]) => b));
2687
- // Undetermined only because a simulated witness cannot be looked up: every
2688
- // other witness confirmed, none unreachable or mismatched, every pack names
2689
- // one. With every record signed `dev` that is sandbox evidence, exit 5.
2690
- const simulatedOnly = reading.verdict === "verified" &&
2691
- onChain.verdict === "undetermined" &&
2692
- onChain.packs.some((r) => (r.simulated ?? 0) > 0) &&
2693
- onChain.packs.every((r) => r.witnesses > 0 && r.unreachable === 0 && r.mismatched === 0 && r.not_checked === (r.simulated ?? 0));
2694
- const code = sandboxExit(caseExitCode(reading.verdict, onChain.verdict), marker, simulatedOnly);
2266
+ // The lookup is outside the reading (it makes network calls the reading never does).
2267
+ // Only a lookup that finds other bytes, or another time, disproves `witnessed`.
2268
+ const problems = [...reading.problems];
2269
+ onChain.packs.forEach((row, index) => {
2270
+ if (row.mismatched > 0)
2271
+ problems.push(problem("witness_differs", [[index, null]]));
2272
+ if ((row.witness_times ?? []).some((t) => t.outcome === "differs")) {
2273
+ problems.push(problem("witness_time_differs", [[index, null]]));
2274
+ }
2275
+ });
2276
+ const inScope = [];
2277
+ const notInScope = {};
2278
+ if (reading.handoffRecords > 0 || reading.packs.some((p) => p.declarationVersion !== null)) {
2279
+ inScope.push("complete");
2280
+ }
2281
+ else {
2282
+ notInScope.complete = "no_declaration_and_no_handoff";
2283
+ }
2284
+ if (reading.handoffRecords > 0)
2285
+ inScope.push("agreed");
2286
+ else
2287
+ notInScope.agreed = "no_handoff";
2288
+ if (reading.handoffRecords > 0 || reading.records.some((r) => r.agent !== null)) {
2289
+ inScope.push("follows_rules");
2290
+ }
2291
+ else {
2292
+ notInScope.follows_rules = "no_agent_or_handoff_records";
2293
+ }
2294
+ const packRefs = reading.packs.map((p) => p.ref);
2295
+ const recordIds = new Map(reading.records.map((r) => [`${r.packIndex}/${r.recordIndex}`, r.eventId]));
2296
+ const claims = claimsOf(problems, inScope, {
2297
+ notInScope,
2298
+ labels: (pack, record) => [
2299
+ pack !== null ? (packRefs[pack] ?? null) : null,
2300
+ recordIds.get(`${pack}/${record}`) ?? null,
2301
+ ],
2302
+ });
2303
+ const verdict = verdictOf(claims);
2304
+ const code = exitCodeFor(verdict.status, marker);
2305
+ const notes = reading.notes.map((n) => note(n.kind, renderCaseNote(n)));
2306
+ notes.push(...findingNotes(reading.records.map((r) => r.agent)));
2307
+ notes.push(...caseFile.flatMap((c) => c.findings.map((f) => note(f.kind, f.rendered))));
2308
+ notes.push(...cacheRefused.map((p) => note("witness_cache_refused", witnessCacheRefusedNote(p))));
2309
+ const person = renderPerson(verdict, claims, orderedNotes(notes), marker, witnessText(witness), witness.state);
2695
2310
  const nil = (v) => (v === undefined ? null : v);
2696
2311
  const data = {
2697
- verdict: reading.verdict,
2698
- title: reading.verdict === "verified" && marker !== null ? SANDBOX_TITLE : titleOf(reading.verdict),
2699
- reliance: caseReliance(reading.verdict, reading.handoffRecords, marker),
2312
+ verdict,
2313
+ title: titleOf(verdict.status),
2314
+ reliance: caseReliance(verdict.status, reading.handoffRecords, marker, verdict.reason_kind ?? null),
2315
+ claims,
2316
+ person,
2317
+ sandbox: marker !== null,
2700
2318
  exit_code: code,
2701
2319
  summary: renderCaseSummary(reading),
2702
2320
  witness: {
@@ -2743,6 +2361,7 @@ async function caseCommand(argv, io) {
2743
2361
  findings: r.findings,
2744
2362
  failed_checks: r.failedChecks,
2745
2363
  counterparts: r.counterparts,
2364
+ agent: r.agent === null ? null : agentJson(r.agent),
2746
2365
  })),
2747
2366
  contributions: reading.contributions.map(([who, verdict]) => [who, verdict]),
2748
2367
  packs: bundles.map(([p], i) => ({
@@ -2764,20 +2383,9 @@ async function caseCommand(argv, io) {
2764
2383
  ...(cacheRefused.length > 0 ? { witness_cache_not_read: cacheRefused } : {}),
2765
2384
  ...(marker !== null ? { environment_marker: marker } : {}),
2766
2385
  };
2767
- // The runs in the drop (DRAFT, specs/enclave-attestation.md and
2768
- // run-records.md): an enclave attestation line and a run-account line per
2769
- // run, beside the witness line. Neither moves `code`, and a drop that holds
2770
- // no run prints and writes exactly what it did before.
2771
- const runs = (await readRuns(bundles.map(([p, bundle]) => ({ ref: p.ref, bundle, content: p.content })), { policy, adapters: await loadInstalledAdapters(), rpc: enclaveRpc, witnessed: confirmedStamps })).runs;
2772
- const withRuns = data;
2773
- if (runs.length > 0)
2774
- [withRuns.enclave_attestation, withRuns.run_account] = runLines(runs);
2775
- // The files outside every pack (evidence-bundle.md 5.7, A29, A41), always,
2776
- // then the files after --files. A pack's logs/ is its side content and is
2777
- // never an offered file; only the paths after --files are. Both are matched
2778
- // by SHA-256 alone and nothing here moves `code`. The section is printed when
2779
- // there is a file to say something about, so a case with nothing outside
2780
- // every pack and no --files prints what it always did.
2386
+ // Files outside every pack, then the `--files` paths (evidence-bundle.md
2387
+ // §5.7). A pack's `logs/` is never offered. Matched by SHA-256 alone; nothing
2388
+ // here moves `code`. Printed only when there is something to say.
2781
2389
  const loose = await looseFiles(tree, split);
2782
2390
  let fileLines = [];
2783
2391
  const out = data;
@@ -2787,9 +2395,8 @@ async function caseCommand(argv, io) {
2787
2395
  skipped: [...loose.skipped, ...skipped],
2788
2396
  };
2789
2397
  out.files = fileMatchToJson(fileReading);
2790
- // No --files: one count line for the recorded files no loose file matched,
2791
- // not a line for each of them, and none for a record the pack's own logs
2792
- // disclose and bind. With --files, and in --json, every one is listed.
2398
+ // Without --files, one count line for unmatched recorded files; with
2399
+ // --files or --json, every one is listed.
2793
2400
  fileLines = renderFileMatch(fileReading, offered !== undefined
2794
2401
  ? undefined
2795
2402
  : (await notAccountedFor(fileReading, new Map(bundles.map(([p]) => [p.ref, p.content])))).length);
@@ -2801,24 +2408,13 @@ async function caseCommand(argv, io) {
2801
2408
  io.out(noLoneSurrogates(line));
2802
2409
  return code;
2803
2410
  }
2804
- function shortTx(tx) {
2805
- if (tx.length <= 16)
2806
- return tx;
2807
- return `${tx.slice(0, 8)}…${tx.slice(-4)}`;
2808
- }
2809
- function shortId(id) {
2810
- if (id.length <= 12)
2811
- return id;
2812
- return `${id.slice(0, 8)}…`;
2813
- }
2814
- // Entry point when run as a binary (not when imported by tests).
2411
+ // Entry point when run as a binary, not when imported.
2815
2412
  const isMain = (() => {
2816
2413
  try {
2817
2414
  if (!process.argv[1])
2818
2415
  return false;
2819
- // realpath BOTH sides: when run via a bin symlink (npx / node_modules/.bin/proof
2820
- // -> dist/cli.js), process.argv[1] is the symlink and import.meta.url is the target,
2821
- // so a naive compare skips main(). Resolving both to the real path makes them match.
2416
+ // Resolve both sides: under a bin symlink, argv[1] is the link and
2417
+ // import.meta.url the target.
2822
2418
  return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
2823
2419
  }
2824
2420
  catch {
@@ -2835,6 +2431,6 @@ if (isMain) {
2835
2431
  })
2836
2432
  .catch((e) => {
2837
2433
  process.stderr.write(`fatal: ${escapeControl(e instanceof Error ? e.stack ?? e.message : String(e))}\n`);
2838
- process.exitCode = EXIT_MALFORMED;
2434
+ process.exitCode = EXIT_COULD_NOT_RUN;
2839
2435
  });
2840
2436
  }