@sentropic/h2a 0.96.1 → 0.97.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 (102) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +98 -29
  5. package/dist/cli.js.map +1 -1
  6. package/dist/index.d.ts +12 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +6 -1
  9. package/dist/index.js.map +1 -1
  10. package/dist/runtime/drive/cluster-mesh-outer.d.ts +44 -0
  11. package/dist/runtime/drive/cluster-mesh-outer.d.ts.map +1 -0
  12. package/dist/runtime/drive/cluster-mesh-outer.js +70 -0
  13. package/dist/runtime/drive/cluster-mesh-outer.js.map +1 -0
  14. package/dist/runtime/drive/inbox-wake.d.ts +5 -0
  15. package/dist/runtime/drive/inbox-wake.d.ts.map +1 -1
  16. package/dist/runtime/drive/inbox-wake.js +4 -1
  17. package/dist/runtime/drive/inbox-wake.js.map +1 -1
  18. package/dist/runtime/drive/index.d.ts +4 -0
  19. package/dist/runtime/drive/index.d.ts.map +1 -1
  20. package/dist/runtime/drive/index.js +12 -0
  21. package/dist/runtime/drive/index.js.map +1 -1
  22. package/dist/runtime/drive/pty-actuator.d.ts +54 -0
  23. package/dist/runtime/drive/pty-actuator.d.ts.map +1 -0
  24. package/dist/runtime/drive/pty-actuator.js +371 -0
  25. package/dist/runtime/drive/pty-actuator.js.map +1 -0
  26. package/dist/runtime/local-files/paths.js +1 -1
  27. package/dist/runtime/local-files/paths.js.map +1 -1
  28. package/dist/runtime/local-files/presence.d.ts.map +1 -1
  29. package/dist/runtime/local-files/presence.js +10 -1
  30. package/dist/runtime/local-files/presence.js.map +1 -1
  31. package/dist/runtime/loop/engine/adapters.d.ts +7 -1
  32. package/dist/runtime/loop/engine/adapters.d.ts.map +1 -1
  33. package/dist/runtime/loop/engine/adapters.js +43 -11
  34. package/dist/runtime/loop/engine/adapters.js.map +1 -1
  35. package/dist/runtime/loop/engine/decision.d.ts +5 -1
  36. package/dist/runtime/loop/engine/decision.d.ts.map +1 -1
  37. package/dist/runtime/loop/engine/decision.js +9 -3
  38. package/dist/runtime/loop/engine/decision.js.map +1 -1
  39. package/dist/runtime/loop/index.d.ts +2 -0
  40. package/dist/runtime/loop/index.d.ts.map +1 -1
  41. package/dist/runtime/loop/index.js +11 -3
  42. package/dist/runtime/loop/index.js.map +1 -1
  43. package/dist/runtime/mcp/agent-launch.d.ts +2 -1
  44. package/dist/runtime/mcp/agent-launch.d.ts.map +1 -1
  45. package/dist/runtime/mcp/agent-launch.js +51 -8
  46. package/dist/runtime/mcp/agent-launch.js.map +1 -1
  47. package/dist/runtime/mcp/handlers.d.ts.map +1 -1
  48. package/dist/runtime/mcp/handlers.js +17 -0
  49. package/dist/runtime/mcp/handlers.js.map +1 -1
  50. package/dist/runtime/mcp/stdio.d.ts +2 -0
  51. package/dist/runtime/mcp/stdio.d.ts.map +1 -1
  52. package/dist/runtime/mcp/stdio.js +8 -5
  53. package/dist/runtime/mcp/stdio.js.map +1 -1
  54. package/dist/runtime/mcp/tools.d.ts.map +1 -1
  55. package/dist/runtime/mcp/tools.js +19 -6
  56. package/dist/runtime/mcp/tools.js.map +1 -1
  57. package/dist/runtime/memory/admit-client.d.ts +30 -0
  58. package/dist/runtime/memory/admit-client.d.ts.map +1 -0
  59. package/dist/runtime/memory/admit-client.js +44 -0
  60. package/dist/runtime/memory/admit-client.js.map +1 -0
  61. package/dist/runtime/memory/d11-ceremony.d.ts +731 -0
  62. package/dist/runtime/memory/d11-ceremony.d.ts.map +1 -0
  63. package/dist/runtime/memory/d11-ceremony.js +1282 -0
  64. package/dist/runtime/memory/d11-ceremony.js.map +1 -0
  65. package/dist/runtime/memory/graphify-contracts-v2.vendored.d.ts +922 -0
  66. package/dist/runtime/memory/graphify-contracts-v2.vendored.d.ts.map +1 -0
  67. package/dist/runtime/memory/graphify-contracts-v2.vendored.js +2 -0
  68. package/dist/runtime/memory/graphify-contracts-v2.vendored.js.map +1 -0
  69. package/dist/runtime/memory/note-builder.d.ts +83 -0
  70. package/dist/runtime/memory/note-builder.d.ts.map +1 -0
  71. package/dist/runtime/memory/note-builder.js +89 -0
  72. package/dist/runtime/memory/note-builder.js.map +1 -0
  73. package/dist/runtime/memory/port-v1.d.ts +235 -0
  74. package/dist/runtime/memory/port-v1.d.ts.map +1 -0
  75. package/dist/runtime/memory/port-v1.js +129 -0
  76. package/dist/runtime/memory/port-v1.js.map +1 -0
  77. package/dist/runtime/memory/port-v2-adapter.d.ts +117 -0
  78. package/dist/runtime/memory/port-v2-adapter.d.ts.map +1 -0
  79. package/dist/runtime/memory/port-v2-adapter.js +140 -0
  80. package/dist/runtime/memory/port-v2-adapter.js.map +1 -0
  81. package/dist/runtime/memory/port-v2-compat.d.ts +50 -0
  82. package/dist/runtime/memory/port-v2-compat.d.ts.map +1 -0
  83. package/dist/runtime/memory/port-v2-compat.js +102 -0
  84. package/dist/runtime/memory/port-v2-compat.js.map +1 -0
  85. package/dist/runtime/memory/preflight.d.ts +17 -0
  86. package/dist/runtime/memory/preflight.d.ts.map +1 -0
  87. package/dist/runtime/memory/preflight.js +18 -0
  88. package/dist/runtime/memory/preflight.js.map +1 -0
  89. package/dist/runtime/memory/promote-client.d.ts +172 -0
  90. package/dist/runtime/memory/promote-client.d.ts.map +1 -0
  91. package/dist/runtime/memory/promote-client.js +168 -0
  92. package/dist/runtime/memory/promote-client.js.map +1 -0
  93. package/dist/runtime/memory/recall-client.d.ts +74 -0
  94. package/dist/runtime/memory/recall-client.d.ts.map +1 -0
  95. package/dist/runtime/memory/recall-client.js +91 -0
  96. package/dist/runtime/memory/recall-client.js.map +1 -0
  97. package/dist/session.d.ts +4 -0
  98. package/dist/session.d.ts.map +1 -1
  99. package/dist/session.js +7 -0
  100. package/dist/session.js.map +1 -1
  101. package/package.json +7 -4
  102. package/skills/h2a-run/SKILL.md +39 -6
@@ -0,0 +1,1282 @@
1
+ /**
2
+ * D11 ceremony orchestrator — WP11 slice 5 (build brief). Anti-fabrication
3
+ * FIXED in two rounds, each closing a hole an independent review NO-GO'd.
4
+ * See "D11 FIX — ROUND 1" and "D11 FIX — ROUND 2" below for what changed, in
5
+ * order, and why; the rest of this doc describes the ceremony's unchanged
6
+ * shape.
7
+ *
8
+ * Composes the double-consensus END TO END: launch the two independent legs,
9
+ * collect their INLINE verdicts, gate on slice 3's `checkDoubleConsensusPreconditions`
10
+ * (the SAME structural check, not a reimplementation) as a cheap pre-write
11
+ * reject, write the verdict + attestation artifacts, READ THEM BACK from a
12
+ * REAL, construction-time-bound durable store and cryptographically verify
13
+ * them (ROUND 1 + ROUND 2 together), and only then dispatch through
14
+ * `promoteNoteWithDoubleConsensus` — now fed the READ, VERIFIED verdicts,
15
+ * never the caller-controlled inline ones.
16
+ *
17
+ * SCOPE (bounded), same shape as the rest of `runtime/memory/`: the actual
18
+ * model-leg launching and the verdict/attestation FILE WRITES are INJECTED
19
+ * (`deps.launchLeg`, `deps.writeVerdict`, `deps.writeAttestation`,
20
+ * `deps.port`) and stubbed in tests — never implemented for real here. This
21
+ * module is the ORCHESTRATION only: what gets called, in what order, and
22
+ * what refuses the ceremony before the next step ever runs. Real Ed25519
23
+ * signature verification (`verifySignature`, the default), the concrete
24
+ * `trustedKeystore` backing store, AND (ROUND 2) the default verdict-artifact
25
+ * READER (`readVerdict`, a real path-bound filesystem reader) ARE
26
+ * implemented for real — crypto, and "read back exactly what is actually
27
+ * persisted", are not "out of scope, inject a stub" concerns the way
28
+ * launching a model or writing a file are. The keystore's actual key
29
+ * material/distribution and the durable store's actual deployment location
30
+ * remain a WP5 concern (see ROUND 1 §1 and ROUND 2 below).
31
+ *
32
+ * ===========================================================================
33
+ * D11 FIX — ROUND 1 — the anti-fabrication anchor (signature verification).
34
+ * ===========================================================================
35
+ *
36
+ * THE HOLE (proven by an independent review): before this fix, `launchLeg`
37
+ * returned verdicts INLINE (caller-controlled), `writeVerdict` returned ref
38
+ * strings that were NEVER READ BACK, and the consensus gate checked only the
39
+ * caller-supplied inline verdicts. A `writeVerdict` that returns invented ref
40
+ * strings without ever creating a file still reached `promoteNote` with
41
+ * `promoted: true` — a caller fabricating "2 GO" verdicts as plain JavaScript
42
+ * objects could promote a note with no real evidence anywhere. This is the
43
+ * classic fabricated-verdict hole: an assertion was trusted where a document
44
+ * should have been demanded.
45
+ *
46
+ * THE FIX, in three parts:
47
+ *
48
+ * 1. `createD11Ceremony({ trustedKeystore, verifySignature? })` — a FACTORY
49
+ * that CLOSES OVER the trusted keystore (and, optionally, the signature
50
+ * verifier). It returns the actual `runD11Ceremony(input, deps)` function.
51
+ * This is the load-bearing design choice: `trustedKeystore` is a
52
+ * CONSTRUCTION-TIME input, never part of the per-call `RunD11CeremonyDeps`.
53
+ * At the time of THIS round, `readVerdict` and `launchLeg` were BOTH still
54
+ * per-call (caller-injected, untrusted); if signature verification used a
55
+ * per-call-supplied keystore too, a caller could simply hand the ceremony
56
+ * ITS OWN keystore (mapping its own throwaway key to whatever leg it
57
+ * likes) and sign its own fabrication — the hole would just move one layer
58
+ * down. The keystore must come from a trusted CONSTRUCTION site (a
59
+ * conductor/bootstrap that wires `createD11Ceremony` once, not a value an
60
+ * arbitrary per-call caller of the returned `runD11Ceremony` can swap).
61
+ * `trustedKeystore` is documented here as the LOCAL anchor for today
62
+ * (an in-process map/interface from leg identity to an Ed25519 public
63
+ * key); it is designed to be swapped later for the WP5 system-wide
64
+ * identity keystore WITHOUT a rewrite of this module — `D11TrustedKeystore`
65
+ * is a one-method lookup interface (`getPublicKey(leg)`) precisely so a
66
+ * different backing store (network-fetched, rotated, cross-repo) can
67
+ * implement it later; the ceremony's own logic never changes.
68
+ *
69
+ * 2. `readVerdict(ref) => Promise<VerdictArtifact | null>` — at THIS round, a
70
+ * per-call dep. It OPENS+READS the verdict artifact actually persisted at
71
+ * `ref` (or returns `null` if absent/unreadable — the exact shape of the
72
+ * proven counter-example: a `writeVerdict` that never created a file).
73
+ * Distinct from `MemoryVerdict` (slice 3's in-memory, caller-asserted
74
+ * shape): `VerdictArtifact` additionally carries a `signature` — proof the
75
+ * artifact was actually produced by whoever holds the claimed leg's
76
+ * private key, not merely narrated by whoever called this ceremony.
77
+ * ROUND 2 (below) moves this dep to construction-time too — read that
78
+ * section for why leaving it per-call was itself still a hole.
79
+ *
80
+ * 3. The gate (fail-closed at every step, `promoteNote` NEVER reached on any
81
+ * failure): after `launchLeg` → `writeVerdict` produce the two refs, for
82
+ * EACH ref —
83
+ * a. `readVerdict(ref)` → REFUSE if `null` (closes the proven
84
+ * counter-example structurally: an invented ref with no real file).
85
+ * b. Look up the trusted public key for the artifact's OWN CLAIMED
86
+ * `leg` (`trustedKeystore.getPublicKey(artifact.leg)`) — REFUSE if
87
+ * the keystore has no entry (unknown leg OR an empty keystore; a
88
+ * missing key is never treated as "skip verification", it is always
89
+ * a refusal — I5 fail-closed applies to the keystore lookup too).
90
+ * Verify the artifact's `signature` against THAT key over the exact
91
+ * payload `{noteId, verdict, leg, at}` — REFUSE if invalid. Because
92
+ * the key is looked up BY THE CLAIMED LEG, a verdict that declares
93
+ * leg A but was actually signed with leg B's key (even though B's
94
+ * key IS in the keystore) fails here: leg A's public key cannot
95
+ * verify a signature produced by leg B's private key.
96
+ * c. REFUSE unless the READ `verdict === "GO"`.
97
+ * d. REFUSE unless the READ `noteId` equals the note actually being
98
+ * promoted (`input.note.noteId`) — ANTI-REPLAY. The signed payload
99
+ * in (b) already binds `noteId` into what the signature covers, so a
100
+ * genuinely-signed verdict for note X cannot be replayed to promote
101
+ * note Y: either its `noteId` fails this pin (this check), or an
102
+ * attacker who edits the `noteId` field to match Y invalidates the
103
+ * signature (check (b) fires instead). Either way: REFUSE.
104
+ * Then, across the two READ artifacts (off the READ content only, never
105
+ * the inline verdicts or the legSpecs):
106
+ * e. REFUSE if the two artifacts' `leg`s are not structurally distinct.
107
+ * f. REFUSE if either artifact's `leg.session === authorId` (separation
108
+ * of powers, re-checked off what was actually read — a `readVerdict`
109
+ * that returns a different leg than the one that was launched is
110
+ * still caught here).
111
+ * Then, per artifact again:
112
+ * g. REFUSE unless the READ content is COHERENT with the INLINE verdict
113
+ * `launchLeg` returned for that same position (`noteId`, `verdict`,
114
+ * `leg` must all match) — a mismatch means what got signed/persisted
115
+ * disagrees with what the leg claimed inline, which is refused
116
+ * rather than silently preferring one over the other.
117
+ * Only if ALL of (a)-(g) pass for both legs: the FINAL verdicts and
118
+ * attestation fed to `promoteNoteWithDoubleConsensus` are built from the
119
+ * READ, VERIFIED artifacts (never the inline ones) — so even a bug in this
120
+ * module's own gate logic cannot cause a fabricated inline verdict to
121
+ * reach the port, because the inline verdicts are never what gets
122
+ * promoted. `promoteNoteWithDoubleConsensus` re-runs
123
+ * `checkDoubleConsensusPreconditions` on that verified data as an
124
+ * unconditional second layer (unchanged from slice 3) before ever
125
+ * touching the port.
126
+ *
127
+ * ===========================================================================
128
+ * D11 FIX — ROUND 2 (this build) — `readVerdict` moves to construction time.
129
+ * ===========================================================================
130
+ *
131
+ * THE HOLE ROUND 1 LEFT (proven by an independent review, a SECOND, separate
132
+ * leg from the one that forced ROUND 1): ROUND 1 moved `trustedKeystore` and
133
+ * `verifySignature` to construction-time, but left `readVerdict` as a
134
+ * PER-CALL, caller-injected dep. An adversarial per-call caller can inject a
135
+ * `readVerdict` that returns a NON-NULL, TRUSTED-SIGNED artifact that exists
136
+ * ONLY in memory — a ref string pointing at NO durable file anywhere — and
137
+ * the gate above (§3a) only refuses `null`; a fabricated-but-signed in-memory
138
+ * object sails through every check in ROUND 1 §3. ROUND 1's signature
139
+ * verification only actually defends anything IF the per-call caller lacks
140
+ * the trusted legs' private keys — but nothing in this module, or in what it
141
+ * composes, ESTABLISHES that. Key custody is a WP5 wiring concern that is not
142
+ * yet specified; assuming the caller can't sign is exactly the kind of
143
+ * unverified assumption D11 exists to eliminate. We do not rely on it here.
144
+ * The deeper problem: a trust-critical READER that the untrusted per-call
145
+ * caller can inject is an EMPTY ANCHOR — no matter how strong the
146
+ * verification logic downstream of it is, the caller controls whether that
147
+ * logic ever sees real data at all. This is the same injected-deps lesson
148
+ * ROUND 1 only half-applied (it anchored the VERIFIER but not the READER).
149
+ *
150
+ * THE FIX:
151
+ *
152
+ * 1. `readVerdict` moves from `RunD11CeremonyDeps` (per-call) to
153
+ * `CreateD11CeremonyOptions` (construction-time) — EXACTLY like
154
+ * `trustedKeystore` and `verifySignature`, and for the identical reason:
155
+ * the per-call caller of the returned `runD11Ceremony(input, deps)` is the
156
+ * UNTRUSTED adversary this whole ceremony exists to defend against. A
157
+ * trust-critical dependency that adversary can inject or override is not
158
+ * a defense, it is a hook for the exact fabrication being defended
159
+ * against. `RunD11CeremonyDeps` no longer has a `readVerdict` field at
160
+ * all (a type-level guarantee, not merely a runtime check) — and even if
161
+ * a caller attaches an extra `readVerdict` property to the `deps` object
162
+ * at runtime anyway (JS does not enforce the TS shape), `runD11Ceremony`
163
+ * never reads `deps.readVerdict` — it always uses the reader CLOSED OVER
164
+ * at construction. Proven in tests: an attached-but-unused
165
+ * `deps.readVerdict` records zero calls.
166
+ *
167
+ * 2. The DEFAULT construction-time reader, `defaultReadVerdict`, is a REAL
168
+ * in-module durable-store reader with PATH-BINDING: `ref` IS a filesystem
169
+ * path, and the reader does a genuine `readFile` at exactly that path,
170
+ * then JSON-parses and shape-validates the result. It returns `null` on
171
+ * ANY failure — missing file, unreadable, malformed JSON, wrong shape —
172
+ * which is exactly ROUND 1's proven counter-example (an invented ref with
173
+ * no real file behind it) and stays refused for the identical reason.
174
+ * Because the reader always dereferences the REAL filesystem at the ref's
175
+ * own path, what it returns is BOUND to that ref — it is literally what
176
+ * is stored there — never a caller-fabricated in-memory object: there is
177
+ * no code path by which an in-memory `VerdictArtifact`, however validly
178
+ * signed, can reach `promoteNote` without first being real bytes on disk
179
+ * at the exact path the ceremony itself derived from `writeVerdict`'s
180
+ * return value. `readVerdict` remains overridable AT CONSTRUCTION for
181
+ * tests only — a test may supply a fake store-reader (e.g. an in-memory
182
+ * `Map<ref, VerdictArtifact>`) to `createD11Ceremony({ readVerdict: ... })`
183
+ * so tests don't need real disk I/O for every scenario — but that
184
+ * override is wired ONCE, at the same trusted construction site as
185
+ * `trustedKeystore`, never by the per-call caller under test.
186
+ *
187
+ * 3. Everything from ROUND 1 §3 is UNCHANGED: signature-by-claimed-leg,
188
+ * GO, anti-replay (noteId pin), cross-artifact distinctness, separation
189
+ * of powers off the READ content, and coherence with the inline verdict.
190
+ * ROUND 2 only changes WHERE `readVerdict` comes from and WHAT its
191
+ * default implementation actually does; it adds no new gate steps because
192
+ * none were missing — the missing piece was that the existing gate could
193
+ * be handed fabricated input before it ever ran.
194
+ *
195
+ * WHAT THIS DOES NOT CLAIM (residual, stated honestly): the CONSTRUCTION
196
+ * SITE — whoever calls `createD11Ceremony({ trustedKeystore, readVerdict })`
197
+ * — remains a trusted wiring point (WP5 domain, unchanged from ROUND 1 §1):
198
+ * its choice of keystore, its choice of durable-store reader (or acceptance
199
+ * of the real filesystem default), and the actual key material/store
200
+ * deployment behind both are not re-verified by this module. That is ONE
201
+ * trusted wiring point exercised once at bootstrap, not any of the
202
+ * potentially many per-call callers of the returned `runD11Ceremony` — a
203
+ * much smaller, much more auditable surface than ROUND 1 left. This module
204
+ * defends against a per-call caller fabricating a plausible-looking verdict,
205
+ * signed or not, durable or not; it does not defend against a compromised
206
+ * private key for a leg genuinely registered in the trusted keystore, a
207
+ * compromised/malicious construction site, or a durable store an attacker
208
+ * can write to directly (bypassing `writeVerdict`) — those remain
209
+ * key-management, wiring, and storage-access concerns, not gaps in this
210
+ * gate.
211
+ *
212
+ * ===========================================================================
213
+ * D11 FIX — ROUND 3 (this build) — `defaultReadVerdict` is now PATH-CONFINED.
214
+ * ===========================================================================
215
+ *
216
+ * THE HOLE ROUND 2 LEFT (proven by a THIRD, independent review, a separate
217
+ * leg from the two that forced ROUNDS 1 and 2): ROUND 2 moved `readVerdict`
218
+ * to construction time, closing the "fabricated in-memory artifact" hole.
219
+ * But the DEFAULT construction-time reader, `defaultReadVerdict`, still did
220
+ * a plain `readFile(ref)` — and `ref` is `writeVerdict`'s PER-CALL,
221
+ * caller-controlled return value. Moving the READER to construction time
222
+ * defends against a caller substituting a DIFFERENT reader; it does nothing
223
+ * to confine WHERE the real, trusted, construction-time reader looks, when
224
+ * that location is still a bare string the untrusted per-call caller
225
+ * chose. Three attacks reached `promoteNote` with `promoted: true` at
226
+ * ROUND 2 (@824b633e):
227
+ * 1. TRAVERSAL — `writeVerdict` returns a `../../…` ref pointing at some
228
+ * OTHER, genuinely signed verdict file outside the intended zone (e.g.
229
+ * one written by a real leg for a DIFFERENT ceremony run entirely).
230
+ * 2. SYMLINK — `writeVerdict` "writes" its ref location as a symlink to
231
+ * an attacker-controlled file (or makes an ancestor directory a
232
+ * symlink); `readFile` follows it transparently.
233
+ * 3. TOCTOU — `writeVerdict`'s ref looks confined and could even pass a
234
+ * naive check, but the file at that path is swapped between the
235
+ * moment any confinement check runs and the moment bytes are actually
236
+ * read — a real risk IF that check and that read are two separate
237
+ * pathname-based filesystem operations ("realpath(ref), then
238
+ * separately re-open(ref)" is itself racy this way).
239
+ *
240
+ * THE FIX, REUSING the path-confinement technique already proven and MERGED
241
+ * in this repo — `packages/h2a-runtime/src/identity-cull/cull.ts` (PR
242
+ * #160): `realpathSync` canonicalization of a TRUSTED root,
243
+ * `openSync(..., O_RDONLY | O_DIRECTORY | O_NOFOLLOW)` descriptor-relative
244
+ * directory walking that refuses a symlink at ANY path component, and — the
245
+ * TOCTOU-closing move — a SINGLE held file descriptor carried from open
246
+ * through the `fstatSync` regular-file check to the actual read, never a
247
+ * second, pathname-based open. This module reimplements the same
248
+ * primitives locally (`h2a` does not depend on `h2a-runtime` at build time;
249
+ * the peer dependency is optional) — the TECHNIQUE is reused, not
250
+ * reinvented, per that module's own comments on the same primitives.
251
+ *
252
+ * 1. `createD11Ceremony({ ..., authorizedRoot })` — a new construction-time
253
+ * option, REQUIRED whenever the REAL default reader is in use (no custom
254
+ * `readVerdict` override): constructing without it throws synchronously,
255
+ * the same fail-loud pattern as a missing `trustedKeystore`. A real
256
+ * filesystem reader with no confinement root IS the hole this round
257
+ * closes; there is no safe default for it.
258
+ *
259
+ * 2. `deriveVerdictRef(authorizedRoot, noteId, leg)` — a new, exported,
260
+ * PURE function (no filesystem access): the ONE location a leg's verdict
261
+ * for a note may ever be read from — `beneath(authorizedRoot,
262
+ * sanitized(noteId), sanitized(legId))`. Both `noteId` and the leg's
263
+ * `{model, session}` are sanitized (reject `/`, `\`, NUL, `.`, `..`) —
264
+ * REFUSED outright (thrown, caught, turned into a ceremony refusal)
265
+ * rather than silently stripped, so a hostile value can never be
266
+ * "cleaned" into some OTHER, ambiguous, colliding path. This is derived
267
+ * from `legSpec1`/`legSpec2` — the ceremony's OWN dispatch decision,
268
+ * already checked structurally distinct and non-author BEFORE launch —
269
+ * never from anything `launchLeg` or `writeVerdict` claim back. (A
270
+ * deliberate, narrow exception to I1's "opaque, never parsed/derived
271
+ * from" stance for `noteId`/leg identity: here they are used ONLY to
272
+ * build a confined filesystem path, via sanitize-or-refuse, never
273
+ * split/parsed/compared piecewise.)
274
+ *
275
+ * 3. Per leg, BEFORE any filesystem access on `writeVerdict`'s ref: the
276
+ * ceremony computes the derived path and requires
277
+ * `resolve(writeVerdictRef) === derivedPath`, EXACTLY, string-for-string
278
+ * — REFUSED otherwise, before `readVerdict` is ever called with it. This
279
+ * alone closes TRAVERSAL structurally: a `../`-laden ref can never be
280
+ * textually identical to the clean, sanitized, ceremony-derived path, so
281
+ * it is refused without a single filesystem call ever touching it.
282
+ *
283
+ * 4. `defaultReadVerdict(ref, authorizedRoot)` (signature change from
284
+ * ROUND 2 — now confined) opens the verdict file via the cull.ts-style
285
+ * descriptor-relative O_NOFOLLOW walk from the realpath'd
286
+ * `authorizedRoot` down through `ref`'s path components — refusing a
287
+ * symlink at ANY component (closes SYMLINK) — then, from the ONE
288
+ * resulting file descriptor: `fstatSync`s it (must be a regular file),
289
+ * re-derives its canonical path via `realpath("/proc/self/fd/<fd>")` as
290
+ * defense-in-depth confirmation it is still beneath `authorizedRoot`,
291
+ * and reads its bytes from THAT SAME descriptor — never a second,
292
+ * pathname-based open (closes TOCTOU: there is no gap between "this is
293
+ * confirmed a regular file beneath the root" and "these are the bytes
294
+ * verified" — both statements are about the identical open file
295
+ * description).
296
+ *
297
+ * 5. When a construction site supplies a CUSTOM `readVerdict` (an
298
+ * alternate, non-default store — still construction-time-only,
299
+ * unchanged from ROUND 2), the ref-derivation VALIDATION in (3) still
300
+ * applies whenever `authorizedRoot` is ALSO configured (defense in
301
+ * depth, cheap, store-agnostic — pure string comparison); it is skipped
302
+ * only when a construction site both overrides the reader AND supplies
303
+ * no `authorizedRoot`, in which case that override remains, as in
304
+ * ROUND 2, a fully trusted construction-time decision this module does
305
+ * not second-guess.
306
+ *
307
+ * Everything from ROUND 1 §3 and ROUND 2 remains UNCHANGED: signature-by-
308
+ * claimed-leg, GO, anti-replay (noteId pin), cross-artifact distinctness,
309
+ * separation of powers off the READ content, and coherence with the inline
310
+ * verdict. ROUND 3 adds a confinement gate BEFORE that pipeline runs; it
311
+ * removes no existing check.
312
+ *
313
+ * ===========================================================================
314
+ * D11 FIX — ROUND 3 §B (this build) — `authorId` is now BOUND to a verified
315
+ * author signature; `input.authorId` is no longer trusted for anything.
316
+ * ===========================================================================
317
+ *
318
+ * THE HOLE: `RunD11CeremonyInput.authorId` was a bare, per-call,
319
+ * caller-supplied string. Every separation-of-powers check compared a leg
320
+ * against WHATEVER `authorId` the caller happened to supply — a caller could
321
+ * simply lie (supply an `authorId` distinct from every leg) even when the
322
+ * TRUE author was genuinely one of the two legs reviewing its own note, and
323
+ * the ceremony had no way to detect it.
324
+ *
325
+ * WHY NOT a trusted-author field on the note itself: graphify verified
326
+ * (@67bf73c7) that `MemoryNote` carries NO trusted author field — graphify
327
+ * authors nothing at admission (its anti-cycle boundary, §8, forbids it from
328
+ * verifying signatures there). So this cannot be closed by reading a field
329
+ * graphify stamped; it must be closed INSIDE the ceremony, the same way the
330
+ * verdict-fabrication hole was: bind the claim to an unforgeable signature,
331
+ * verified against a trust anchor this module already owns.
332
+ *
333
+ * THE FIX — reuses the EXACT SAME trust anchor as verdict signatures, no
334
+ * second trust root:
335
+ *
336
+ * 1. The note carries an AUTHOR SIGNATURE in its open extension field
337
+ * (`note["h2a.author_signature"]`, `D11CeremonyNote`'s `[key: string]:
338
+ * unknown` slot — `MemoryNoteInput`/`note-builder.ts` already carry such
339
+ * an open slot; graphify stores it OPAQUE/unverified, it never inspects
340
+ * or verifies it). The shape, `AuthorSignature`, mirrors `VerdictArtifact`:
341
+ * `{authorLeg: LegIdentity, noteId, signature}` — a base64 Ed25519
342
+ * signature over the canonicalized `{authorLeg, noteId}` payload, binding
343
+ * the identity claim to BOTH who claims it and which note it is for
344
+ * (anti-replay, identical shape to how verdict signatures bind `noteId`).
345
+ * `authorLeg` reuses `LegIdentity` (not a new taxonomy, I4) precisely so
346
+ * it can be looked up in the SAME `trustedKeystore` a leg's key is.
347
+ *
348
+ * 2. `runD11Ceremony` RE-VERIFIES this signature — FIRST, before any other
349
+ * check — against the SAME construction-time `trustedKeystore` +
350
+ * `verifySignature` already closed over for verdicts (`verifyNoteAuthor`,
351
+ * below). No per-call/caller-injected dependency is consulted for this
352
+ * (injected_deps_hide_fabrication_hole discipline, same as ROUND 1/2/3's
353
+ * other anchors): an attacker who controls `deps` cannot make their own
354
+ * verifier say yes.
355
+ *
356
+ * 3. On success, the VERIFIED signer's identity — `authorLeg.session` — is
357
+ * what EVERY separation-of-powers check in this module now compares
358
+ * against, replacing every prior use of `input.authorId`.
359
+ * `RunD11CeremonyInput.authorId` remains in the type (callers may still
360
+ * pass it) but is STRUCTURALLY NEVER READ for a trust decision anywhere
361
+ * in this module — at most an unverified, decorative hint. Lying about it
362
+ * has zero effect: it is not consulted.
363
+ *
364
+ * 4. FAIL-CLOSED: a note with no author signature, a malformed one, one
365
+ * whose `noteId` does not match the note it is attached to, or one that
366
+ * does not verify against the trusted keystore, REFUSES the entire
367
+ * ceremony immediately — `launchLeg` is never called, exactly like the
368
+ * other pre-launch fail-closed checks (I5).
369
+ *
370
+ * PARKED (owner-gated, not this round): having graphify itself stamp a
371
+ * trusted `authored_by` field at admission (via `AdmissionDeps`) — a
372
+ * heavier, graphify-contract change. This round stays entirely h2a-side,
373
+ * zero graphify change, closing the hole with a mechanism this module
374
+ * already had (signature verification against its own trust anchor).
375
+ *
376
+ * ===========================================================================
377
+ * D11 FIX — ROUND 4 (this build) — same-note verdict REPLAY is now refused
378
+ * (a fresh, gate-issued ceremony nonce), and separation-of-powers is now
379
+ * enforced on CANONICAL CRYPTO PRINCIPALS (public-key fingerprints), not
380
+ * session strings.
381
+ * ===========================================================================
382
+ *
383
+ * THE TWO HOLES (proven by an independent review, a FOURTH, separate leg from
384
+ * the three that forced ROUNDS 1-3): at ROUND 3 (@64e3f8a4), the signed
385
+ * verdict payload was `{noteId, verdict, leg, at}` — signed, noteId-pinned
386
+ * against cross-note replay, but with NO freshness, NO nonce, NO one-shot
387
+ * consumption. An attacker holding NO private key at all could REPLAY the
388
+ * already-signed BYTES of two OLD, genuinely-produced GO verdicts for the
389
+ * SAME noteId (from a prior, real ceremony) straight into a FRESH ceremony's
390
+ * confined artifact locations and mint a fresh promotion — BLOCKER 1,
391
+ * SAME-NOTE REPLAY. Separately, separation-of-powers derived and compared
392
+ * `authorLeg.session` / `leg.session` STRINGS only, never the verified
393
+ * PUBLIC KEY those sessions claim to speak for; `D11TrustedKeystore` never
394
+ * enforced that a public key maps to at most one identity. If the same
395
+ * keystore mapped ONE Ed25519 key to TWO (or three) different
396
+ * `{model, session}` identities, the SAME cryptographic principal could
397
+ * author a note under one alias and review it — genuinely signing, no forged
398
+ * signature anywhere — under another, or "independently" review it TWICE
399
+ * under two reviewer aliases — BLOCKER 2, CRYPTO AUTHOR/REVIEWER (and
400
+ * REVIEWER/REVIEWER) ALIAS. Both reach `promoteNote({promoted:true})` at
401
+ * @64e3f8a4 without ever forging a signature.
402
+ *
403
+ * FIX 1 — REPLAY: a fresh, gate-issued, unpredictable ONE-SHOT ceremony
404
+ * nonce.
405
+ *
406
+ * 1. `runD11Ceremony` generates `ceremonyNonce = randomBytes(32).toString(
407
+ * "base64")` (`node:crypto`, NOT `Math.random`/`Date.now` — unpredictable)
408
+ * as the FIRST thing it does, every call. Never persisted, never reused
409
+ * across calls — a fresh value exists ONLY for the lifetime of this one
410
+ * ceremony invocation ("stateless one-shot": no server-side nonce store
411
+ * is needed because the GATE regenerates its own value every run and
412
+ * trusts nothing else).
413
+ * 2. The nonce is threaded to `launchLeg(note, legSpec, ceremonyNonce)` (new
414
+ * 3rd arg), so an honest leg's inline verdict — now `LegVerdict =
415
+ * MemoryVerdict & {ceremonyNonce}` (a LOCAL extension; slice 3's own
416
+ * `MemoryVerdict` is untouched, I4) — can carry it through `writeVerdict`
417
+ * into what actually gets signed. `VerdictArtifact` and
418
+ * `verdictSignedPayload` gain `ceremonyNonce`; the signature now covers
419
+ * `{noteId, verdict, leg, at, ceremonyNonce}`.
420
+ * 3. THE GATE (load-bearing): for each READ artifact, (a) REQUIRE
421
+ * `artifact.ceremonyNonce === ceremonyNonce` (THIS run's generated value)
422
+ * — reject a stale/replayed artifact immediately, before any signature
423
+ * math; AND (b), independently, verify the signature over a payload
424
+ * REBUILT from the GATE's OWN `ceremonyNonce` variable — never
425
+ * `artifact.ceremonyNonce` as the source of truth for what to verify. A
426
+ * replayed old artifact's real signed bytes covered a DIFFERENT (or
427
+ * absent) nonce, so (b) alone already fails to verify against a payload
428
+ * built with the CURRENT run's nonce, even hypothetically without (a).
429
+ * Both (a) and (b) independently refuse a same-note replay — proven by
430
+ * the ROUND 4 mutation-check in the test file (each neutralized alone,
431
+ * then together, then restored — see that file's comments for the
432
+ * ACTUAL, reproduced results).
433
+ * 4. `coherentWithInline` is extended to also require
434
+ * `artifact.ceremonyNonce === inline.ceremonyNonce`.
435
+ *
436
+ * FIX 2 — ALIAS: separation-of-powers on canonical CRYPTO PRINCIPALS.
437
+ *
438
+ * 1. `canonicalKeyFingerprint(pem)` — `createPublicKey` then re-export as
439
+ * SPKI DER, SHA-256 of that DER, base64. Two differently-formatted PEM
440
+ * encodings of the identical key canonicalize to the SAME fingerprint
441
+ * (robust to encoding, unlike a raw PEM string compare); an unparseable
442
+ * key throws (caught and turned into a ceremony refusal, I5).
443
+ * 2. `verifyNoteAuthor` resolves and returns the VERIFIED author's
444
+ * fingerprint alongside its `authorId` — the SAME public key it already
445
+ * looked up to verify the author signature, so no extra trust decision is
446
+ * introduced.
447
+ * 3. Separation-of-powers now requires THREE PAIRWISE-DISTINCT crypto
448
+ * principals (author, reviewer1, reviewer2), by fingerprint, fail-closed
449
+ * on any missing/unparseable key:
450
+ * - PRE-LAUNCH: `legSpec1`/`legSpec2`'s trusted keys are resolved and
451
+ * fingerprinted BEFORE `launchLeg` is ever called (symmetric with the
452
+ * existing pre-launch session-string checks) — refuses before a
453
+ * single model call is ever made.
454
+ * - POST-READ: re-checked off the ACTUAL READ artifacts' `leg`s'
455
+ * fingerprints (the SAME trusted keys that just verified each
456
+ * artifact's signature) — a `readVerdict` returning a different leg
457
+ * than the one launched is still caught, symmetric with ROUND 1's
458
+ * existing "off the READ content only" discipline.
459
+ * The existing session-string distinctness checks (`sameLegSpec`,
460
+ * `.session === verifiedAuthorId`) are KEPT, unchanged, as defense in
461
+ * depth; the fingerprint checks are the AUTHORITATIVE crypto layer added
462
+ * this round — a `.session` alias backed by a shared key now refuses even
463
+ * when every session string looks pairwise distinct (the
464
+ * reviewer/reviewer variant ROUND 3 could not catch, since `sameLegSpec`
465
+ * only compares `{model, session}`, never the key behind them).
466
+ *
467
+ * Everything from ROUNDS 1-3 remains UNCHANGED: path confinement,
468
+ * signature-by-claimed-leg, GO, cross-note noteId anti-replay, the two
469
+ * artifacts' structural (session) distinctness, coherence with the inline
470
+ * verdict, and author-signature verification. ROUND 4 adds a freshness gate
471
+ * and a crypto-principal gate; it removes no existing check.
472
+ *
473
+ * ===========================================================================
474
+ * (Unchanged) FLOW summary and invariants.
475
+ * ===========================================================================
476
+ *
477
+ * I5 — fail-closed at EVERY injected/lookup step: `deps` itself,
478
+ * `deps.legSpecs`, `deps.launchLeg`, `deps.writeVerdict`, `deps.writeAttestation`,
479
+ * `deps.port`, the CONSTRUCTION-TIME `readVerdict` (ROUND 2), and the
480
+ * `trustedKeystore` lookup are all treated as untrusted or fallible — absent,
481
+ * wrong shape, throwing, rejecting, or (for the keystore, and for
482
+ * `readVerdict`'s return) simply "nothing there" all REFUSE with a structured
483
+ * `{promoted:false, reason}`, never a silent success. A ceremony that fails
484
+ * partway NEVER reaches `promoteNote` (slice 3's raw dispatch) — proven in
485
+ * tests via counting stubs showing later steps are never invoked once an
486
+ * earlier one refuses.
487
+ *
488
+ * I1 — durable identity slot: `note.noteId`, `note.principal_owner`,
489
+ * `authorId`, and every `legSpec.{model,session}` / `leg.{model,session}` are
490
+ * OPAQUE strings here — compared with `===` only (via slice 3's own
491
+ * equality, or the local `sameLegSpec` mirror below), never parsed, split or
492
+ * derived-from. The `trustedKeystore` is looked up BY the opaque `LegIdentity`
493
+ * value, not by any string built from parsing it.
494
+ * I4 — no new capabilities vocabulary: this module reuses `MemoryVerdict`,
495
+ * `IndependenceAttestation`, `LegIdentity`, `checkDoubleConsensusPreconditions`
496
+ * and `promoteNoteWithDoubleConsensus` UNCHANGED from `./promote-client.ts`;
497
+ * `LegSpec` is a type alias of `LegIdentity`, not a second taxonomy, and
498
+ * `D11CeremonyResult` reuses slice 3's own `PromoteNoteResult` shape — a
499
+ * ceremony IS a (composed) promotion attempt, not a new outcome vocabulary.
500
+ * `VerdictArtifact` is the one genuinely NEW shape this fix introduces — the
501
+ * READ, signed record `MemoryVerdict` never was.
502
+ */
503
+ import { createHash, createPublicKey, randomBytes, verify as verifyEd25519Signature } from "node:crypto";
504
+ import { closeSync, constants, fstatSync, openSync, readFileSync, realpathSync } from "node:fs";
505
+ import { isAbsolute, relative, resolve, sep } from "node:path";
506
+ import { canonicalize } from "../../canonical.js";
507
+ import { checkDoubleConsensusPreconditions, promoteNoteWithDoubleConsensus } from "./promote-client.js";
508
+ /** The note extension key `AuthorSignature` is carried under (`D11CeremonyNote`'s open `[key: string]: unknown` slot). */
509
+ export const AUTHOR_SIGNATURE_KEY = "h2a.author_signature";
510
+ /** A fixed, descriptive orchestrator id — NOT a minted identity (I1); this module names the mechanism, not a person/session. */
511
+ const ORCHESTRATOR_ID = "h2a:d11-ceremony";
512
+ /**
513
+ * Placeholder refs for the PRE-write, PRE-read precondition gate (a cheap
514
+ * early reject on the INLINE verdicts, before any write happens) — no real
515
+ * ref exists yet at that point. Never appear in the evidence handed to the
516
+ * port; discarded the moment the real refs come back from `writeVerdict`.
517
+ */
518
+ const PENDING_LEG1_REF = "__d11_ceremony_pending_leg1_ref__";
519
+ const PENDING_LEG2_REF = "__d11_ceremony_pending_leg2_ref__";
520
+ function refuse(reason) {
521
+ return { outcome: { promoted: false, reason }, localOnly: true };
522
+ }
523
+ function sameLegSpec(a, b) {
524
+ return a.model === b.model && a.session === b.session;
525
+ }
526
+ function errorReason(err) {
527
+ return err instanceof Error ? err.message : String(err);
528
+ }
529
+ /**
530
+ * D11 FIX ROUND 4 FIX 1 — the signed payload now includes `ceremonyNonce`,
531
+ * but deliberately as a SEPARATE, EXPLICIT parameter (`ceremonyNonce`,
532
+ * passed by the caller), never read off `artifact.ceremonyNonce`. The gate
533
+ * (below) ALWAYS calls this with THIS RUN's own generated nonce — the ONE
534
+ * source of truth for what a valid signature must cover this run — so a
535
+ * replayed artifact whose real signed bytes covered a DIFFERENT (stale)
536
+ * nonce fails to verify here even if the artifact's self-reported
537
+ * `ceremonyNonce` field were tampered to match.
538
+ */
539
+ function verdictSignedPayload(artifact, ceremonyNonce) {
540
+ return { noteId: artifact.noteId, verdict: artifact.verdict, leg: artifact.leg, at: artifact.at, ceremonyNonce };
541
+ }
542
+ function toMemoryVerdict(artifact) {
543
+ return { noteId: artifact.noteId, verdict: artifact.verdict, leg: artifact.leg, at: artifact.at };
544
+ }
545
+ function coherentWithInline(artifact, inline) {
546
+ return (artifact.noteId === inline.noteId &&
547
+ artifact.verdict === inline.verdict &&
548
+ artifact.leg.model === inline.leg.model &&
549
+ artifact.leg.session === inline.leg.session &&
550
+ artifact.ceremonyNonce === inline.ceremonyNonce);
551
+ }
552
+ /**
553
+ * The default, real signature verifier: Ed25519 over the canonicalized
554
+ * payload, via `node:crypto` — the same primitive `./signature.ts` uses
555
+ * elsewhere in h2a, applied here to a raw base64 signature string (this
556
+ * seam's shape) rather than the `H2ASignature{by,alg,value}` envelope.
557
+ */
558
+ export function defaultVerifySignature(payload, signature, publicKeyPem) {
559
+ let key;
560
+ try {
561
+ key = createPublicKey({ key: publicKeyPem, format: "pem" });
562
+ }
563
+ catch {
564
+ return false;
565
+ }
566
+ let raw;
567
+ try {
568
+ raw = Buffer.from(signature, "base64");
569
+ }
570
+ catch {
571
+ return false;
572
+ }
573
+ try {
574
+ const message = Buffer.from(canonicalize(payload), "utf8");
575
+ return verifyEd25519Signature(null, message, key, raw);
576
+ }
577
+ catch {
578
+ return false;
579
+ }
580
+ }
581
+ /**
582
+ * D11 FIX ROUND 2: shape-validate a value read back from the durable store
583
+ * before trusting it as a `VerdictArtifact` at all — a malformed or
584
+ * unrelated JSON blob at a path must never be handed to the signature check
585
+ * as if it were a real artifact (it would simply fail signature
586
+ * verification, but failing CLOSED here, before that, is cheaper and
587
+ * clearer about why).
588
+ */
589
+ function isVerdictArtifactShape(value) {
590
+ if (typeof value !== "object" || value === null)
591
+ return false;
592
+ const v = value;
593
+ if (typeof v.noteId !== "string")
594
+ return false;
595
+ if (v.verdict !== "GO" && v.verdict !== "NO-GO")
596
+ return false;
597
+ if (typeof v.at !== "number")
598
+ return false;
599
+ // D11 FIX ROUND 4 FIX 1 — a verdict artifact with no (or empty)
600
+ // ceremonyNonce is not a valid shape at all — refused HERE, before ever
601
+ // reaching the freshness/signature gate below.
602
+ if (typeof v.ceremonyNonce !== "string" || v.ceremonyNonce.length === 0)
603
+ return false;
604
+ if (typeof v.signature !== "string")
605
+ return false;
606
+ if (typeof v.leg !== "object" || v.leg === null)
607
+ return false;
608
+ const leg = v.leg;
609
+ return typeof leg.model === "string" && typeof leg.session === "string";
610
+ }
611
+ /**
612
+ * D11 FIX ROUND 4 FIX 2 — the canonical CRYPTO PRINCIPAL fingerprint for an
613
+ * Ed25519 public key: parse the PEM, re-export as SPKI DER (a canonical
614
+ * encoding independent of the ORIGINAL PEM's own formatting/line-wrapping),
615
+ * then SHA-256 that DER. Two differently-formatted PEM encodings of the
616
+ * IDENTICAL key canonicalize to the SAME fingerprint — robust to encoding,
617
+ * unlike a raw PEM string compare. Throws on an unparseable key; callers
618
+ * MUST catch this and turn it into a fail-closed ceremony refusal (I5) —
619
+ * there is no safe default fingerprint for a key that cannot even be parsed.
620
+ */
621
+ export function canonicalKeyFingerprint(publicKeyPem) {
622
+ const key = createPublicKey({ key: publicKeyPem, format: "pem" });
623
+ const der = key.export({ type: "spki", format: "der" });
624
+ return createHash("sha256").update(der).digest("base64");
625
+ }
626
+ // ---------------------------------------------------------------------------
627
+ // D11 FIX ROUND 3 §B — author-signature verification. Reuses the SAME
628
+ // construction-time `trustedKeystore` + `verifySignature` verdict
629
+ // signatures use — no second trust root, no per-call-injected verifier.
630
+ // ---------------------------------------------------------------------------
631
+ /** Mirrors `isVerdictArtifactShape`'s shape-before-signature discipline: refuse a malformed shape BEFORE ever handing it to signature verification. */
632
+ function isAuthorSignatureShape(value) {
633
+ if (typeof value !== "object" || value === null)
634
+ return false;
635
+ const v = value;
636
+ if (typeof v.noteId !== "string")
637
+ return false;
638
+ if (typeof v.signature !== "string")
639
+ return false;
640
+ if (typeof v.authorLeg !== "object" || v.authorLeg === null)
641
+ return false;
642
+ const authorLeg = v.authorLeg;
643
+ return typeof authorLeg.model === "string" && typeof authorLeg.session === "string";
644
+ }
645
+ function authorSignedPayload(sig) {
646
+ return { authorLeg: sig.authorLeg, noteId: sig.noteId };
647
+ }
648
+ /**
649
+ * D11 FIX ROUND 3 §B — verify `note[AUTHOR_SIGNATURE_KEY]` against the SAME
650
+ * construction-time `trustedKeystore` + `verifySignature` verdict
651
+ * signatures are checked against (no second trust root — the module doc's
652
+ * ROUND 3 §B explains why). Returns the VERIFIED signer's `authorLeg.session`
653
+ * on success; a structured refusal reason on ANY failure — missing
654
+ * signature, malformed shape, a `noteId` that does not match the note it is
655
+ * attached to (anti-replay), an unknown/untrusted claimed identity, or an
656
+ * invalid signature. Never throws; never falls back to any caller-supplied
657
+ * value.
658
+ */
659
+ function verifyNoteAuthor(note, trustedKeystore, verifySignature) {
660
+ const raw = note[AUTHOR_SIGNATURE_KEY];
661
+ if (!isAuthorSignatureShape(raw)) {
662
+ return {
663
+ ok: false,
664
+ reason: `note has no valid author signature (${AUTHOR_SIGNATURE_KEY}) — refusing (fail-closed)`
665
+ };
666
+ }
667
+ if (raw.noteId !== note.noteId) {
668
+ return {
669
+ ok: false,
670
+ reason: "the note's author signature noteId does not match the note itself — refusing (anti-replay)"
671
+ };
672
+ }
673
+ const publicKey = trustedKeystore.getPublicKey(raw.authorLeg);
674
+ if (typeof publicKey !== "string" || publicKey.length === 0) {
675
+ return {
676
+ ok: false,
677
+ reason: "no trusted public key for the note's claimed author — refusing (fail-closed; unknown author or empty keystore)"
678
+ };
679
+ }
680
+ let signatureOk;
681
+ try {
682
+ signatureOk = verifySignature(authorSignedPayload(raw), raw.signature, publicKey);
683
+ }
684
+ catch (err) {
685
+ return { ok: false, reason: `author signature verification threw — ${errorReason(err)}` };
686
+ }
687
+ if (!signatureOk) {
688
+ return {
689
+ ok: false,
690
+ reason: "the note's author signature is invalid — refusing (fabricated, tampered, or signed by the wrong key)"
691
+ };
692
+ }
693
+ // D11 FIX ROUND 4 FIX 2 — resolve the canonical fingerprint of the SAME
694
+ // trusted key that just verified this signature. Fail-closed (I5) on an
695
+ // unparseable key, the same discipline as every other lookup in this
696
+ // module.
697
+ let authorKeyFingerprint;
698
+ try {
699
+ authorKeyFingerprint = canonicalKeyFingerprint(publicKey);
700
+ }
701
+ catch (err) {
702
+ return {
703
+ ok: false,
704
+ reason: `the note's author trusted public key is not a parseable Ed25519 key — refusing (fail-closed) — ${errorReason(err)}`
705
+ };
706
+ }
707
+ return { ok: true, authorId: raw.authorLeg.session, authorKeyFingerprint };
708
+ }
709
+ // ---------------------------------------------------------------------------
710
+ // D11 FIX ROUND 3 — path-confinement primitives. REUSES the technique
711
+ // proven and MERGED in `packages/h2a-runtime/src/identity-cull/cull.ts`
712
+ // (PR #160): `realpathSync` canonicalization of a trusted root,
713
+ // `openSync(..., O_RDONLY | O_DIRECTORY | O_NOFOLLOW)` descriptor-relative
714
+ // directory walking that refuses a symlink at ANY path component
715
+ // (`openNoFollowDirectory`/`descriptorPath` below mirror cull.ts's own
716
+ // functions of the same names and shape), and a SINGLE held file
717
+ // descriptor carried from open through the `fstatSync` check to the read —
718
+ // never a second, pathname-based open. `h2a` does not depend on
719
+ // `h2a-runtime` at build time (the peer dependency is optional), so the
720
+ // primitives are reimplemented locally rather than imported; the technique,
721
+ // not the module, is what is reused.
722
+ // ---------------------------------------------------------------------------
723
+ /**
724
+ * Reject `/`, `\`, NUL, `.` and `..` outright rather than stripping them —
725
+ * a hostile `noteId`/leg identity value must never be silently "cleaned"
726
+ * into some OTHER, ambiguous, possibly-colliding path segment.
727
+ */
728
+ function sanitizePathSegment(value, label) {
729
+ if (typeof value !== "string" || value.length === 0) {
730
+ throw new Error(`${label} must be a non-empty string to derive a confined verdict path`);
731
+ }
732
+ if (value === "." || value === "..") {
733
+ throw new Error(`${label} refuses "." or ".." as a path segment`);
734
+ }
735
+ if (value.includes("/") || value.includes("\\") || value.includes("\0")) {
736
+ throw new Error(`${label} refuses a path separator or NUL byte in a path segment`);
737
+ }
738
+ return value;
739
+ }
740
+ /** Mirrors cull.ts's `isInside` — `relative()`-based containment check, no `..`, never absolute. */
741
+ function isBeneathRoot(candidate, root) {
742
+ const rel = relative(root, candidate);
743
+ return rel !== "" && rel !== "." && !rel.startsWith("..") && !isAbsolute(rel);
744
+ }
745
+ /**
746
+ * D11 FIX ROUND 3: the ONE location a leg's verdict for a note may ever be
747
+ * read from — `beneath(authorizedRoot, sanitized(noteId), sanitized(legId))`.
748
+ * Pure (no filesystem access) so the ceremony can compute it and compare it
749
+ * against `writeVerdict`'s per-call return value BEFORE touching disk.
750
+ * Derived from the ceremony's OWN dispatch decision (the `legSpec` it
751
+ * actually launched, already checked distinct/non-author before launch) —
752
+ * never from anything `launchLeg`/`writeVerdict` claim back.
753
+ */
754
+ export function deriveVerdictRef(authorizedRoot, noteId, leg) {
755
+ const root = resolve(authorizedRoot);
756
+ const noteSegment = sanitizePathSegment(noteId, "noteId");
757
+ const model = sanitizePathSegment(leg?.model, "leg.model");
758
+ const session = sanitizePathSegment(leg?.session, "leg.session");
759
+ // NB-03: length-prefix `model` so the (model, session) → segment mapping is
760
+ // injective. A bare `${model}__${session}` join is ambiguous — ("claude",
761
+ // "3__test") and ("claude__3", "test") both yield "claude__3__test.json", which
762
+ // would alias two DISTINCT legs to ONE verdict file and silently collapse the
763
+ // double-consensus into a single verdict. Encoding model's length ahead of it
764
+ // makes the model|session boundary unambiguous regardless of underscores in
765
+ // either value (the segment decodes to exactly one (model, session) pair).
766
+ const legSegment = sanitizePathSegment(`${model.length}_${model}_${session}.json`, "leg");
767
+ const derived = resolve(root, noteSegment, legSegment);
768
+ if (!isBeneathRoot(derived, root)) {
769
+ // Defense in depth: sanitizePathSegment above should already make this
770
+ // unreachable (no `/`/`..` can survive into a segment), but a derived
771
+ // path is never trusted without this check regardless.
772
+ throw new Error("derived verdict ref escaped authorizedRoot");
773
+ }
774
+ return derived;
775
+ }
776
+ /** Mirrors cull.ts's `descriptorPath` — a safe, descriptor-relative `/proc/self/fd/<fd>[/<name>]` path. */
777
+ function descriptorPath(fd, name) {
778
+ if (name !== undefined && (name.length === 0 || name.includes(sep) || name === "." || name === "..")) {
779
+ throw new Error(`unsafe descriptor-relative verdict path segment: ${name}`);
780
+ }
781
+ return name === undefined ? `/proc/self/fd/${fd}` : `/proc/self/fd/${fd}/${name}`;
782
+ }
783
+ /** Mirrors cull.ts's `openNoFollowDirectory` — open a directory descriptor, refusing a symlink. */
784
+ function openNoFollowDirectory(path) {
785
+ const fd = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
786
+ try {
787
+ if (!fstatSync(fd).isDirectory())
788
+ throw new Error(`not a directory: ${path}`);
789
+ return fd;
790
+ }
791
+ catch (error) {
792
+ closeSync(fd);
793
+ throw error;
794
+ }
795
+ }
796
+ /** The path components of `target` relative to `root`, or `null` if `target` is not strictly beneath `root`. */
797
+ function componentsBeneathRoot(root, target) {
798
+ const rel = relative(root, target);
799
+ if (rel === "" || rel === "." || rel.startsWith("..") || isAbsolute(rel))
800
+ return null;
801
+ const parts = rel.split(sep).filter((part) => part.length > 0);
802
+ return parts.length > 0 ? parts : null;
803
+ }
804
+ /**
805
+ * D11 FIX ROUND 3 — confined-open + single-descriptor read. Walks from the
806
+ * realpath'd `authorizedRoot` down through `ref`'s path components, opening
807
+ * EVERY component (directories AND the final file) with `O_NOFOLLOW` —
808
+ * refusing a symlink anywhere in the chain (closes SYMLINK). The final file
809
+ * descriptor is `fstatSync`'d (must be a regular file), its canonical path
810
+ * re-derived via `realpath(/proc/self/fd/<fd>)` and re-checked beneath the
811
+ * root (defense in depth), and then READ FROM THAT SAME DESCRIPTOR — never
812
+ * a second, pathname-based open (closes TOCTOU: no gap between "confirmed a
813
+ * regular file beneath the root" and "these are the verified bytes"; both
814
+ * are about the identical open file description).
815
+ *
816
+ * Returns `null` on ANY failure — missing path, escaped root, a symlink at
817
+ * any component, not a regular file — never throws.
818
+ */
819
+ function readConfinedFileBytes(authorizedRoot, ref) {
820
+ let resolvedRoot;
821
+ let resolvedRef;
822
+ try {
823
+ resolvedRoot = realpathSync(resolve(authorizedRoot));
824
+ resolvedRef = resolve(ref);
825
+ }
826
+ catch {
827
+ return null;
828
+ }
829
+ const components = componentsBeneathRoot(resolvedRoot, resolvedRef);
830
+ if (!components)
831
+ return null;
832
+ const openedFds = [];
833
+ try {
834
+ let currentFd;
835
+ try {
836
+ currentFd = openNoFollowDirectory(resolvedRoot);
837
+ }
838
+ catch {
839
+ return null;
840
+ }
841
+ openedFds.push(currentFd);
842
+ for (let index = 0; index < components.length - 1; index += 1) {
843
+ let nextFd;
844
+ try {
845
+ nextFd = openNoFollowDirectory(descriptorPath(currentFd, components[index]));
846
+ }
847
+ catch {
848
+ return null;
849
+ }
850
+ openedFds.push(nextFd);
851
+ currentFd = nextFd;
852
+ }
853
+ const finalName = components[components.length - 1];
854
+ let fileFd;
855
+ try {
856
+ fileFd = openSync(descriptorPath(currentFd, finalName), constants.O_RDONLY | constants.O_NOFOLLOW);
857
+ }
858
+ catch {
859
+ return null;
860
+ }
861
+ openedFds.push(fileFd);
862
+ let stat;
863
+ try {
864
+ stat = fstatSync(fileFd);
865
+ }
866
+ catch {
867
+ return null;
868
+ }
869
+ if (!stat.isFile())
870
+ return null;
871
+ let canonicalOpened;
872
+ try {
873
+ canonicalOpened = realpathSync(descriptorPath(fileFd));
874
+ }
875
+ catch {
876
+ return null;
877
+ }
878
+ if (!isBeneathRoot(canonicalOpened, resolvedRoot))
879
+ return null;
880
+ try {
881
+ return readFileSync(fileFd);
882
+ }
883
+ catch {
884
+ return null;
885
+ }
886
+ }
887
+ finally {
888
+ for (const fd of openedFds) {
889
+ try {
890
+ closeSync(fd);
891
+ }
892
+ catch {
893
+ // best-effort close — the descriptor may already be invalid after a failure above
894
+ }
895
+ }
896
+ }
897
+ }
898
+ /**
899
+ * D11 FIX ROUND 2/3 — the default, REAL, CONFINED durable-store reader.
900
+ * `ref` IS a filesystem path, opened only via the confined,
901
+ * descriptor-relative, O_NOFOLLOW walk (`readConfinedFileBytes`, ROUND 3)
902
+ * beneath `authorizedRoot` — never a bare `readFile(ref)` — then
903
+ * JSON-parsed and shape-validated. Returns `null` on ANY failure — missing
904
+ * file, escaped root, a symlink anywhere in the chain, permission error,
905
+ * malformed JSON, or a well-formed-but-wrong-shape value — never throws.
906
+ * What it returns, when non-null, is PATH-BOUND: literally the bytes
907
+ * actually persisted at `ref`, confirmed beneath `authorizedRoot`, never a
908
+ * value the caller constructed in memory and never bytes read via a path
909
+ * that could have been re-resolved after any check.
910
+ */
911
+ export async function defaultReadVerdict(ref, authorizedRoot) {
912
+ const bytes = readConfinedFileBytes(authorizedRoot, ref);
913
+ if (!bytes)
914
+ return null;
915
+ let parsed;
916
+ try {
917
+ parsed = JSON.parse(bytes.toString("utf8"));
918
+ }
919
+ catch {
920
+ return null;
921
+ }
922
+ return isVerdictArtifactShape(parsed) ? parsed : null;
923
+ }
924
+ /**
925
+ * Factory: builds the actual `runD11Ceremony` function, CLOSED OVER
926
+ * `trustedKeystore` (and, optionally, `verifySignature` and `readVerdict`).
927
+ * This is the anti-fabrication anchor (D11 FIX ROUND 1 §1 + ROUND 2 §1):
928
+ * signature verification always uses THIS keystore, and the verdict
929
+ * read-back always uses THIS reader (real filesystem by default) — never
930
+ * one a caller of the returned function can inject per-call. Throws
931
+ * synchronously on a missing/malformed `trustedKeystore` — a
932
+ * construction-site wiring bug should fail loudly and immediately, not
933
+ * silently produce a ceremony that can never verify anything.
934
+ */
935
+ export function createD11Ceremony(options) {
936
+ if (!options || typeof options.trustedKeystore?.getPublicKey !== "function") {
937
+ throw new Error("createD11Ceremony requires a trustedKeystore with getPublicKey(leg) — construction-time, not caller-swappable");
938
+ }
939
+ const trustedKeystore = options.trustedKeystore;
940
+ const verifySignature = typeof options.verifySignature === "function" ? options.verifySignature : defaultVerifySignature;
941
+ const hasCustomReadVerdict = typeof options.readVerdict === "function";
942
+ const authorizedRoot = typeof options.authorizedRoot === "string" ? options.authorizedRoot : undefined;
943
+ // D11 FIX ROUND 3: a real filesystem reader with no confinement root is
944
+ // exactly the hole this round closes — fail loudly at construction, the
945
+ // same pattern as a missing `trustedKeystore`, rather than silently
946
+ // producing a ceremony whose default reader is unconfined.
947
+ if (!hasCustomReadVerdict && !authorizedRoot) {
948
+ throw new Error("createD11Ceremony requires authorizedRoot when no custom readVerdict is supplied — the real default reader must be confined (D11 FIX ROUND 3)");
949
+ }
950
+ const readVerdict = hasCustomReadVerdict
951
+ ? options.readVerdict
952
+ : (ref) => defaultReadVerdict(ref, authorizedRoot);
953
+ /**
954
+ * Run the D11 ceremony end to end. See the module doc for the full flow.
955
+ * Any refusal at any step returns a structured `{promoted:false, reason}`
956
+ * with `localOnly: true`, and guarantees `promoteNote` (slice 3's raw
957
+ * dispatch, the only thing that ever touches the injected port for a
958
+ * promotion) is NEVER reached.
959
+ */
960
+ return async function runD11Ceremony(input, deps) {
961
+ // =========================================================================
962
+ // D11 FIX ROUND 4 FIX 1 — the FIRST thing every ceremony run does: mint a
963
+ // fresh, unpredictable, gate-owned nonce (`node:crypto.randomBytes`, NOT
964
+ // Math.random/Date.now). Never persisted, never reused — a "stateless
965
+ // one-shot": THIS run's own value is the ONE source of truth every read
966
+ // artifact is checked against below (see the ROUND 4 module doc).
967
+ // =========================================================================
968
+ const ceremonyNonce = randomBytes(32).toString("base64");
969
+ if (!deps) {
970
+ return refuse("no ceremony dependencies injected — refusing (fail-closed, I5)");
971
+ }
972
+ // =========================================================================
973
+ // D11 FIX ROUND 3 §B — verify the note's AUTHOR SIGNATURE FIRST, against
974
+ // the SAME construction-time trustedKeystore + verifySignature used for
975
+ // verdict signatures (no second trust root). Every separation-of-powers
976
+ // check below compares against the VERIFIED signer's identity —
977
+ // `verifiedAuthorId` — never `input.authorId` (an unverified, per-call
978
+ // hint this module structurally never reads for a trust decision).
979
+ // FAIL-CLOSED: no signature, a malformed one, a noteId mismatch, or one
980
+ // that does not verify against the trusted keystore refuses immediately —
981
+ // launchLeg is never called, exactly like the other pre-launch checks.
982
+ // =========================================================================
983
+ const authorVerification = verifyNoteAuthor(input.note, trustedKeystore, verifySignature);
984
+ if (!authorVerification.ok) {
985
+ return refuse(authorVerification.reason);
986
+ }
987
+ const verifiedAuthorId = authorVerification.authorId;
988
+ const authorKeyFingerprint = authorVerification.authorKeyFingerprint;
989
+ // D11 FIX ROUND 2: `readVerdict` is intentionally NOT destructured from
990
+ // `deps` here — it is not a field of `RunD11CeremonyDeps` at all. Even if
991
+ // a caller attaches a `readVerdict` property to the object passed as
992
+ // `deps` anyway (JS does not enforce the TS shape at runtime), it is
993
+ // never read: the CLOSED-OVER `readVerdict` from `createD11Ceremony`
994
+ // (above) is what's used below, unconditionally.
995
+ const { launchLeg, writeVerdict, writeAttestation, legSpecs, port, ctx } = deps;
996
+ if (!Array.isArray(legSpecs) || legSpecs.length !== 2) {
997
+ return refuse("exactly 2 legSpecs are required to run a double-consensus ceremony");
998
+ }
999
+ const [legSpec1, legSpec2] = legSpecs;
1000
+ // --- Separation of powers, BEFORE anything is launched. ---
1001
+ if (sameLegSpec(legSpec1, legSpec2)) {
1002
+ return refuse("the two legSpecs are not structurally distinct (same model+session) — refusing before launch");
1003
+ }
1004
+ if (legSpec1.session === verifiedAuthorId || legSpec2.session === verifiedAuthorId) {
1005
+ return refuse("a legSpec's session equals the note's VERIFIED author — separation of powers requires launching only independent reviewers");
1006
+ }
1007
+ // =========================================================================
1008
+ // D11 FIX ROUND 4 FIX 2 — separation of powers on CANONICAL CRYPTO
1009
+ // PRINCIPALS, PRE-LAUNCH. The session-string checks above are kept as
1010
+ // defense in depth, but a public key can back TWO (or three) distinct
1011
+ // `{model, session}` identities in the SAME trusted keystore — a
1012
+ // `.session` alias the checks above cannot see. Resolve + fingerprint
1013
+ // BOTH legSpecs' trusted keys BEFORE a single model call is made; refuse
1014
+ // fail-closed on a missing/unparseable key (cannot establish
1015
+ // distinctness, so it is never treated as "assume distinct").
1016
+ // =========================================================================
1017
+ let legSpec1KeyFingerprint;
1018
+ let legSpec2KeyFingerprint;
1019
+ try {
1020
+ const legSpec1PublicKey = trustedKeystore.getPublicKey(legSpec1);
1021
+ if (typeof legSpec1PublicKey !== "string" || legSpec1PublicKey.length === 0) {
1022
+ return refuse("legSpec1: no trusted public key — refusing before launch (fail-closed; cannot establish crypto-principal distinctness)");
1023
+ }
1024
+ const legSpec2PublicKey = trustedKeystore.getPublicKey(legSpec2);
1025
+ if (typeof legSpec2PublicKey !== "string" || legSpec2PublicKey.length === 0) {
1026
+ return refuse("legSpec2: no trusted public key — refusing before launch (fail-closed; cannot establish crypto-principal distinctness)");
1027
+ }
1028
+ legSpec1KeyFingerprint = canonicalKeyFingerprint(legSpec1PublicKey);
1029
+ legSpec2KeyFingerprint = canonicalKeyFingerprint(legSpec2PublicKey);
1030
+ }
1031
+ catch (err) {
1032
+ return refuse(`could not resolve a canonical key fingerprint before launch — refusing (fail-closed) — ${errorReason(err)}`);
1033
+ }
1034
+ if (legSpec1KeyFingerprint === authorKeyFingerprint || legSpec2KeyFingerprint === authorKeyFingerprint) {
1035
+ return refuse("a legSpec's trusted public key is the SAME canonical crypto principal as the note's VERIFIED author (key-fingerprint alias) — separation of powers requires launching only independent reviewers — refusing before launch");
1036
+ }
1037
+ if (legSpec1KeyFingerprint === legSpec2KeyFingerprint) {
1038
+ return refuse("legSpec1 and legSpec2 resolve to the SAME canonical crypto principal (key-fingerprint alias) — refusing before launch (two reviewer identities must not share one key)");
1039
+ }
1040
+ if (typeof launchLeg !== "function") {
1041
+ return refuse("no launchLeg injected — refusing (fail-closed, I5)");
1042
+ }
1043
+ // --- Launch both legs CONCURRENTLY: neither sees the other's verdict. ---
1044
+ // D11 FIX ROUND 4 FIX 1: both legs receive THIS run's ceremonyNonce as a
1045
+ // 3rd arg — an honest leg threads it into its returned LegVerdict.
1046
+ let v1;
1047
+ let v2;
1048
+ try {
1049
+ [v1, v2] = await Promise.all([
1050
+ launchLeg(input.note, legSpec1, ceremonyNonce),
1051
+ launchLeg(input.note, legSpec2, ceremonyNonce)
1052
+ ]);
1053
+ }
1054
+ catch (err) {
1055
+ return refuse(`launchLeg failed: ${errorReason(err)}`);
1056
+ }
1057
+ // --- Cheap pre-write reject on the INLINE (caller-controlled) verdicts. ---
1058
+ // This is a courtesy that saves a write + read round trip on an obviously
1059
+ // bad ceremony; it is NOT the authority — the READ, signature-verified
1060
+ // gate below (D11 FIX §3) is what actually protects `promoteNote`.
1061
+ const precheckAttestation = {
1062
+ leg1: v1.leg,
1063
+ leg2: v2.leg,
1064
+ distinctModels: v1.leg.model !== v2.leg.model,
1065
+ distinctSessions: v1.leg.session !== v2.leg.session,
1066
+ verdictsWrittenBeforeCrossVisibility: true,
1067
+ orchestrator: ORCHESTRATOR_ID
1068
+ };
1069
+ const precheck = checkDoubleConsensusPreconditions({
1070
+ verdicts: [v1, v2],
1071
+ attestation: precheckAttestation,
1072
+ leg1Ref: PENDING_LEG1_REF,
1073
+ leg2Ref: PENDING_LEG2_REF,
1074
+ authorId: verifiedAuthorId
1075
+ });
1076
+ if (!precheck.ok) {
1077
+ return refuse(`double-consensus preconditions not met: ${precheck.reason}`);
1078
+ }
1079
+ if (typeof writeVerdict !== "function") {
1080
+ return refuse("no writeVerdict injected — refusing (fail-closed, I5)");
1081
+ }
1082
+ let leg1Ref;
1083
+ let leg2Ref;
1084
+ try {
1085
+ leg1Ref = await writeVerdict(v1);
1086
+ leg2Ref = await writeVerdict(v2);
1087
+ }
1088
+ catch (err) {
1089
+ return refuse(`writeVerdict failed: ${errorReason(err)}`);
1090
+ }
1091
+ // =========================================================================
1092
+ // D11 FIX ROUND 3 §3 — path confinement, BEFORE any filesystem access on
1093
+ // writeVerdict's per-call, caller-controlled ref. Active whenever
1094
+ // `authorizedRoot` is configured (always, when using the real default
1095
+ // reader — construction requires it; also as defense in depth when a
1096
+ // construction site supplies both a custom reader AND an authorizedRoot).
1097
+ // The ceremony derives the ONE location each leg's verdict may be read
1098
+ // from, from data it already trusts at this point (the note being
1099
+ // promoted, and the legSpec it actually dispatched) — and requires the
1100
+ // per-call ref to resolve to EXACTLY that path, string-for-string,
1101
+ // REFUSING otherwise. This closes TRAVERSAL structurally: a `../`-laden
1102
+ // ref can never be textually identical to the clean, sanitized,
1103
+ // ceremony-derived path.
1104
+ // =========================================================================
1105
+ let leg1ReadRef = leg1Ref;
1106
+ let leg2ReadRef = leg2Ref;
1107
+ if (authorizedRoot) {
1108
+ let expectedRef1;
1109
+ let expectedRef2;
1110
+ try {
1111
+ expectedRef1 = deriveVerdictRef(authorizedRoot, input.note.noteId, legSpec1);
1112
+ expectedRef2 = deriveVerdictRef(authorizedRoot, input.note.noteId, legSpec2);
1113
+ }
1114
+ catch (err) {
1115
+ return refuse(`could not derive a confined verdict path: ${errorReason(err)}`);
1116
+ }
1117
+ if (resolve(leg1Ref) !== expectedRef1) {
1118
+ return refuse("leg1: writeVerdict's ref does not resolve to the ceremony-derived confined path — refusing (traversal/relocation refused before any file access)");
1119
+ }
1120
+ if (resolve(leg2Ref) !== expectedRef2) {
1121
+ return refuse("leg2: writeVerdict's ref does not resolve to the ceremony-derived confined path — refusing (traversal/relocation refused before any file access)");
1122
+ }
1123
+ // From here on, ONLY the ceremony's own derived strings are ever
1124
+ // handed to readVerdict — never the raw per-call ref, even though the
1125
+ // two are required equal at this point.
1126
+ leg1ReadRef = expectedRef1;
1127
+ leg2ReadRef = expectedRef2;
1128
+ }
1129
+ // =========================================================================
1130
+ // D11 FIX §3 — the READ, signature-verified gate. Everything from here on
1131
+ // is what actually protects `promoteNote`; nothing before this point does.
1132
+ // `readVerdict` here is ALWAYS the construction-time closure (ROUND 2) —
1133
+ // a real function is guaranteed (default or a construction-time override),
1134
+ // so there is no "readVerdict absent" branch left to guard: a missing
1135
+ // durable artifact now surfaces as `readVerdict(ref)` resolving to `null`
1136
+ // (checked immediately below), not as this dep being absent.
1137
+ // =========================================================================
1138
+ let artifact1;
1139
+ let artifact2;
1140
+ try {
1141
+ [artifact1, artifact2] = await Promise.all([readVerdict(leg1ReadRef), readVerdict(leg2ReadRef)]);
1142
+ }
1143
+ catch (err) {
1144
+ return refuse(`readVerdict failed: ${errorReason(err)}`);
1145
+ }
1146
+ if (!artifact1) {
1147
+ return refuse("readVerdict found no artifact at leg1's ref — refusing (no file at the claimed ref; the fabrication hole this closes)");
1148
+ }
1149
+ if (!artifact2) {
1150
+ return refuse("readVerdict found no artifact at leg2's ref — refusing (no file at the claimed ref; the fabrication hole this closes)");
1151
+ }
1152
+ // --- Per artifact: freshness, fingerprint, signature (against the CLAIMED leg's trusted key), GO, anti-replay. ---
1153
+ let artifact1KeyFingerprint;
1154
+ let artifact2KeyFingerprint;
1155
+ for (const [label, artifact] of [
1156
+ ["leg1", artifact1],
1157
+ ["leg2", artifact2]
1158
+ ]) {
1159
+ // D11 FIX ROUND 4 FIX 1 — freshness, checked FIRST and cheaply: REQUIRE
1160
+ // this artifact was signed for THIS ceremony's own, freshly generated
1161
+ // nonce. A stale/replayed artifact (from ANY prior ceremony, even one
1162
+ // that genuinely, validly signed it) is refused here before any
1163
+ // signature math runs.
1164
+ if (artifact.ceremonyNonce !== ceremonyNonce) {
1165
+ return refuse(`${label}: the read verdict's ceremonyNonce does not match this ceremony's freshly generated nonce — refusing (stale or replayed artifact; anti-replay)`);
1166
+ }
1167
+ const publicKey = trustedKeystore.getPublicKey(artifact.leg);
1168
+ if (typeof publicKey !== "string" || publicKey.length === 0) {
1169
+ return refuse(`${label}: no trusted public key for the claimed leg — refusing (fail-closed; unknown leg or empty keystore)`);
1170
+ }
1171
+ // D11 FIX ROUND 4 FIX 2 — resolve the canonical crypto-principal
1172
+ // fingerprint for the CLAIMED leg's trusted key (the SAME key about to
1173
+ // verify the signature below). Used by the cross-artifact checks
1174
+ // after this loop.
1175
+ let fingerprint;
1176
+ try {
1177
+ fingerprint = canonicalKeyFingerprint(publicKey);
1178
+ }
1179
+ catch (err) {
1180
+ return refuse(`${label}: trusted public key is not a parseable Ed25519 key — refusing (fail-closed) — ${errorReason(err)}`);
1181
+ }
1182
+ if (label === "leg1") {
1183
+ artifact1KeyFingerprint = fingerprint;
1184
+ }
1185
+ else {
1186
+ artifact2KeyFingerprint = fingerprint;
1187
+ }
1188
+ let signatureOk;
1189
+ try {
1190
+ // D11 FIX ROUND 4 FIX 1 — the payload is rebuilt from THIS RUN's OWN
1191
+ // `ceremonyNonce` variable, never `artifact.ceremonyNonce` — see
1192
+ // `verdictSignedPayload` and the ROUND 4 module doc for why this,
1193
+ // independently of the explicit check above, already refuses a
1194
+ // same-note replay.
1195
+ signatureOk = verifySignature(verdictSignedPayload(artifact, ceremonyNonce), artifact.signature, publicKey);
1196
+ }
1197
+ catch (err) {
1198
+ return refuse(`${label}: signature verification threw — ${errorReason(err)}`);
1199
+ }
1200
+ if (!signatureOk) {
1201
+ return refuse(`${label}: signature invalid for the claimed leg — refusing (fabricated, tampered, or signed by the wrong key)`);
1202
+ }
1203
+ if (artifact.verdict !== "GO") {
1204
+ return refuse(`${label}: the read verdict is not GO — refusing`);
1205
+ }
1206
+ if (artifact.noteId !== input.note.noteId) {
1207
+ return refuse(`${label}: the read verdict's noteId does not match the note being promoted — refusing (anti-replay)`);
1208
+ }
1209
+ }
1210
+ // --- Across the two READ artifacts, off the READ content only. ---
1211
+ if (sameLegSpec(artifact1.leg, artifact2.leg)) {
1212
+ return refuse("the two read verdict artifacts are not independent — the same leg was read twice — refusing");
1213
+ }
1214
+ if (artifact1.leg.session === verifiedAuthorId || artifact2.leg.session === verifiedAuthorId) {
1215
+ return refuse("a read verdict artifact's leg session equals the note's VERIFIED author — separation of powers requires an independent reviewer — refusing");
1216
+ }
1217
+ // D11 FIX ROUND 4 FIX 2 — separation of powers on the ACTUAL READ
1218
+ // artifacts' CANONICAL CRYPTO PRINCIPALS, POST-READ. Re-checked off the
1219
+ // fingerprints resolved from the SAME trusted keys that just verified
1220
+ // each artifact's signature — a `readVerdict` returning a different leg
1221
+ // than the one launched (and thus a different key) is still caught
1222
+ // here, symmetric with the existing "off the READ content only"
1223
+ // discipline for the session-string checks above. This is the
1224
+ // AUTHORITATIVE crypto check; the session-string checks above are kept
1225
+ // as defense in depth.
1226
+ if (artifact1KeyFingerprint === authorKeyFingerprint || artifact2KeyFingerprint === authorKeyFingerprint) {
1227
+ return refuse("a read verdict artifact's leg resolves to the SAME canonical crypto principal as the note's VERIFIED author (key-fingerprint alias) — separation of powers requires an independent reviewer — refusing");
1228
+ }
1229
+ if (artifact1KeyFingerprint === artifact2KeyFingerprint) {
1230
+ return refuse("the two read verdict artifacts resolve to the SAME canonical crypto principal (key-fingerprint alias) — refusing (two reviewer identities must not share one key)");
1231
+ }
1232
+ // --- Per artifact again: coherence with the INLINE verdict launchLeg returned. ---
1233
+ if (!coherentWithInline(artifact1, v1)) {
1234
+ return refuse("leg1: the read verdict artifact is not coherent with launchLeg's inline verdict — refusing");
1235
+ }
1236
+ if (!coherentWithInline(artifact2, v2)) {
1237
+ return refuse("leg2: the read verdict artifact is not coherent with launchLeg's inline verdict — refusing");
1238
+ }
1239
+ // --- Build the FINAL verdicts + attestation from the READ, VERIFIED artifacts. ---
1240
+ // Never the inline v1/v2 from here on — a bug in the checks above cannot
1241
+ // cause a fabricated inline verdict to be what actually gets promoted.
1242
+ const verifiedVerdicts = [
1243
+ toMemoryVerdict(artifact1),
1244
+ toMemoryVerdict(artifact2)
1245
+ ];
1246
+ const attestation = {
1247
+ leg1: artifact1.leg,
1248
+ leg2: artifact2.leg,
1249
+ distinctModels: artifact1.leg.model !== artifact2.leg.model,
1250
+ distinctSessions: artifact1.leg.session !== artifact2.leg.session,
1251
+ verdictsWrittenBeforeCrossVisibility: true,
1252
+ orchestrator: ORCHESTRATOR_ID
1253
+ };
1254
+ if (typeof writeAttestation !== "function") {
1255
+ return refuse("no writeAttestation injected — refusing (fail-closed, I5)");
1256
+ }
1257
+ let attestationRef;
1258
+ try {
1259
+ attestationRef = await writeAttestation(attestation);
1260
+ }
1261
+ catch (err) {
1262
+ return refuse(`writeAttestation failed: ${errorReason(err)}`);
1263
+ }
1264
+ if (!port) {
1265
+ return refuse("no memory producer port injected — refusing (fail-closed, I5)");
1266
+ }
1267
+ // `promoteNoteWithDoubleConsensus` re-runs `checkDoubleConsensusPreconditions`
1268
+ // on this VERIFIED data as an unconditional second layer (unchanged from
1269
+ // slice 3) before ever touching the port.
1270
+ return promoteNoteWithDoubleConsensus({
1271
+ noteId: input.note.noteId,
1272
+ ctx,
1273
+ verdicts: verifiedVerdicts,
1274
+ attestation,
1275
+ attestationRef,
1276
+ leg1Ref,
1277
+ leg2Ref,
1278
+ authorId: verifiedAuthorId
1279
+ }, port);
1280
+ };
1281
+ }
1282
+ //# sourceMappingURL=d11-ceremony.js.map