@sanning/proof 0.18.0 → 0.19.1

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.
package/dist/cli.js CHANGED
@@ -17,6 +17,8 @@
17
17
  // could not be reached reads `partial`; a pack whose records nobody hashed
18
18
  // reads `incomplete` (see below)
19
19
  // 4 mixed
20
+ // 5 sandbox: a record is signed environment dev; would be 0, or only a
21
+ // simulated witness is unchecked (`sandboxExit`)
20
22
  // `sanningd verify-status` in sanning-io/agent mirrored the 0.12 table, which
21
23
  // exited 0 on `partial`; it moves with this one.
22
24
  //
@@ -47,13 +49,15 @@ import { createHash, randomBytes } from "node:crypto";
47
49
  import { basename, resolve, sep } from "node:path";
48
50
  import { fileURLToPath } from "node:url";
49
51
  import { isAgentProofSpec, verifyAgentProof } from "./agent-proof.js";
50
- import { CHAIN_READING_SCOPE, readChains, renderChainFinding } from "./chain.js";
52
+ import { CHAIN_READING_SCOPE, readChainLinks, readChains, renderChainFinding } from "./chain.js";
51
53
  import { VERDICT_DISPLAY_TITLES, caseReliance, readCase, renderCaseNote, renderCaseSummary, renderException, renderWitnessLine, } from "./case.js";
52
54
  import { CASE_FILE_INDEX_NAME, caseFileListedPaths, jsonDepthExceeds, readCaseFileIndex, renderCaseFileFinding, unreadableCaseFileIndex, } from "./case-file.js";
53
55
  import { MAX_ZIP_ARCHIVE_BYTES, MAX_ZIP_BYTES, ZipReadError, readZip } from "./zip-reader.js";
54
56
  import { composeExport } from "./compose.js";
55
57
  import { isHexField, sha256Hex, utf8 } from "./crypto.js";
56
58
  import { verifyEvidenceBundle } from "./evidence.js";
59
+ import { readRuns, renderAccountLine, renderEnclaveLines } from "./run.js";
60
+ import { loadInstalledAdapters } from "./run-adapters.js";
57
61
  import { DuplicateKeyError, parseRefusingDuplicates } from "./verifier.js";
58
62
  import { fileMatchToJson, matchCaseFiles, matchFiles, notAccountedFor, renderFileMatch, } from "./file-match.js";
59
63
  import { WITNESS_CACHE_NAME, readWitnessCache, } from "./witness-cache.js";
@@ -65,7 +69,8 @@ import { WITNESS_CACHE_NAME, readWitnessCache, } from "./witness-cache.js";
65
69
  import { onChainHalf, witnessLine } from "./witness-lookup.js";
66
70
  import { checkDeclaration, renderDeclarationSummary, renderNote } from "./declaration.js";
67
71
  import { completenessNotes, gradePack, renderCause } from "./grading.js";
68
- import { EXIT_CODES_HELP, EXIT_FAILED, EXIT_MALFORMED, EXIT_UNDETERMINED, EXIT_VERIFIED, exitCodeFor, } from "./exit-codes.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";
69
74
  // ── What a person reads is escaped at one choke point ──
70
75
  //
71
76
  // Every human-readable line this command prints passes through `guard`, which
@@ -326,7 +331,7 @@ async function runVerify(rest, io, sink) {
326
331
  gateways,
327
332
  ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
328
333
  });
329
- return reportAgentProof(result, bundlePath, gateways, io, sink);
334
+ return reportAgentProof(result, bundlePath, gateways, io, sink, parsed);
330
335
  }
331
336
  // Default: treat anything else as an evidence bundle. verifyEvidenceBundle
332
337
  // rejects an unknown major as "malformed" so a genuinely unknown shape still
@@ -338,8 +343,11 @@ async function runVerify(rest, io, sink) {
338
343
  });
339
344
  // Read the chains from the SAME parsed bundle the verifier saw. Findings are
340
345
  // evidence about the records in front of us, not a verdict: they are printed
341
- // and they never move `exit`, because a chain is not a completeness proof and
342
- // an exit code is exactly the kind of thing people read as one.
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.
343
351
  //
344
352
  // A READING THAT COULD NOT BE PRODUCED SAYS SO. This was an unguarded call:
345
353
  // a throw out of `readChains` would have escaped the command entirely and
@@ -355,7 +363,17 @@ async function runVerify(rest, io, sink) {
355
363
  catch (e) {
356
364
  chains = { unreadable: errMsg(e) };
357
365
  }
358
- const exit = reportEvidence(result, bundlePath, gateways, io, chains, parsed, logs, sink);
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.
369
+ let links;
370
+ try {
371
+ links = "unreadable" in chains ? null : await readChainLinks(parsed);
372
+ }
373
+ catch {
374
+ links = null;
375
+ }
376
+ const exit = reportEvidence(result, bundlePath, gateways, io, chains, links, parsed, logs, sink);
359
377
  // Files handed in beside the pack (evidence-bundle.md 5.7, A29): findings
360
378
  // beside the verdict, never inside it, so `exit` is untouched.
361
379
  if (offered !== undefined) {
@@ -1234,7 +1252,7 @@ function cliDisclosureAccount(result, logs) {
1234
1252
  extra: (logs?.suppliedIds ?? []).filter((id) => !known.has(id)),
1235
1253
  };
1236
1254
  }
1237
- function reportEvidence(result, path, gateways, io, chains, parsed, logs, sink) {
1255
+ function reportEvidence(result, path, gateways, io, chains, links, parsed, logs, sink) {
1238
1256
  if (result.status === "malformed") {
1239
1257
  if (sink)
1240
1258
  sink.data = verifyJson(result, null, null, null);
@@ -1324,10 +1342,24 @@ function reportEvidence(result, path, gateways, io, chains, parsed, logs, sink)
1324
1342
  // their verdicts rather than a new check. A pack that DOES
1325
1343
  // carry a declaration this reader cannot read is a different case and exits
1326
1344
  // 3: the holder chose that field, so UNKNOWN is the honest answer.
1327
- const declaration = checkDeclaration(parsed, logs?.content);
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);
1328
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);
1329
1361
  io.out("");
1330
- printRollup(grade.verdict, result.status, result.assertedStatus, gateways, result.onChainChecked, io);
1362
+ printRollup(grade.verdict, result.status, result.assertedStatus, gateways, result.onChainChecked, io, marker, exit === EXIT_SANDBOX, simulatedOnlyRun(result, gateways));
1331
1363
  io.out(paint(` declaration: ${renderDeclarationSummary(declaration)}`, declaration.verdict === "failed" ? R : declaration.verdict === "verified" ? DIM : Y));
1332
1364
  for (const n of declaration.notes)
1333
1365
  io.out(paint(` note: ${renderNote(n)}`, Y));
@@ -1346,15 +1378,15 @@ function reportEvidence(result, path, gateways, io, chains, parsed, logs, sink)
1346
1378
  io.out(paint(` logs: ${grade.recordsVerified}/${result.events.length} record(s) content-verified`, DIM));
1347
1379
  }
1348
1380
  if (sink)
1349
- sink.data = verifyJson(result, grade, declaration, chains);
1350
- return mapGradeToExit(grade);
1381
+ sink.data = verifyJson(result, grade, declaration, chains, marker);
1382
+ return exit;
1351
1383
  }
1352
1384
  /**
1353
1385
  * What `verify --json` prints for an evidence bundle, the same keys and the
1354
1386
  * same words as `_verify_json` in the Python twin. `grade`, `declaration` and
1355
1387
  * `chains` are null for a bundle no verdict could be rendered over.
1356
1388
  */
1357
- function verifyJson(result, grade, declaration, chains) {
1389
+ function verifyJson(result, grade, declaration, chains, marker = null) {
1358
1390
  let chainBlock = null;
1359
1391
  if (chains !== null) {
1360
1392
  chainBlock =
@@ -1380,6 +1412,9 @@ function verifyJson(result, grade, declaration, chains) {
1380
1412
  ? { presented: result.events.length, content_verified: grade.recordsVerified }
1381
1413
  : null,
1382
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 } : {}),
1383
1418
  };
1384
1419
  }
1385
1420
  /**
@@ -1440,7 +1475,8 @@ function reportExport(exp, io) {
1440
1475
  }
1441
1476
  }
1442
1477
  }
1443
- function reportAgentProof(result, path, gateways, io, sink) {
1478
+ function reportAgentProof(result, path, gateways, io, sink, parsed) {
1479
+ const marker = result.status === "malformed" ? null : environmentMarker([parsed]);
1444
1480
  if (sink) {
1445
1481
  sink.data = {
1446
1482
  verdict: result.status,
@@ -1448,6 +1484,7 @@ function reportAgentProof(result, path, gateways, io, sink) {
1448
1484
  spec_version: "sanning.agent.proof/v1",
1449
1485
  on_chain_checked: result.onChainChecked,
1450
1486
  notes: [...result.errors],
1487
+ ...(marker !== null ? { environment_marker: marker } : {}),
1451
1488
  };
1452
1489
  }
1453
1490
  if (result.status === "malformed") {
@@ -1464,17 +1501,30 @@ function reportAgentProof(result, path, gateways, io, sink) {
1464
1501
  io.out("");
1465
1502
  // An agent inclusion proof carries no records and no disclosed bytes, so
1466
1503
  // there is no pack to grade: the bundle status IS the headline here.
1467
- printRollup(result.status, result.status, null, gateways, result.onChainChecked, io);
1504
+ const exit = sandboxExit(exitCodeFor(result.status), marker);
1505
+ printRollup(result.status, result.status, null, gateways, result.onChainChecked, io, marker, exit === EXIT_SANDBOX);
1468
1506
  for (const e of result.errors)
1469
1507
  io.out(paint(` note: ${e}`, DIM));
1470
- return exitCodeFor(result.status);
1508
+ return exit;
1471
1509
  }
1472
- function printRollup(headline, bundleStatus, asserted, gateways, onChainChecked, io) {
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.
1473
1517
  const scope = onChainChecked
1474
1518
  ? `verified offline + on-chain against ${gateways.length} gateway(s)`
1475
- : "verified offline (no gateway re-fetch requested)";
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)";
1476
1522
  let head;
1477
- switch (headline) {
1523
+ switch (sandbox ? "sandbox" : headline) {
1524
+ // Sandbox evidence (exit 5) never prints VERIFIED.
1525
+ case "sandbox":
1526
+ head = paint(SANDBOX_HEADLINE, Y);
1527
+ break;
1478
1528
  case "verified":
1479
1529
  head = paint("VERIFIED", G);
1480
1530
  break;
@@ -1492,6 +1542,8 @@ function printRollup(headline, bundleStatus, asserted, gateways, onChainChecked,
1492
1542
  head = paint(headline.toUpperCase(), R);
1493
1543
  }
1494
1544
  io.out(`${head} ${paint(scope, DIM)}`);
1545
+ if (marker !== null)
1546
+ io.out(paint(` ${SANDBOX_LINE}`, Y));
1495
1547
  // Both verdicts, whenever they differ, because they answer different
1496
1548
  // questions: §3 grades the signed bundle, the pack verdict grades the bundle
1497
1549
  // plus whatever bytes travelled beside it. A pack can be incomplete while the
@@ -2072,7 +2124,7 @@ async function unzip(arg, prefix, tree, seen) {
2072
2124
  // 5.5.4 v1.16, A17), so it reads `failed` and exits 1.
2073
2125
  export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict over one pack or many, offline
2074
2126
 
2075
- sanning-proof case <folder or case file>... [--gateways <url,url>] [--files <path>...] [--json]
2127
+ sanning-proof case <folder or case file>... [--gateways <url,url>] [--files <path>...] [--enclave-policy <file>] [--enclave-rpc <url>] [--json]
2076
2128
 
2077
2129
  <folder> a pack folder (bundle.json plus logs/), a folder that holds
2078
2130
  several packs, or an unzipped case file. Packs are found by the
@@ -2110,6 +2162,17 @@ export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict ove
2110
2162
  contribution, the packs, each with its index, the files outside
2111
2163
  them, the case file index comparison, the witness line and the
2112
2164
  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.
2113
2176
 
2114
2177
  Files outside every pack are matched too, always, so
2115
2178
  sanning-proof case audit-file.zip checks a whole download in one command: each
@@ -2184,12 +2247,21 @@ const titleOf = (word) => Object.prototype.hasOwnProperty.call(VERDICT_DISPLAY_T
2184
2247
  * scope sentence and the witness line are printed with it every time.
2185
2248
  */
2186
2249
  function caseReport(data) {
2187
- const lines = [
2188
- data.title,
2189
- ` ${data.summary}`,
2190
- ` ${data.witness.rendered}`,
2191
- ` ${data.reliance}`,
2192
- ];
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}`);
2193
2265
  for (const e of data.exceptions)
2194
2266
  lines.push(` - ${e.rendered}`);
2195
2267
  for (const n of data.notes)
@@ -2197,7 +2269,11 @@ function caseReport(data) {
2197
2269
  lines.push(`packs ${data.packs.length}`);
2198
2270
  for (const p of data.packs) {
2199
2271
  const key = p.signing_key !== null ? p.signing_key : "a key this reader cannot read";
2200
- const grade = p.grade !== null ? titleOf(p.grade) : "not graded";
2272
+ const grade = p.grade === null
2273
+ ? "not graded"
2274
+ : p.grade === "verified" && p.environment_marker !== undefined
2275
+ ? SANDBOX_TITLE
2276
+ : titleOf(p.grade);
2201
2277
  // Each record's status, counted per pack (A43): the JSON lists every one.
2202
2278
  const mine = data.records.filter((r) => r.pack_index === p.index);
2203
2279
  const count = (status) => mine.filter((r) => r.status === status).length;
@@ -2235,13 +2311,23 @@ function caseReport(data) {
2235
2311
  }
2236
2312
  const oc = data.on_chain;
2237
2313
  if (oc.checked) {
2238
- lines.push(`on-chain looked up against ${oc.gateways.length} gateway(s), ` +
2239
- "pack by pack; the reading above is offline and this does not " +
2240
- "change it");
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".
2316
+ const simulated = oc.packs.reduce((n, r) => n + (r.simulated ?? 0), 0);
2317
+ const onlySimulated = simulated > 0 &&
2318
+ oc.packs.every((r) => (r.simulated ?? 0) === r.witnesses && (r.witness_times ?? []).length === 0);
2319
+ lines.push(onlySimulated
2320
+ ? "on-chain nothing looked up: every witness here is simulated, " +
2321
+ "which is not on a chain; the reading above is offline and this " +
2322
+ "does not change it"
2323
+ : `on-chain looked up against ${oc.gateways.length} gateway(s), ` +
2324
+ "pack by pack; the reading above is offline and this does not " +
2325
+ "change it");
2241
2326
  for (const r of oc.packs) {
2327
+ const sim = r.simulated ? `, ${r.simulated} of them simulated (not on a chain)` : "";
2242
2328
  lines.push(` ${r.ref} ${r.witnesses} witness(es): ${r.confirmed} ` +
2243
2329
  `confirmed, ${r.mismatched} mismatched, ${r.unreachable} ` +
2244
- `unreachable, ${r.not_checked} not checked`);
2330
+ `unreachable, ${r.not_checked} not checked${sim}`);
2245
2331
  }
2246
2332
  }
2247
2333
  return lines;
@@ -2333,6 +2419,12 @@ export async function caseFileReadings(tree, whole, cap = MAX_FOLDER_BYTES) {
2333
2419
  }
2334
2420
  return out;
2335
2421
  }
2422
+ // One pack's environment marker, as the key its JSON row carries only where
2423
+ // the pack holds an envelope signed `dev`.
2424
+ function packMarker(bundle) {
2425
+ const m = environmentMarker([bundle]);
2426
+ return m !== null ? { environment_marker: m } : {};
2427
+ }
2336
2428
  function caseError(e, asJson, io) {
2337
2429
  if (asJson)
2338
2430
  outJson(io, jsonText({ error: e.message, exit_code: EXIT_MALFORMED, hint: e.hint }));
@@ -2417,11 +2509,69 @@ export async function looseFiles(tree, split, cap = MAX_FOLDER_BYTES) {
2417
2509
  }
2418
2510
  return { offered, skipped };
2419
2511
  }
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
+ }
2420
2568
  async function caseCommand(argv, io) {
2421
2569
  let asJson = false;
2422
2570
  let gateways = [];
2423
2571
  const paths = [];
2424
2572
  let filePaths;
2573
+ let enclavePolicy = null;
2574
+ let enclaveRpc = null;
2425
2575
  for (let i = 0; i < argv.length; i++) {
2426
2576
  const arg = argv[i];
2427
2577
  if ((arg === "--help" || arg === "-h" || arg === "help") && paths.length === 0) {
@@ -2454,6 +2604,19 @@ async function caseCommand(argv, io) {
2454
2604
  filePaths = [...(filePaths ?? []), ...taken];
2455
2605
  i += taken.length;
2456
2606
  }
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
+ }
2457
2620
  else if (arg.startsWith("--")) {
2458
2621
  io.err(`case: unknown option ${arg}`);
2459
2622
  outBlock(io, CASE_USAGE);
@@ -2477,6 +2640,15 @@ async function caseCommand(argv, io) {
2477
2640
  offered = read.offered;
2478
2641
  skipped = read.skipped;
2479
2642
  }
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
+ }
2480
2652
  const whole = { n: MAX_FOLDER_BYTES };
2481
2653
  let split;
2482
2654
  let tree;
@@ -2506,14 +2678,25 @@ async function caseCommand(argv, io) {
2506
2678
  for (const [p, bundle] of bundles) {
2507
2679
  caches.push(readWitnessCache(bundle, await witnessCacheBytes(tree, split, p, whole, cacheRefused)));
2508
2680
  }
2509
- const onChain = await onChainHalf(bundles.map(([p, bundle], i) => [p.ref, bundle, caches[i]]), gateways, { ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}) });
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);
2510
2683
  const witness = witnessLine(onChain, caches.flat());
2511
- const code = caseExitCode(reading.verdict, onChain.verdict);
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.
2686
+ 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);
2512
2695
  const nil = (v) => (v === undefined ? null : v);
2513
2696
  const data = {
2514
2697
  verdict: reading.verdict,
2515
- title: titleOf(reading.verdict),
2516
- reliance: caseReliance(reading.verdict, reading.handoffRecords),
2698
+ title: reading.verdict === "verified" && marker !== null ? SANDBOX_TITLE : titleOf(reading.verdict),
2699
+ reliance: caseReliance(reading.verdict, reading.handoffRecords, marker),
2517
2700
  exit_code: code,
2518
2701
  summary: renderCaseSummary(reading),
2519
2702
  witness: {
@@ -2572,13 +2755,23 @@ async function caseCommand(argv, io) {
2572
2755
  grade: reading.packs[i].grade,
2573
2756
  records_withheld: reading.packs[i].recordsWithheld,
2574
2757
  declaration_version: reading.packs[i].declarationVersion,
2758
+ ...packMarker(bundles[i][1]),
2575
2759
  })),
2576
2760
  outside: split.outside,
2577
2761
  not_regular: split.notRegular,
2578
2762
  case_file: caseFile,
2579
2763
  on_chain: onChain,
2580
2764
  ...(cacheRefused.length > 0 ? { witness_cache_not_read: cacheRefused } : {}),
2765
+ ...(marker !== null ? { environment_marker: marker } : {}),
2581
2766
  };
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);
2582
2775
  // The files outside every pack (evidence-bundle.md 5.7, A29, A41), always,
2583
2776
  // then the files after --files. A pack's logs/ is its side content and is
2584
2777
  // never an offered file; only the paths after --files are. Both are matched
@@ -1,3 +1,4 @@
1
+ import type { ChainLink } from "./chain.js";
1
2
  import { type HandoffRecord } from "./handoff.js";
2
3
  import { type SideContentMap } from "./side-content.js";
3
4
  /** The first declaration schema, which every earlier reader knows. */
@@ -8,8 +9,15 @@ export declare const DECLARATION_VERSION = "sanning.declaration/v1";
8
9
  * it cannot read, which is the safe direction.
9
10
  */
10
11
  export declare const DECLARATION_VERSION_V2 = "sanning.declaration/v2";
12
+ /**
13
+ * v2 plus a signed `chains[]`: the `chain_key`s the holder claims are complete
14
+ * and in order (evidence-bundle.md 5.4.1 v1.36, A45). A reader that knows only
15
+ * v1 and v2 reads a v3 declaration as one it cannot read, which is the safe
16
+ * direction.
17
+ */
18
+ export declare const DECLARATION_VERSION_V3 = "sanning.declaration/v3";
11
19
  /** Every declaration schema this reader knows, in the order they were added. */
12
- export declare const DECLARATION_VERSIONS: readonly ["sanning.declaration/v1", "sanning.declaration/v2"];
20
+ export declare const DECLARATION_VERSIONS: readonly ["sanning.declaration/v1", "sanning.declaration/v2", "sanning.declaration/v3"];
13
21
  /**
14
22
  * What a v2 declaration may sign a record IS, compared exactly: `handoff`, a
15
23
  * record 4.1 governs that the pack claims as a side in `handoffs[]`, or
@@ -20,7 +28,7 @@ export type DeclarationRole = (typeof DECLARATION_ROLES)[number];
20
28
  /** The three states a declaration check can reach, from the §3 vocabulary. */
21
29
  export declare const DECLARATION_VERDICTS: readonly ["verified", "failed", "partial"];
22
30
  export type DeclarationVerdict = (typeof DECLARATION_VERDICTS)[number];
23
- export declare const DECLARATION_DEVIATION_KINDS: readonly ["declaration_malformed", "declared_absent", "undeclared_present", "withheld_but_disclosed", "duplicate_event_id", "pair_record_undeclared", "pair_record_not_a_handoff", "role_handoff_unclaimed", "role_step_claimed", "role_step_governed", "role_step_unreadable"];
31
+ export declare const DECLARATION_DEVIATION_KINDS: readonly ["declaration_malformed", "declared_absent", "undeclared_present", "withheld_but_disclosed", "duplicate_event_id", "pair_record_undeclared", "pair_record_not_a_handoff", "role_handoff_unclaimed", "role_step_claimed", "role_step_governed", "role_step_unreadable", "chain_second_root", "chain_fork", "chain_link_dangling", "chain_claimed_absent"];
24
32
  export type DeclarationDeviationKind = (typeof DECLARATION_DEVIATION_KINDS)[number];
25
33
  /**
26
34
  * Every way a pack and the UNSIGNED content handed in beside it fail to line
@@ -60,7 +68,7 @@ export interface DeclarationContentFinding {
60
68
  eventId?: string;
61
69
  }
62
70
  /** Everything this check reports that is NOT a deviation, as a closed set. */
63
- export declare const DECLARATION_NOTE_KINDS: readonly ["disclosure_did_not_travel", "pair_record_withheld", "role_step_withheld", "other_declaration_held", "other_declaration_withheld"];
71
+ export declare const DECLARATION_NOTE_KINDS: readonly ["disclosure_did_not_travel", "pair_record_withheld", "role_step_withheld", "other_declaration_held", "other_declaration_withheld", "chain_claim_withheld"];
64
72
  export type DeclarationNoteKind = (typeof DECLARATION_NOTE_KINDS)[number];
65
73
  /**
66
74
  * One named non-deviation finding, as data.
@@ -79,6 +87,8 @@ export interface DeclarationNote {
79
87
  side?: "sent" | "received";
80
88
  /** Which claimed hand-off, by position, for `pair_record_withheld`. */
81
89
  pairIndex?: number;
90
+ /** The claimed chain, for `chain_claim_withheld`. */
91
+ chainKey?: string;
82
92
  }
83
93
  /** One deviation, as data. A surface renders its own prose or calls `renderDeviation`. */
84
94
  export interface DeclarationDeviation {
@@ -97,6 +107,8 @@ export interface DeclarationDeviation {
97
107
  * the first one this reader learned.
98
108
  */
99
109
  version?: string;
110
+ /** The claimed chain, for the four rule 11 `chain_` deviations. */
111
+ chainKey?: string;
100
112
  }
101
113
  export interface DeclarationCheck {
102
114
  verdict: DeclarationVerdict;
@@ -137,6 +149,12 @@ export interface DeclarationCheck {
137
149
  eventId: string;
138
150
  role: DeclarationRole;
139
151
  }[];
152
+ /**
153
+ * Every `chain_key` a v3 declaration claims is complete and in order, in
154
+ * declaration order. Empty for v1 and v2, and for anything this reader could
155
+ * not read.
156
+ */
157
+ chains?: string[];
140
158
  }
141
159
  /**
142
160
  * One of the pack's own records: the id, and whether the pack supplied its
@@ -172,6 +190,19 @@ export interface PackRecord {
172
190
  * two different readings and never one.
173
191
  */
174
192
  bytesSupplied: boolean;
193
+ /**
194
+ * Whether this record's payload binding is UNDETERMINED: no `event_bytes`
195
+ * string and no inline envelope `payload`, the two things the envelope
196
+ * verifier binds `payload_hash` against. Rule 11 excuses a dangling link
197
+ * only for such a record's producing key.
198
+ */
199
+ bindingUndetermined: boolean;
200
+ /**
201
+ * The envelope's signed `payload_hash` as written, or `""`. For a record
202
+ * whose binding is undetermined it is the identity a successor's pointer
203
+ * names: rule 11 excuses a dangling link only where the pointer equals it.
204
+ */
205
+ payloadHash: string;
175
206
  }
176
207
  /**
177
208
  * The pack's own records, in bundle order. Exported so the CASE layer
@@ -189,11 +220,19 @@ export declare function packRecords(bundle: unknown, content: SideContentMap | n
189
220
  * and a pack that discloses them beside itself passes the map a consumer
190
221
  * already built.
191
222
  *
223
+ * `chains` is `readChains` over the SAME bundle. Only a v3 declaration that
224
+ * claims a chain reads it (rule 11). It is an argument rather than computed
225
+ * here because `readChains` is asynchronous and this check is not, and Python's
226
+ * `check_declaration` takes it the same way so the two kernels share one
227
+ * contract. A v3 declaration that claims a chain, checked without it, reads as
228
+ * a reader that does not know v3 reads it: `partial`, nothing named, and the
229
+ * pack `incomplete`. Leaving it out can never read `verified`.
230
+ *
192
231
  * The verdict is this check's own. It does not restate the bundle verdict and
193
232
  * it does not read it: a declaration can match perfectly over a pack whose
194
233
  * signatures are forged, and saying so is `gradePack`'s job, which takes both.
195
234
  */
196
- export declare function checkDeclaration(bundle: unknown, content?: SideContentMap | null): DeclarationCheck;
235
+ export declare function checkDeclaration(bundle: unknown, content?: SideContentMap | null, links?: readonly ChainLink[] | null): DeclarationCheck;
197
236
  /**
198
237
  * The house wording for one deviation.
199
238
  *