@sanning/proof 0.18.0 → 0.19.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.
package/src/cli.ts 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
  //
@@ -50,12 +52,12 @@ import { fileURLToPath } from "node:url";
50
52
 
51
53
  import { isAgentProofSpec, verifyAgentProof } from "./agent-proof.js";
52
54
  import type { AgentProofResult } from "./agent-proof.js";
53
- import { CHAIN_READING_SCOPE, readChains, renderChainFinding } from "./chain.js";
55
+ import { CHAIN_READING_SCOPE, readChainLinks, readChains, renderChainFinding } from "./chain.js";
54
56
 
55
57
  // What this command holds after asking for a chain reading: the reading, or a
56
58
  // named statement that one could not be produced. Three states, not two.
57
59
  type ChainOutcome = ChainReading | { unreadable: string };
58
- import type { ChainReading } from "./chain.js";
60
+ import type { ChainLink, ChainReading } from "./chain.js";
59
61
  import {
60
62
  VERDICT_DISPLAY_TITLES,
61
63
  caseReliance,
@@ -78,6 +80,9 @@ import { composeExport } from "./compose.js";
78
80
  import type { ComposeExportOptions } from "./compose.js";
79
81
  import { isHexField, sha256Hex, utf8 } from "./crypto.js";
80
82
  import { verifyEvidenceBundle } from "./evidence.js";
83
+ import { readRuns, renderAccountLine, renderEnclaveLines } from "./run.js";
84
+ import type { RunReading } from "./run.js";
85
+ import { loadInstalledAdapters } from "./run-adapters.js";
81
86
  import { DuplicateKeyError, parseRefusingDuplicates } from "./verifier.js";
82
87
  import {
83
88
  fileMatchToJson,
@@ -107,10 +112,21 @@ import {
107
112
  EXIT_CODES_HELP,
108
113
  EXIT_FAILED,
109
114
  EXIT_MALFORMED,
115
+ EXIT_SANDBOX,
110
116
  EXIT_UNDETERMINED,
111
117
  EXIT_VERIFIED,
112
118
  exitCodeFor,
119
+ sandboxExit,
113
120
  } from "./exit-codes.js";
121
+ import {
122
+ type EnvironmentMarker,
123
+ environmentMarker,
124
+ onlySimulatedUnsettled,
125
+ simulatedOnlyRun,
126
+ SANDBOX_HEADLINE,
127
+ SANDBOX_LINE,
128
+ SANDBOX_TITLE,
129
+ } from "./environment.js";
114
130
 
115
131
  interface Cli {
116
132
  out: (s: string) => void;
@@ -416,7 +432,7 @@ async function runVerify(rest: string[], io: Cli, sink: JsonSink | null): Promis
416
432
  gateways,
417
433
  ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
418
434
  });
419
- return reportAgentProof(result, bundlePath, gateways, io, sink);
435
+ return reportAgentProof(result, bundlePath, gateways, io, sink, parsed);
420
436
  }
421
437
 
422
438
  // Default: treat anything else as an evidence bundle. verifyEvidenceBundle
@@ -429,8 +445,11 @@ async function runVerify(rest: string[], io: Cli, sink: JsonSink | null): Promis
429
445
  });
430
446
  // Read the chains from the SAME parsed bundle the verifier saw. Findings are
431
447
  // evidence about the records in front of us, not a verdict: they are printed
432
- // and they never move `exit`, because a chain is not a completeness proof and
433
- // an exit code is exactly the kind of thing people read as one.
448
+ // and they move `exit` only through the pack's own declaration, where a v3
449
+ // declaration claims a chain complete and in order and a finding under that
450
+ // claim contradicts it (rule 11). Without a claim a chain is not a
451
+ // completeness proof, and an exit code is exactly the kind of thing people
452
+ // read as one.
434
453
  //
435
454
  // A READING THAT COULD NOT BE PRODUCED SAYS SO. This was an unguarded call:
436
455
  // a throw out of `readChains` would have escaped the command entirely and
@@ -445,7 +464,16 @@ async function runVerify(rest: string[], io: Cli, sink: JsonSink | null): Promis
445
464
  } catch (e) {
446
465
  chains = { unreadable: errMsg(e) };
447
466
  }
448
- const exit = reportEvidence(result, bundlePath, gateways, io, chains, parsed, logs, sink);
467
+ // The links rule 11 holds a claimed chain to (evidence-bundle.md 5.4.2), from
468
+ // the same parsed bundle. Links that cannot be read are none, which reads a
469
+ // claim UNKNOWN and never clean.
470
+ let links: ChainLink[] | null;
471
+ try {
472
+ links = "unreadable" in chains ? null : await readChainLinks(parsed);
473
+ } catch {
474
+ links = null;
475
+ }
476
+ const exit = reportEvidence(result, bundlePath, gateways, io, chains, links, parsed, logs, sink);
449
477
  // Files handed in beside the pack (evidence-bundle.md 5.7, A29): findings
450
478
  // beside the verdict, never inside it, so `exit` is untouched.
451
479
  if (offered !== undefined) {
@@ -1397,6 +1425,7 @@ function reportEvidence(
1397
1425
  gateways: string[],
1398
1426
  io: Cli,
1399
1427
  chains: ChainOutcome,
1428
+ links: ChainLink[] | null,
1400
1429
  parsed: unknown,
1401
1430
  logs: LoadedLogs | undefined,
1402
1431
  sink: JsonSink | null,
@@ -1503,7 +1532,12 @@ function reportEvidence(
1503
1532
  // their verdicts rather than a new check. A pack that DOES
1504
1533
  // carry a declaration this reader cannot read is a different case and exits
1505
1534
  // 3: the holder chose that field, so UNKNOWN is the honest answer.
1506
- const declaration = checkDeclaration(parsed, logs?.content);
1535
+ //
1536
+ // A v3 declaration that claims a chain is checked against this command's
1537
+ // own chain links (rule 11, evidence-bundle.md 5.4.2 v1.36, A45). Links
1538
+ // that could not be read are passed as none, which reads the claim UNKNOWN
1539
+ // and never clean.
1540
+ const declaration = checkDeclaration(parsed, logs?.content, links);
1507
1541
  const grade = gradePack(
1508
1542
  result,
1509
1543
  cliDisclosureAccount(result, logs),
@@ -1511,8 +1545,34 @@ function reportEvidence(
1511
1545
  declaration,
1512
1546
  );
1513
1547
 
1548
+ // Sandbox evidence: read from the signed envelopes the pack holds.
1549
+ const marker = environmentMarker([parsed]);
1550
+ // A pack whose records are all signed `dev`, undetermined only because a
1551
+ // simulated witness cannot be looked up, would grade `verified` with that
1552
+ // witness accepted: it is sandbox evidence, exit 5, never 3.
1553
+ const simulatedOnly =
1554
+ grade.verdict === "partial" &&
1555
+ onlySimulatedUnsettled(result) &&
1556
+ gradePack(
1557
+ { ...result, status: "verified" },
1558
+ cliDisclosureAccount(result, logs),
1559
+ completenessNotes(parsed),
1560
+ declaration,
1561
+ ).verdict === "verified";
1562
+ const exit = sandboxExit(mapGradeToExit(grade), marker, simulatedOnly);
1563
+
1514
1564
  io.out("");
1515
- printRollup(grade.verdict, result.status, result.assertedStatus, gateways, result.onChainChecked, io);
1565
+ printRollup(
1566
+ grade.verdict,
1567
+ result.status,
1568
+ result.assertedStatus,
1569
+ gateways,
1570
+ result.onChainChecked,
1571
+ io,
1572
+ marker,
1573
+ exit === EXIT_SANDBOX,
1574
+ simulatedOnlyRun(result, gateways),
1575
+ );
1516
1576
  io.out(
1517
1577
  paint(
1518
1578
  ` declaration: ${renderDeclarationSummary(declaration)}`,
@@ -1539,8 +1599,8 @@ function reportEvidence(
1539
1599
  );
1540
1600
  }
1541
1601
 
1542
- if (sink) sink.data = verifyJson(result, grade, declaration, chains);
1543
- return mapGradeToExit(grade);
1602
+ if (sink) sink.data = verifyJson(result, grade, declaration, chains, marker);
1603
+ return exit;
1544
1604
  }
1545
1605
 
1546
1606
  /**
@@ -1553,6 +1613,7 @@ function verifyJson(
1553
1613
  grade: PackGrade | null,
1554
1614
  declaration: ReturnType<typeof checkDeclaration> | null,
1555
1615
  chains: ChainOutcome | null,
1616
+ marker: EnvironmentMarker | null = null,
1556
1617
  ): Record<string, unknown> {
1557
1618
  let chainBlock: Record<string, unknown> | null = null;
1558
1619
  if (chains !== null) {
@@ -1579,6 +1640,9 @@ function verifyJson(
1579
1640
  ? { presented: result.events.length, content_verified: grade.recordsVerified }
1580
1641
  : null,
1581
1642
  chains: chainBlock,
1643
+ // Present only where the pack holds an envelope signed `dev`
1644
+ // (envelope-spec §2 MAY), so a production pack's JSON is unchanged.
1645
+ ...(marker !== null ? { environment_marker: marker } : {}),
1582
1646
  };
1583
1647
  }
1584
1648
 
@@ -1661,7 +1725,9 @@ function reportAgentProof(
1661
1725
  gateways: string[],
1662
1726
  io: Cli,
1663
1727
  sink: JsonSink | null,
1728
+ parsed: unknown,
1664
1729
  ): number {
1730
+ const marker = result.status === "malformed" ? null : environmentMarker([parsed]);
1665
1731
  if (sink) {
1666
1732
  sink.data = {
1667
1733
  verdict: result.status,
@@ -1669,6 +1735,7 @@ function reportAgentProof(
1669
1735
  spec_version: "sanning.agent.proof/v1",
1670
1736
  on_chain_checked: result.onChainChecked,
1671
1737
  notes: [...result.errors],
1738
+ ...(marker !== null ? { environment_marker: marker } : {}),
1672
1739
  };
1673
1740
  }
1674
1741
  if (result.status === "malformed") {
@@ -1687,10 +1754,11 @@ function reportAgentProof(
1687
1754
  io.out("");
1688
1755
  // An agent inclusion proof carries no records and no disclosed bytes, so
1689
1756
  // there is no pack to grade: the bundle status IS the headline here.
1690
- printRollup(result.status, result.status, null, gateways, result.onChainChecked, io);
1757
+ const exit = sandboxExit(exitCodeFor(result.status), marker);
1758
+ printRollup(result.status, result.status, null, gateways, result.onChainChecked, io, marker, exit === EXIT_SANDBOX);
1691
1759
  for (const e of result.errors) io.out(paint(` note: ${e}`, DIM));
1692
1760
 
1693
- return exitCodeFor(result.status);
1761
+ return exit;
1694
1762
  }
1695
1763
 
1696
1764
  function printRollup(
@@ -1700,12 +1768,27 @@ function printRollup(
1700
1768
  gateways: string[],
1701
1769
  onChainChecked: boolean,
1702
1770
  io: Cli,
1771
+ marker: EnvironmentMarker | null = null,
1772
+ sandbox = false,
1773
+ simulatedOnly = false,
1703
1774
  ): void {
1775
+ // Three scopes, not two. A run with gateways whose only witnesses are
1776
+ // simulated looked nothing up (`simulatedOnlyRun`): saying "on-chain" there
1777
+ // claims a check that never happened, and saying "no gateway re-fetch
1778
+ // requested" denies the flag the reader passed. Every other run that is not
1779
+ // counted as checked on a chain, a run that stopped early among them, keeps
1780
+ // the second scope as before.
1704
1781
  const scope = onChainChecked
1705
1782
  ? `verified offline + on-chain against ${gateways.length} gateway(s)`
1706
- : "verified offline (no gateway re-fetch requested)";
1783
+ : simulatedOnly
1784
+ ? "verified offline; a simulated witness is not on a chain, so nothing was looked up"
1785
+ : "verified offline (no gateway re-fetch requested)";
1707
1786
  let head: string;
1708
- switch (headline) {
1787
+ switch (sandbox ? "sandbox" : headline) {
1788
+ // Sandbox evidence (exit 5) never prints VERIFIED.
1789
+ case "sandbox":
1790
+ head = paint(SANDBOX_HEADLINE, Y);
1791
+ break;
1709
1792
  case "verified":
1710
1793
  head = paint("VERIFIED", G);
1711
1794
  break;
@@ -1723,6 +1806,7 @@ function printRollup(
1723
1806
  head = paint(headline.toUpperCase(), R);
1724
1807
  }
1725
1808
  io.out(`${head} ${paint(scope, DIM)}`);
1809
+ if (marker !== null) io.out(paint(` ${SANDBOX_LINE}`, Y));
1726
1810
  // Both verdicts, whenever they differ, because they answer different
1727
1811
  // questions: §3 grades the signed bundle, the pack verdict grades the bundle
1728
1812
  // plus whatever bytes travelled beside it. A pack can be incomplete while the
@@ -2379,7 +2463,7 @@ async function unzip(
2379
2463
 
2380
2464
  export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict over one pack or many, offline
2381
2465
 
2382
- sanning-proof case <folder or case file>... [--gateways <url,url>] [--files <path>...] [--json]
2466
+ sanning-proof case <folder or case file>... [--gateways <url,url>] [--files <path>...] [--enclave-policy <file>] [--enclave-rpc <url>] [--json]
2383
2467
 
2384
2468
  <folder> a pack folder (bundle.json plus logs/), a folder that holds
2385
2469
  several packs, or an unzipped case file. Packs are found by the
@@ -2417,6 +2501,17 @@ export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict ove
2417
2501
  contribution, the packs, each with its index, the files outside
2418
2502
  them, the case file index comparison, the witness line and the
2419
2503
  on-chain half.
2504
+ --enclave-policy
2505
+ a sanning.enclave-policy/v1 file: what this reader accepts of
2506
+ an enclave attestation. Where the packs hold a runner's run, an
2507
+ enclave attestation line and a run-account line print beside
2508
+ the witness line (DRAFT); neither moves the verdict or the exit
2509
+ code. Without a policy, or without an adapter for the provider
2510
+ installed, the line reads not_checked.
2511
+ --enclave-rpc
2512
+ a chain JSON-RPC endpoint: a flare-fcc run also gets the online
2513
+ line, read from the contracts the policy names. It moves
2514
+ nothing either.
2420
2515
 
2421
2516
  Files outside every pack are matched too, always, so
2422
2517
  sanning-proof case audit-file.zip checks a whole download in one command: each
@@ -2499,6 +2594,8 @@ interface CaseData {
2499
2594
  reliance: string;
2500
2595
  summary: string;
2501
2596
  witness: { rendered: string };
2597
+ enclave_attestation?: { rendered: string[] }[];
2598
+ run_account?: { rendered: string }[];
2502
2599
  exceptions: { rendered: string }[];
2503
2600
  notes: { rendered: string }[];
2504
2601
  packs: {
@@ -2508,8 +2605,10 @@ interface CaseData {
2508
2605
  unaccounted: string[];
2509
2606
  signing_key: string | null;
2510
2607
  grade: string | null;
2608
+ environment_marker?: EnvironmentMarker;
2511
2609
  }[];
2512
2610
  records: { pack_index: number; status: string }[];
2611
+ environment_marker?: EnvironmentMarker;
2513
2612
  outside: string[];
2514
2613
  not_regular: string[];
2515
2614
  case_file: CaseFileData[];
@@ -2528,18 +2627,30 @@ const titleOf = (word: string): string =>
2528
2627
  * scope sentence and the witness line are printed with it every time.
2529
2628
  */
2530
2629
  function caseReport(data: CaseData): string[] {
2531
- const lines = [
2532
- data.title,
2533
- ` ${data.summary}`,
2534
- ` ${data.witness.rendered}`,
2535
- ` ${data.reliance}`,
2536
- ];
2630
+ const lines = [data.title];
2631
+ // Sandbox evidence: printed with every headline the marker is on.
2632
+ if (data.environment_marker !== undefined) lines.push(` ${SANDBOX_LINE}`);
2633
+ lines.push(` ${data.summary}`, ` ${data.witness.rendered}`);
2634
+ // Where the packs hold a runner's run: its enclave attestation line and its
2635
+ // run-account line, beside the witness line.
2636
+ const enclave = data.enclave_attestation ?? [];
2637
+ const account = data.run_account ?? [];
2638
+ for (let i = 0; i < Math.min(enclave.length, account.length); i++) {
2639
+ for (const line of enclave[i]!.rendered) lines.push(` ${line}`);
2640
+ lines.push(` ${account[i]!.rendered}`);
2641
+ }
2642
+ lines.push(` ${data.reliance}`);
2537
2643
  for (const e of data.exceptions) lines.push(` - ${e.rendered}`);
2538
2644
  for (const n of data.notes) lines.push(` note: ${n.rendered}`);
2539
2645
  lines.push(`packs ${data.packs.length}`);
2540
2646
  for (const p of data.packs) {
2541
2647
  const key = p.signing_key !== null ? p.signing_key : "a key this reader cannot read";
2542
- const grade = p.grade !== null ? titleOf(p.grade) : "not graded";
2648
+ const grade =
2649
+ p.grade === null
2650
+ ? "not graded"
2651
+ : p.grade === "verified" && p.environment_marker !== undefined
2652
+ ? SANDBOX_TITLE
2653
+ : titleOf(p.grade);
2543
2654
  // Each record's status, counted per pack (A43): the JSON lists every one.
2544
2655
  const mine = data.records.filter((r) => r.pack_index === p.index);
2545
2656
  const count = (status: string) => mine.filter((r) => r.status === status).length;
@@ -2581,16 +2692,27 @@ function caseReport(data: CaseData): string[] {
2581
2692
  }
2582
2693
  const oc = data.on_chain;
2583
2694
  if (oc.checked) {
2695
+ // A drop whose only witnesses are simulated had nothing looked up on a
2696
+ // chain, and the header says so rather than "looked up against".
2697
+ const simulated = oc.packs.reduce((n, r) => n + (r.simulated ?? 0), 0);
2698
+ const onlySimulated =
2699
+ simulated > 0 &&
2700
+ oc.packs.every((r) => (r.simulated ?? 0) === r.witnesses && (r.witness_times ?? []).length === 0);
2584
2701
  lines.push(
2585
- `on-chain looked up against ${oc.gateways.length} gateway(s), ` +
2586
- "pack by pack; the reading above is offline and this does not " +
2587
- "change it",
2702
+ onlySimulated
2703
+ ? "on-chain nothing looked up: every witness here is simulated, " +
2704
+ "which is not on a chain; the reading above is offline and this " +
2705
+ "does not change it"
2706
+ : `on-chain looked up against ${oc.gateways.length} gateway(s), ` +
2707
+ "pack by pack; the reading above is offline and this does not " +
2708
+ "change it",
2588
2709
  );
2589
2710
  for (const r of oc.packs) {
2711
+ const sim = r.simulated ? `, ${r.simulated} of them simulated (not on a chain)` : "";
2590
2712
  lines.push(
2591
2713
  ` ${r.ref} ${r.witnesses} witness(es): ${r.confirmed} ` +
2592
2714
  `confirmed, ${r.mismatched} mismatched, ${r.unreachable} ` +
2593
- `unreachable, ${r.not_checked} not checked`,
2715
+ `unreachable, ${r.not_checked} not checked${sim}`,
2594
2716
  );
2595
2717
  }
2596
2718
  }
@@ -2681,6 +2803,13 @@ export async function caseFileReadings(
2681
2803
  return out as CaseFileData[];
2682
2804
  }
2683
2805
 
2806
+ // One pack's environment marker, as the key its JSON row carries only where
2807
+ // the pack holds an envelope signed `dev`.
2808
+ function packMarker(bundle: unknown): { environment_marker?: EnvironmentMarker } {
2809
+ const m = environmentMarker([bundle]);
2810
+ return m !== null ? { environment_marker: m } : {};
2811
+ }
2812
+
2684
2813
  function caseError(e: CaseInputError, asJson: boolean, io: Cli): number {
2685
2814
  if (asJson) outJson(io, jsonText({ error: e.message, exit_code: EXIT_MALFORMED, hint: e.hint }));
2686
2815
  io.err(noLoneSurrogates(`case: ${e.message}`));
@@ -2769,11 +2898,67 @@ export async function looseFiles(
2769
2898
  return { offered, skipped };
2770
2899
  }
2771
2900
 
2901
+ // A policy file that is not JSON: read, and unreadable (`policy_unreadable`).
2902
+ const POLICY_NOT_JSON = Symbol("policy not JSON");
2903
+
2904
+ async function readEnclavePolicy(path: string | null): Promise<unknown> {
2905
+ if (path === null) return null;
2906
+ let raw: Uint8Array;
2907
+ try {
2908
+ if (!(await stat(path)).isFile()) throw new CaseInputError(`--enclave-policy ${path} is not a readable file`);
2909
+ raw = new Uint8Array(await readFile(path));
2910
+ } catch (e) {
2911
+ if (e instanceof CaseInputError) throw e;
2912
+ throw new CaseInputError(`--enclave-policy ${path} is not a readable file`);
2913
+ }
2914
+ try {
2915
+ return parseRefusingDuplicates(new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(raw));
2916
+ } catch {
2917
+ return POLICY_NOT_JSON;
2918
+ }
2919
+ }
2920
+
2921
+ /** The two line members of `--json`: one entry per run, with the lines it prints. */
2922
+ function runLines(runs: readonly RunReading[]): [Record<string, unknown>[], Record<string, unknown>[]] {
2923
+ const enclave = runs.map((r) => ({
2924
+ pack_ref: r.packRef,
2925
+ pack_index: r.packIndex,
2926
+ event_id: r.eventId,
2927
+ runner_public_key: r.runnerPublicKey,
2928
+ agent_public_key: r.agentPublicKey,
2929
+ state: r.enclave.state,
2930
+ kinds: r.enclave.kinds,
2931
+ notes: r.enclave.notes,
2932
+ reason: r.enclave.reason,
2933
+ provider: r.enclave.provider,
2934
+ evaluation: r.enclave.evaluation,
2935
+ evaluated_at: r.enclave.evaluatedAt,
2936
+ token_checked: r.enclave.tokenChecked,
2937
+ online: r.online !== null ? { state: r.online.state, kinds: r.online.kinds } : null,
2938
+ rendered: renderEnclaveLines(r).map(escapeControl),
2939
+ }));
2940
+ const account = runs.map((r) => ({
2941
+ pack_ref: r.packRef,
2942
+ pack_index: r.packIndex,
2943
+ event_id: r.eventId,
2944
+ runner_public_key: r.runnerPublicKey,
2945
+ state: r.account.state,
2946
+ findings: r.account.findings.map((f) => ({ kind: f.kind, event_id: f.eventId })),
2947
+ notes: r.account.notes.map((n) => ({ kind: n.kind, event_id: n.eventId })),
2948
+ closing_event_id: r.account.closingEventId,
2949
+ reason: r.account.reason,
2950
+ rendered: escapeControl(renderAccountLine(r)),
2951
+ }));
2952
+ return [enclave, account];
2953
+ }
2954
+
2772
2955
  async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
2773
2956
  let asJson = false;
2774
2957
  let gateways: string[] = [];
2775
2958
  const paths: string[] = [];
2776
2959
  let filePaths: string[] | undefined;
2960
+ let enclavePolicy: string | null = null;
2961
+ let enclaveRpc: string | null = null;
2777
2962
  for (let i = 0; i < argv.length; i++) {
2778
2963
  const arg = argv[i]!;
2779
2964
  if ((arg === "--help" || arg === "-h" || arg === "help") && paths.length === 0) {
@@ -2803,6 +2988,16 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
2803
2988
  }
2804
2989
  filePaths = [...(filePaths ?? []), ...taken];
2805
2990
  i += taken.length;
2991
+ } else if (arg === "--enclave-policy" || arg === "--enclave-rpc") {
2992
+ const next = argv[i + 1];
2993
+ if (next === undefined) {
2994
+ io.err(arg === "--enclave-policy" ? "case: --enclave-policy requires a policy file" : "case: --enclave-rpc requires a URL");
2995
+ outBlock(io, CASE_USAGE);
2996
+ return EXIT_MALFORMED;
2997
+ }
2998
+ if (arg === "--enclave-policy") enclavePolicy = next;
2999
+ else enclaveRpc = next;
3000
+ i++;
2806
3001
  } else if (arg.startsWith("--")) {
2807
3002
  io.err(`case: unknown option ${arg}`);
2808
3003
  outBlock(io, CASE_USAGE);
@@ -2825,6 +3020,13 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
2825
3020
  offered = read.offered;
2826
3021
  skipped = read.skipped;
2827
3022
  }
3023
+ let policy: unknown;
3024
+ try {
3025
+ policy = await readEnclavePolicy(enclavePolicy);
3026
+ } catch (e) {
3027
+ if (e instanceof CaseInputError) return caseError(e, asJson, io);
3028
+ throw e;
3029
+ }
2828
3030
 
2829
3031
  const whole = { n: MAX_FOLDER_BYTES };
2830
3032
  let split;
@@ -2854,18 +3056,33 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
2854
3056
  for (const [p, bundle] of bundles) {
2855
3057
  caches.push(readWitnessCache(bundle, await witnessCacheBytes(tree, split, p, whole, cacheRefused)));
2856
3058
  }
3059
+ const confirmedStamps: string[][] = [];
2857
3060
  const onChain = await onChainHalf(
2858
3061
  bundles.map(([p, bundle], i): [string, unknown, CachedWitnessTime[]] => [p.ref, bundle, caches[i]!]),
2859
3062
  gateways,
2860
3063
  { ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}) },
3064
+ confirmedStamps,
2861
3065
  );
2862
3066
  const witness = witnessLine(onChain, caches.flat());
2863
- const code = caseExitCode(reading.verdict, onChain.verdict);
3067
+ // Sandbox evidence: over every envelope every pack holds, and per
3068
+ // pack for its row. Only a case that would otherwise exit 0 exits 5.
3069
+ const marker = environmentMarker(bundles.map(([, b]) => b));
3070
+ // Undetermined only because a simulated witness cannot be looked up: every
3071
+ // other witness confirmed, none unreachable or mismatched, every pack names
3072
+ // one. With every record signed `dev` that is sandbox evidence, exit 5.
3073
+ const simulatedOnly =
3074
+ reading.verdict === "verified" &&
3075
+ onChain.verdict === "undetermined" &&
3076
+ onChain.packs.some((r) => (r.simulated ?? 0) > 0) &&
3077
+ onChain.packs.every(
3078
+ (r) => r.witnesses > 0 && r.unreachable === 0 && r.mismatched === 0 && r.not_checked === (r.simulated ?? 0),
3079
+ );
3080
+ const code = sandboxExit(caseExitCode(reading.verdict, onChain.verdict), marker, simulatedOnly);
2864
3081
  const nil = <T>(v: T | undefined): T | null => (v === undefined ? null : v);
2865
3082
 
2866
3083
  const data = {
2867
3084
  verdict: reading.verdict,
2868
- title: titleOf(reading.verdict),
3085
+ title: reading.verdict === "verified" && marker !== null ? SANDBOX_TITLE : titleOf(reading.verdict),
2869
3086
  reliance: caseReliance(reading.verdict, reading.handoffRecords),
2870
3087
  exit_code: code,
2871
3088
  summary: renderCaseSummary(reading),
@@ -2925,13 +3142,27 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
2925
3142
  grade: reading.packs[i]!.grade,
2926
3143
  records_withheld: reading.packs[i]!.recordsWithheld,
2927
3144
  declaration_version: reading.packs[i]!.declarationVersion,
3145
+ ...packMarker(bundles[i]![1]),
2928
3146
  })),
2929
3147
  outside: split.outside,
2930
3148
  not_regular: split.notRegular,
2931
3149
  case_file: caseFile,
2932
3150
  on_chain: onChain,
2933
3151
  ...(cacheRefused.length > 0 ? { witness_cache_not_read: cacheRefused } : {}),
3152
+ ...(marker !== null ? { environment_marker: marker } : {}),
2934
3153
  };
3154
+ // The runs in the drop (DRAFT, specs/enclave-attestation.md and
3155
+ // run-records.md): an enclave attestation line and a run-account line per
3156
+ // run, beside the witness line. Neither moves `code`, and a drop that holds
3157
+ // no run prints and writes exactly what it did before.
3158
+ const runs = (
3159
+ await readRuns(
3160
+ bundles.map(([p, bundle]) => ({ ref: p.ref, bundle, content: p.content })),
3161
+ { policy, adapters: await loadInstalledAdapters(), rpc: enclaveRpc, witnessed: confirmedStamps },
3162
+ )
3163
+ ).runs;
3164
+ const withRuns: Record<string, unknown> = data;
3165
+ if (runs.length > 0) [withRuns.enclave_attestation, withRuns.run_account] = runLines(runs);
2935
3166
  // The files outside every pack (evidence-bundle.md 5.7, A29, A41), always,
2936
3167
  // then the files after --files. A pack's logs/ is its side content and is
2937
3168
  // never an offered file; only the paths after --files are. Both are matched