@intentius/chant 0.100.0 → 0.101.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 (276) hide show
  1. package/dist/build.d.ts +6 -0
  2. package/dist/build.d.ts.map +1 -1
  3. package/dist/cli/build-options.d.ts +2 -0
  4. package/dist/cli/build-options.d.ts.map +1 -1
  5. package/dist/cli/commands/build.d.ts.map +1 -1
  6. package/dist/cli/commands/import.d.ts.map +1 -1
  7. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  8. package/dist/cli/main.d.ts.map +1 -1
  9. package/dist/cli/mcp/workspace-tools.d.ts +8 -0
  10. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
  11. package/dist/cli/registry.d.ts +22 -0
  12. package/dist/cli/registry.d.ts.map +1 -1
  13. package/dist/config.d.ts +11 -0
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/lexicon.d.ts +24 -1
  16. package/dist/lexicon.d.ts.map +1 -1
  17. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  18. package/dist/lifecycle/plan-digest.d.ts +26 -5
  19. package/dist/lifecycle/plan-digest.d.ts.map +1 -1
  20. package/dist/lint/config.d.ts +4 -4
  21. package/dist/op/activities/activity-contracts.d.ts +1 -0
  22. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  23. package/dist/op/activities/propose-upgrade.d.ts +2 -0
  24. package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
  25. package/dist/op/index.d.ts +1 -1
  26. package/dist/op/index.d.ts.map +1 -1
  27. package/dist/serializer.d.ts +8 -0
  28. package/dist/serializer.d.ts.map +1 -1
  29. package/dist/telemetry-attribution.d.ts +77 -0
  30. package/dist/telemetry-attribution.d.ts.map +1 -0
  31. package/dist/workspace/agent-cli.d.ts +83 -0
  32. package/dist/workspace/agent-cli.d.ts.map +1 -0
  33. package/dist/workspace/changes-cli.d.ts.map +1 -1
  34. package/dist/workspace/changes.d.ts +8 -1
  35. package/dist/workspace/changes.d.ts.map +1 -1
  36. package/dist/workspace/checks/links.d.ts +1 -0
  37. package/dist/workspace/checks/links.d.ts.map +1 -1
  38. package/dist/workspace/checks/live.d.ts +40 -0
  39. package/dist/workspace/checks/live.d.ts.map +1 -0
  40. package/dist/workspace/checks.d.ts +21 -2
  41. package/dist/workspace/checks.d.ts.map +1 -1
  42. package/dist/workspace/compose-graph.d.ts +63 -0
  43. package/dist/workspace/compose-graph.d.ts.map +1 -1
  44. package/dist/workspace/decide.d.ts +1 -1
  45. package/dist/workspace/decide.d.ts.map +1 -1
  46. package/dist/workspace/declaration.d.ts +32 -0
  47. package/dist/workspace/declaration.d.ts.map +1 -1
  48. package/dist/workspace/declaration.schema.json +138 -3
  49. package/dist/workspace/export-cli.d.ts +12 -0
  50. package/dist/workspace/export-cli.d.ts.map +1 -0
  51. package/dist/workspace/export.d.ts +145 -0
  52. package/dist/workspace/export.d.ts.map +1 -0
  53. package/dist/workspace/graph-cli.d.ts.map +1 -1
  54. package/dist/workspace/import.d.ts +73 -0
  55. package/dist/workspace/import.d.ts.map +1 -0
  56. package/dist/workspace/kinds.d.ts +6 -2
  57. package/dist/workspace/kinds.d.ts.map +1 -1
  58. package/dist/workspace/lineage-adopt-cli.d.ts +15 -0
  59. package/dist/workspace/lineage-adopt-cli.d.ts.map +1 -0
  60. package/dist/workspace/lineage-adopt.d.ts +106 -0
  61. package/dist/workspace/lineage-adopt.d.ts.map +1 -0
  62. package/dist/workspace/lineage-check.d.ts +9 -2
  63. package/dist/workspace/lineage-check.d.ts.map +1 -1
  64. package/dist/workspace/lineage-cli.d.ts +6 -1
  65. package/dist/workspace/lineage-cli.d.ts.map +1 -1
  66. package/dist/workspace/lineage-hash-index.d.ts +110 -0
  67. package/dist/workspace/lineage-hash-index.d.ts.map +1 -0
  68. package/dist/workspace/lineage-init.d.ts +10 -0
  69. package/dist/workspace/lineage-init.d.ts.map +1 -1
  70. package/dist/workspace/lineage-lock.d.ts +147 -0
  71. package/dist/workspace/lineage-lock.d.ts.map +1 -1
  72. package/dist/workspace/lineage-migrations.d.ts +15 -3
  73. package/dist/workspace/lineage-migrations.d.ts.map +1 -1
  74. package/dist/workspace/lineage-provenance.d.ts +18 -0
  75. package/dist/workspace/lineage-provenance.d.ts.map +1 -0
  76. package/dist/workspace/lineage-upgrade-cli.d.ts +2 -0
  77. package/dist/workspace/lineage-upgrade-cli.d.ts.map +1 -1
  78. package/dist/workspace/lineage-upgrade.d.ts +30 -1
  79. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  80. package/dist/workspace/lineage-versions.d.ts +116 -0
  81. package/dist/workspace/lineage-versions.d.ts.map +1 -0
  82. package/dist/workspace/links.d.ts +31 -5
  83. package/dist/workspace/links.d.ts.map +1 -1
  84. package/dist/workspace/ls-generated.d.ts +37 -0
  85. package/dist/workspace/ls-generated.d.ts.map +1 -0
  86. package/dist/workspace/ls.d.ts +4 -0
  87. package/dist/workspace/ls.d.ts.map +1 -1
  88. package/dist/workspace/member-commands.d.ts.map +1 -1
  89. package/dist/workspace/nested-graph.d.ts +56 -0
  90. package/dist/workspace/nested-graph.d.ts.map +1 -0
  91. package/dist/workspace/nesting.d.ts +21 -0
  92. package/dist/workspace/nesting.d.ts.map +1 -0
  93. package/dist/workspace/pin-cli.d.ts +10 -0
  94. package/dist/workspace/pin-cli.d.ts.map +1 -0
  95. package/dist/workspace/pin-integrity.d.ts +51 -0
  96. package/dist/workspace/pin-integrity.d.ts.map +1 -0
  97. package/dist/workspace/reason-codes.d.ts +26 -2
  98. package/dist/workspace/reason-codes.d.ts.map +1 -1
  99. package/dist/workspace/record-sessions.d.ts +7 -11
  100. package/dist/workspace/record-sessions.d.ts.map +1 -1
  101. package/dist/workspace/records-cli.d.ts +30 -1
  102. package/dist/workspace/records-cli.d.ts.map +1 -1
  103. package/dist/workspace/records-close.d.ts +5 -2
  104. package/dist/workspace/records-close.d.ts.map +1 -1
  105. package/dist/workspace/records-write.d.ts +22 -4
  106. package/dist/workspace/records-write.d.ts.map +1 -1
  107. package/dist/workspace/records.d.ts +43 -5
  108. package/dist/workspace/records.d.ts.map +1 -1
  109. package/dist/workspace/returns.d.ts +129 -0
  110. package/dist/workspace/returns.d.ts.map +1 -0
  111. package/dist/workspace/status-gates.d.ts.map +1 -1
  112. package/dist/workspace/template-manifest.d.ts +11 -3
  113. package/dist/workspace/template-manifest.d.ts.map +1 -1
  114. package/dist/workspace/trust/attestor.d.ts +8 -0
  115. package/dist/workspace/trust/attestor.d.ts.map +1 -1
  116. package/dist/workspace/trust/dsse.d.ts +58 -0
  117. package/dist/workspace/trust/dsse.d.ts.map +1 -0
  118. package/dist/workspace/trust/evidence-cli.d.ts +66 -0
  119. package/dist/workspace/trust/evidence-cli.d.ts.map +1 -0
  120. package/dist/workspace/trust/evidence.d.ts +93 -0
  121. package/dist/workspace/trust/evidence.d.ts.map +1 -0
  122. package/dist/workspace/trust/policy.d.ts +54 -1
  123. package/dist/workspace/trust/policy.d.ts.map +1 -1
  124. package/dist/workspace/trust/provenance.d.ts +21 -1
  125. package/dist/workspace/trust/provenance.d.ts.map +1 -1
  126. package/dist/workspace/trust/rotation.d.ts +132 -0
  127. package/dist/workspace/trust/rotation.d.ts.map +1 -0
  128. package/dist/workspace/trust/seal.d.ts.map +1 -1
  129. package/dist/workspace/trust/signers-cli.d.ts +49 -0
  130. package/dist/workspace/trust/signers-cli.d.ts.map +1 -0
  131. package/dist/workspace/trust/ssh-commit.d.ts +12 -0
  132. package/dist/workspace/trust/ssh-commit.d.ts.map +1 -1
  133. package/dist/workspace/trust/test-repo.d.ts +13 -0
  134. package/dist/workspace/trust/test-repo.d.ts.map +1 -1
  135. package/dist/workspace/trust/verify.d.ts +15 -0
  136. package/dist/workspace/trust/verify.d.ts.map +1 -1
  137. package/dist/workspace/work-evidence.d.ts +1 -1
  138. package/dist/workspace/work-evidence.d.ts.map +1 -1
  139. package/dist/workspace/write-scope.d.ts +199 -0
  140. package/dist/workspace/write-scope.d.ts.map +1 -0
  141. package/package.json +1 -1
  142. package/src/build.ts +8 -0
  143. package/src/cli/build-options.ts +4 -1
  144. package/src/cli/commands/build.ts +2 -0
  145. package/src/cli/commands/import-live.test.ts +69 -1
  146. package/src/cli/commands/import.ts +48 -22
  147. package/src/cli/handlers/fan-out.test.ts +6 -6
  148. package/src/cli/handlers/fan-out.ts +2 -1
  149. package/src/cli/handlers/graph.test.ts +42 -0
  150. package/src/cli/handlers/graph.ts +22 -0
  151. package/src/cli/handlers/operator.ts +1 -1
  152. package/src/cli/main.test.ts +32 -0
  153. package/src/cli/main.ts +103 -6
  154. package/src/cli/mcp/workspace-tools.test.ts +1 -1
  155. package/src/cli/mcp/workspace-tools.ts +33 -2
  156. package/src/cli/registry.ts +22 -0
  157. package/src/cli/serve-mcp-workspace.test.ts +1 -1
  158. package/src/codegen/release-wiring.test.ts +5 -1
  159. package/src/components/fan-out-output.test.ts +1 -1
  160. package/src/components/fan-out.test.ts +1 -1
  161. package/src/components/promote.test.ts +1 -1
  162. package/src/config.ts +12 -0
  163. package/src/content-digest.test.ts +2 -2
  164. package/src/lexicon.ts +25 -1
  165. package/src/lifecycle/gate-ledger.test.ts +14 -0
  166. package/src/lifecycle/gate-ledger.ts +2 -1
  167. package/src/lifecycle/plan-digest.test.ts +54 -3
  168. package/src/lifecycle/plan-digest.ts +38 -8
  169. package/src/op/activities/activity-contracts.ts +1 -0
  170. package/src/op/activities/propose-upgrade.ts +8 -5
  171. package/src/op/gate-approval.test.ts +17 -0
  172. package/src/op/gate.ts +3 -3
  173. package/src/op/index.ts +1 -1
  174. package/src/serializer.ts +9 -0
  175. package/src/telemetry-attribution.test.ts +91 -0
  176. package/src/telemetry-attribution.ts +145 -0
  177. package/src/workspace/agent-cli.ts +134 -0
  178. package/src/workspace/agent.schema.json +356 -0
  179. package/src/workspace/behold-kinds.test.ts +1 -1
  180. package/src/workspace/changes-cli.ts +5 -0
  181. package/src/workspace/changes.schema.json +179 -1
  182. package/src/workspace/changes.ts +58 -4
  183. package/src/workspace/check-live.test.ts +192 -0
  184. package/src/workspace/check.schema.json +64 -0
  185. package/src/workspace/checks/links.ts +23 -2
  186. package/src/workspace/checks/live.ts +113 -0
  187. package/src/workspace/checks.test.ts +2 -2
  188. package/src/workspace/checks.ts +20 -3
  189. package/src/workspace/compose-graph.test.ts +47 -0
  190. package/src/workspace/compose-graph.ts +120 -3
  191. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +7 -1
  192. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +5 -0
  193. package/src/workspace/declaration.schema.json +138 -3
  194. package/src/workspace/declaration.ts +106 -1
  195. package/src/workspace/declared-kinds.test.ts +33 -0
  196. package/src/workspace/evidence.schema.json +279 -0
  197. package/src/workspace/export-cli.ts +180 -0
  198. package/src/workspace/export-import.test.ts +282 -0
  199. package/src/workspace/export.ts +486 -0
  200. package/src/workspace/graph-cli.ts +47 -1
  201. package/src/workspace/graph-contract.test.ts +89 -4
  202. package/src/workspace/graph.schema.json +206 -1
  203. package/src/workspace/import.ts +325 -0
  204. package/src/workspace/kinds.test.ts +5 -5
  205. package/src/workspace/kinds.ts +25 -3
  206. package/src/workspace/lineage-adopt-cli.ts +103 -0
  207. package/src/workspace/lineage-adopt.test.ts +552 -0
  208. package/src/workspace/lineage-adopt.ts +452 -0
  209. package/src/workspace/lineage-check.ts +26 -5
  210. package/src/workspace/lineage-cli.ts +12 -2
  211. package/src/workspace/lineage-hash-index.ts +305 -0
  212. package/src/workspace/lineage-init.test.ts +9 -0
  213. package/src/workspace/lineage-init.ts +29 -9
  214. package/src/workspace/lineage-lock.ts +54 -0
  215. package/src/workspace/lineage-migrations.test.ts +27 -0
  216. package/src/workspace/lineage-migrations.ts +44 -14
  217. package/src/workspace/lineage-provenance.ts +40 -0
  218. package/src/workspace/lineage-upgrade-cli.ts +9 -5
  219. package/src/workspace/lineage-upgrade.test.ts +92 -1
  220. package/src/workspace/lineage-upgrade.ts +111 -16
  221. package/src/workspace/lineage-versions.ts +348 -0
  222. package/src/workspace/links.test.ts +121 -2
  223. package/src/workspace/links.ts +97 -6
  224. package/src/workspace/ls-contract.test.ts +84 -1
  225. package/src/workspace/ls-generated.ts +111 -0
  226. package/src/workspace/ls.schema.json +19 -0
  227. package/src/workspace/ls.ts +8 -1
  228. package/src/workspace/member-commands.ts +3 -1
  229. package/src/workspace/nested-graph.test.ts +176 -0
  230. package/src/workspace/nested-graph.ts +169 -0
  231. package/src/workspace/nesting.ts +37 -0
  232. package/src/workspace/pin-cli.test.ts +71 -0
  233. package/src/workspace/pin-cli.ts +57 -0
  234. package/src/workspace/pin-integrity.test.ts +121 -0
  235. package/src/workspace/pin-integrity.ts +104 -0
  236. package/src/workspace/points-write.schema.json +1 -0
  237. package/src/workspace/read-contract.test.ts +24 -0
  238. package/src/workspace/reason-codes.test.ts +10 -0
  239. package/src/workspace/reason-codes.ts +29 -2
  240. package/src/workspace/record-sessions.ts +12 -14
  241. package/src/workspace/records-amend.schema.json +4 -0
  242. package/src/workspace/records-cli.ts +93 -12
  243. package/src/workspace/records-close.schema.json +6 -2
  244. package/src/workspace/records-close.ts +10 -2
  245. package/src/workspace/records-formats.test.ts +13 -5
  246. package/src/workspace/records-new.schema.json +4 -0
  247. package/src/workspace/records-review.schema.json +4 -0
  248. package/src/workspace/records-sessions.test.ts +23 -6
  249. package/src/workspace/records-write.test.ts +65 -8
  250. package/src/workspace/records-write.ts +70 -6
  251. package/src/workspace/records.schema.json +55 -3
  252. package/src/workspace/records.ts +96 -5
  253. package/src/workspace/returns.ts +328 -0
  254. package/src/workspace/signers.schema.json +206 -0
  255. package/src/workspace/status-gates.ts +2 -1
  256. package/src/workspace/template-manifest.ts +22 -4
  257. package/src/workspace/trust/attestor.ts +15 -0
  258. package/src/workspace/trust/dsse.ts +134 -0
  259. package/src/workspace/trust/evidence-cli.ts +195 -0
  260. package/src/workspace/trust/evidence.test.ts +241 -0
  261. package/src/workspace/trust/evidence.ts +207 -0
  262. package/src/workspace/trust/policy.ts +110 -3
  263. package/src/workspace/trust/provenance.ts +41 -4
  264. package/src/workspace/trust/record-seal.test.ts +1 -1
  265. package/src/workspace/trust/rotation.test.ts +258 -0
  266. package/src/workspace/trust/rotation.ts +336 -0
  267. package/src/workspace/trust/seal.ts +11 -0
  268. package/src/workspace/trust/signers-cli.ts +178 -0
  269. package/src/workspace/trust/ssh-commit.ts +53 -4
  270. package/src/workspace/trust/test-repo.ts +18 -0
  271. package/src/workspace/trust/trust.test.ts +8 -2
  272. package/src/workspace/trust/verify-cli.ts +1 -0
  273. package/src/workspace/trust/verify.ts +22 -0
  274. package/src/workspace/work-evidence.schema.json +4 -0
  275. package/src/workspace/write-scope.test.ts +340 -0
  276. package/src/workspace/write-scope.ts +448 -0
@@ -0,0 +1,134 @@
1
+ /**
2
+ * DSSE envelopes (https://github.com/secure-systems-lab/dsse, v1) signed with
3
+ * Ed25519 runner keys (#2553).
4
+ *
5
+ * The signature covers the pre-authentication encoding (PAE) of the payload
6
+ * type and the payload, never the payload alone, so an envelope cannot be
7
+ * replayed under another type. Everything here runs offline: verifying needs
8
+ * the envelope and the public key, nothing else.
9
+ *
10
+ * Public keys are written the way ssh writes them (`ssh-ed25519 AAAA...`), so
11
+ * the runner keys in `.chant/trust.json` read like the signers file beside
12
+ * them, and a key id is the key's ssh fingerprint (`SHA256:...`).
13
+ */
14
+
15
+ import { createHash, createPrivateKey, createPublicKey, sign, verify, type KeyObject } from "node:crypto";
16
+
17
+ export const IN_TOTO_PAYLOAD_TYPE = "application/vnd.in-toto+json";
18
+
19
+ export interface DsseSignature {
20
+ keyid: string;
21
+ sig: string;
22
+ }
23
+
24
+ export interface DsseEnvelope {
25
+ payloadType: string;
26
+ /** Base64 of the payload bytes. */
27
+ payload: string;
28
+ signatures: DsseSignature[];
29
+ }
30
+
31
+ /** DSSE v1 pre-authentication encoding. Lengths are byte lengths in ASCII decimal. */
32
+ export function pae(payloadType: string, payload: Buffer): Buffer {
33
+ const type = Buffer.from(payloadType, "utf8");
34
+ return Buffer.concat([Buffer.from(`DSSEv1 ${type.length} `, "utf8"), type, Buffer.from(` ${payload.length} `, "utf8"), payload]);
35
+ }
36
+
37
+ // ── ssh-ed25519 public keys ──────────────────────────────────────────────────
38
+
39
+ function sshString(buf: Buffer, at: number): { value: Buffer; next: number } {
40
+ if (at + 4 > buf.length) throw new Error("truncated key");
41
+ const len = buf.readUInt32BE(at);
42
+ if (at + 4 + len > buf.length) throw new Error("truncated key");
43
+ return { value: buf.subarray(at + 4, at + 4 + len), next: at + 4 + len };
44
+ }
45
+
46
+ /** The 32 raw bytes of an `ssh-ed25519 <base64>` public key. */
47
+ export function ed25519FromSsh(line: string): Buffer {
48
+ const [type, b64] = line.trim().split(/\s+/);
49
+ if (type !== "ssh-ed25519" || !b64) throw new Error("runner keys must be ssh-ed25519 public keys");
50
+ const blob = Buffer.from(b64, "base64");
51
+ const t = sshString(blob, 0);
52
+ if (t.value.toString("utf8") !== "ssh-ed25519") throw new Error("the key blob is not ssh-ed25519");
53
+ const k = sshString(blob, t.next);
54
+ if (k.value.length !== 32 || k.next !== blob.length) throw new Error("not a 32-byte ed25519 key");
55
+ return Buffer.from(k.value);
56
+ }
57
+
58
+ /** An `ssh-ed25519 <base64>` line for 32 raw public key bytes. */
59
+ export function sshFromEd25519(raw: Buffer): string {
60
+ const part = (b: Buffer) => {
61
+ const len = Buffer.alloc(4);
62
+ len.writeUInt32BE(b.length);
63
+ return Buffer.concat([len, b]);
64
+ };
65
+ return `ssh-ed25519 ${Buffer.concat([part(Buffer.from("ssh-ed25519")), part(raw)]).toString("base64")}`;
66
+ }
67
+
68
+ /** The ssh fingerprint of a public key line: `SHA256:` and unpadded base64. */
69
+ export function sshFingerprint(line: string): string {
70
+ const blob = Buffer.from(line.trim().split(/\s+/)[1] ?? "", "base64");
71
+ return `SHA256:${createHash("sha256").update(blob).digest("base64").replace(/=+$/, "")}`;
72
+ }
73
+
74
+ function publicKeyObject(line: string): KeyObject {
75
+ return createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: ed25519FromSsh(line).toString("base64url") }, format: "jwk" });
76
+ }
77
+
78
+ /** A private Ed25519 key from PEM (PKCS#8), and its public half as an ssh line. */
79
+ export function loadRunnerKey(pem: string): { key: KeyObject; publicKey: string } {
80
+ const key = createPrivateKey(pem);
81
+ if (key.asymmetricKeyType !== "ed25519") throw new Error(`runner keys must be Ed25519, not ${key.asymmetricKeyType}`);
82
+ const jwk = createPublicKey(key).export({ format: "jwk" }) as { x: string };
83
+ return { key, publicKey: sshFromEd25519(Buffer.from(jwk.x, "base64url")) };
84
+ }
85
+
86
+ // ── Sign and verify ──────────────────────────────────────────────────────────
87
+
88
+ export function signEnvelope(payloadType: string, payload: Buffer, key: KeyObject, publicKey: string): DsseEnvelope {
89
+ const sig = sign(null, pae(payloadType, payload), key);
90
+ return { payloadType, payload: payload.toString("base64"), signatures: [{ keyid: sshFingerprint(publicKey), sig: sig.toString("base64") }] };
91
+ }
92
+
93
+ export interface TrustedKey {
94
+ principal: string;
95
+ /** `ssh-ed25519 <base64>`. */
96
+ key: string;
97
+ }
98
+
99
+ export type EnvelopeVerdict =
100
+ | { ok: true; principal: string; keyid: string; payloadType: string; payload: Buffer }
101
+ | { ok: false; code: "envelope-invalid" | "envelope-untrusted"; reason: string };
102
+
103
+ /** Verify an envelope against `trusted`. One valid signature by a trusted key is enough. */
104
+ export function verifyEnvelope(envelope: unknown, trusted: readonly TrustedKey[]): EnvelopeVerdict {
105
+ if (!isEnvelope(envelope)) return { ok: false, code: "envelope-invalid", reason: "not a DSSE envelope: it needs payloadType, payload and signatures" };
106
+ const payload = Buffer.from(envelope.payload, "base64");
107
+ if (payload.toString("base64") !== envelope.payload) return { ok: false, code: "envelope-invalid", reason: "the payload is not canonical base64" };
108
+ const message = pae(envelope.payloadType, payload);
109
+ const byId = new Map(trusted.map((t) => [sshFingerprint(t.key), t]));
110
+ for (const s of envelope.signatures) {
111
+ const t = byId.get(s.keyid);
112
+ if (!t) continue;
113
+ let good = false;
114
+ try {
115
+ good = verify(null, message, publicKeyObject(t.key), Buffer.from(s.sig, "base64"));
116
+ } catch {
117
+ good = false;
118
+ }
119
+ if (good) return { ok: true, principal: t.principal, keyid: s.keyid, payloadType: envelope.payloadType, payload };
120
+ }
121
+ const ids = envelope.signatures.map((s) => s.keyid).join(", ") || "none";
122
+ return { ok: false, code: "envelope-untrusted", reason: `no signature verifies against a runner key the policy at base lists (key ids: ${ids})` };
123
+ }
124
+
125
+ function isEnvelope(v: unknown): v is DsseEnvelope {
126
+ if (v === null || typeof v !== "object") return false;
127
+ const e = v as Record<string, unknown>;
128
+ return (
129
+ typeof e.payloadType === "string" &&
130
+ typeof e.payload === "string" &&
131
+ Array.isArray(e.signatures) &&
132
+ e.signatures.every((s) => s !== null && typeof s === "object" && typeof (s as DsseSignature).keyid === "string" && typeof (s as DsseSignature).sig === "string")
133
+ );
134
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * `chant workspace evidence sign|verify` (#2553): runner evidence over the
3
+ * records a kind locates, in a DSSE envelope. See ./evidence.ts.
4
+ *
5
+ * `sign` runs in CI or in a service, with a runner key the policy at base
6
+ * lists. It refuses a key the signers file lists: evidence is a machine's
7
+ * statement, and a developer's key cannot make one. `verify` needs no
8
+ * network and no key, only the envelope and the repository.
9
+ *
10
+ * With `--json`, `verify` prints the `evidence.schema.json` document, and a
11
+ * refused `sign` prints its failure branch.
12
+ */
13
+
14
+ import { readFileSync, writeFileSync } from "node:fs";
15
+ import { formatError, formatSuccess } from "../../cli/format";
16
+ import type { CommandContext } from "../../cli/registry";
17
+ import { readerVersion } from "../declaration";
18
+ import { gitRevisionSource, gitRoot } from "../record-source";
19
+ import { loadRecordKind, readRecords, RecordReadError } from "../records";
20
+ import { loadRunnerKey, type DsseEnvelope } from "./dsse";
21
+ import { buildStatement, signEvidence, verifyEvidence, EVIDENCE_ERROR_CODES, type EvidenceErrorCode, type EvidenceStatement, type EvidenceVerdict } from "./evidence";
22
+ import { policyAtBase, resolveBase } from "./provenance";
23
+
24
+ export const EVIDENCE_CONTRACT_VERSION = 1;
25
+ export const EVIDENCE_OUTPUT_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/evidence/v1/evidence.schema.json";
26
+
27
+ const USAGE =
28
+ "chant workspace evidence sign --kind <kind file> --key <runner key.pem> --check-id <id> [--claim <file>] [--environment <file>] [--at <rev>] [--base <rev>] [--output <file>] [--json]\n" +
29
+ " chant workspace evidence verify --envelope <file> [--at <rev>] [--base <rev>] [--json]";
30
+
31
+ export interface EvidenceFailure {
32
+ code: EvidenceErrorCode;
33
+ message: string;
34
+ }
35
+
36
+ /** What `evidence verify --json` prints, and a refused `evidence sign --json`. */
37
+ export type EvidenceDocument =
38
+ | ({ $schema: string; contract: number; chant: string; at: string } & Extract<EvidenceVerdict, { ok: true }>)
39
+ | { $schema: string; contract: number; chant: string; at: string | null; error: EvidenceFailure };
40
+
41
+ function head(): { $schema: string; contract: number; chant: string } {
42
+ return { $schema: EVIDENCE_OUTPUT_SCHEMA_ID, contract: EVIDENCE_CONTRACT_VERSION, chant: readerVersion() };
43
+ }
44
+
45
+ function report(json: boolean | undefined, at: string | null, error: EvidenceFailure, usage = false): number {
46
+ if (json) console.log(JSON.stringify({ ...head(), at, error }, null, 2));
47
+ else console.error(formatError({ message: `${error.message} (${error.code})`, ...(usage ? { hint: USAGE } : {}) }));
48
+ return 1;
49
+ }
50
+
51
+ function usage(message: string): number {
52
+ console.error(formatError({ message, hint: USAGE }));
53
+ return 1;
54
+ }
55
+
56
+ function commitOf(repo: string, rev: string): string | undefined {
57
+ return resolveBase(repo, rev).commit ?? undefined;
58
+ }
59
+
60
+ export async function runWorkspaceEvidence(ctx: CommandContext): Promise<number> {
61
+ const { args } = ctx;
62
+ const sub = args.extraPositional;
63
+ if (sub !== "sign" && sub !== "verify") return usage(sub ? `Unknown evidence subcommand: ${sub}` : "chant workspace evidence needs sign or verify");
64
+ const repo = gitRoot(process.cwd());
65
+ if (!repo) return report(args.json, null, { code: "not-a-git-repository", message: "runner evidence is bound to git commits, and this directory is not in a git repository" });
66
+ return sub === "sign" ? sign(ctx, repo) : verify(ctx, repo);
67
+ }
68
+
69
+ export interface SignRequest {
70
+ repo: string;
71
+ /** The kind file, resolved against `cwd`. */
72
+ kind: string;
73
+ cwd: string;
74
+ keyPem: string;
75
+ checkId: string;
76
+ at?: string;
77
+ base?: string;
78
+ claim?: Buffer;
79
+ environment?: Buffer;
80
+ }
81
+
82
+ /**
83
+ * Build and sign evidence, or say why not. Refuses a key the signers file
84
+ * lists, and a key the policy at base does not list as a runner.
85
+ */
86
+ export async function signRecordsEvidence(
87
+ req: SignRequest,
88
+ ): Promise<{ envelope: DsseEnvelope; statement: EvidenceStatement; runner: string } | { error: EvidenceFailure }> {
89
+ const { repo } = req;
90
+ const at = commitOf(repo, req.at ?? "HEAD");
91
+ if (!at) return { error: { code: "revision-unknown", message: `--at ${req.at ?? "HEAD"} names no commit` } };
92
+ const policy = policyAtBase(repo, resolveBase(repo, req.base));
93
+ if (policy.problems.length > 0) return { error: { code: "trust-policy-unreadable", message: `the policy at base cannot be read: ${policy.problems.join("; ")}` } };
94
+ let runner;
95
+ try {
96
+ runner = loadRunnerKey(req.keyPem);
97
+ } catch (err) {
98
+ return { error: { code: "runner-key-invalid", message: `the key is not an Ed25519 private key in PEM: ${err instanceof Error ? err.message : String(err)}` } };
99
+ }
100
+ if (policy.signers.some((s) => s.key === runner.publicKey)) {
101
+ return {
102
+ error: {
103
+ code: "runner-key-is-signer",
104
+ message: `this is a signer's key in ${policy.signersPath}. Runner evidence is signed by a service or CI identity, never by a person's key`,
105
+ },
106
+ };
107
+ }
108
+ const listed = policy.runners.find((r) => r.key === runner.publicKey);
109
+ if (!listed) {
110
+ return { error: { code: "runner-key-unlisted", message: `the policy at base lists no runner with this key. Add it to .chant/trust.json "runners" first:\n ${runner.publicKey}` } };
111
+ }
112
+
113
+ let paths: string[];
114
+ try {
115
+ const loaded = await loadRecordKind(req.kind, req.cwd);
116
+ const read = await readRecords(loaded, { root: repo, source: gitRevisionSource(repo, at) });
117
+ paths = read.records.map((r) => r.path);
118
+ } catch (err) {
119
+ if (err instanceof RecordReadError && (EVIDENCE_ERROR_CODES as readonly string[]).includes(err.code)) {
120
+ return { error: { code: err.code as EvidenceErrorCode, message: err.message } };
121
+ }
122
+ throw err;
123
+ }
124
+ const statement = buildStatement({
125
+ repo,
126
+ commit: at,
127
+ paths,
128
+ runner: listed.principal,
129
+ check: req.checkId,
130
+ ...(req.claim ? { claim: req.claim } : {}),
131
+ ...(req.environment ? { environment: req.environment } : {}),
132
+ });
133
+ return { envelope: signEvidence(statement, runner.key, runner.publicKey), statement, runner: listed.principal };
134
+ }
135
+
136
+ async function sign(ctx: CommandContext, repo: string): Promise<number> {
137
+ const { args } = ctx;
138
+ if (!args.kind || !args.key || !args.checkId) return usage("--kind, --key and --check-id are required");
139
+ const r = await signRecordsEvidence({
140
+ repo,
141
+ kind: args.kind,
142
+ cwd: process.cwd(),
143
+ keyPem: readFileSync(args.key, "utf-8"),
144
+ checkId: args.checkId,
145
+ at: args.at,
146
+ base: args.base,
147
+ ...(args.claim ? { claim: readFileSync(args.claim) } : {}),
148
+ ...(args.environment ? { environment: readFileSync(args.environment) } : {}),
149
+ });
150
+ if ("error" in r) return report(args.json, null, r.error);
151
+ const envelope = JSON.stringify(r.envelope, null, 2) + "\n";
152
+ if (args.output) {
153
+ writeFileSync(args.output, envelope);
154
+ console.error(formatSuccess(`signed evidence for ${r.statement.subject.length} records at ${r.statement.predicate.commit.slice(0, 8)} as ${r.runner}: ${args.output}`));
155
+ } else {
156
+ process.stdout.write(envelope);
157
+ }
158
+ return 0;
159
+ }
160
+
161
+ /** Verify an envelope file at `at` against the runner keys at base, as the document `--json` prints. */
162
+ export function verifyEvidenceFile(repo: string, envelopePath: string, opts: { at?: string; base?: string } = {}): EvidenceDocument {
163
+ const at = commitOf(repo, opts.at ?? "HEAD");
164
+ if (!at) return { ...head(), at: null, error: { code: "revision-unknown", message: `--at ${opts.at ?? "HEAD"} names no commit` } };
165
+ const policy = policyAtBase(repo, resolveBase(repo, opts.base));
166
+ if (policy.problems.length > 0) {
167
+ return { ...head(), at, error: { code: "trust-policy-unreadable", message: `the policy at base cannot be read: ${policy.problems.join("; ")}` } };
168
+ }
169
+ let envelope: unknown;
170
+ try {
171
+ envelope = JSON.parse(readFileSync(envelopePath, "utf-8"));
172
+ } catch (err) {
173
+ return { ...head(), at, error: { code: "envelope-unreadable", message: `--envelope ${envelopePath} could not be read as JSON: ${err instanceof Error ? err.message : String(err)}` } };
174
+ }
175
+ const verdict = verifyEvidence(envelope, policy.runners, repo, at);
176
+ if (!verdict.ok) return { ...head(), at, error: { code: verdict.code, message: verdict.reason } };
177
+ return { ...head(), at, ...verdict };
178
+ }
179
+
180
+ async function verify(ctx: CommandContext, repo: string): Promise<number> {
181
+ const { args } = ctx;
182
+ if (!args.envelope) return usage("--envelope <file> is required");
183
+ const doc = verifyEvidenceFile(repo, args.envelope, { at: args.at, base: args.base });
184
+ if ("error" in doc) return report(args.json, doc.at, doc.error);
185
+ if (args.json) {
186
+ console.log(JSON.stringify(doc, null, 2));
187
+ } else {
188
+ for (const s of doc.subjects) console.log(` ${s.matches ? "same " : "changed"} ${s.name}`);
189
+ const p = doc.statement.predicate;
190
+ const line = `evidence from ${doc.runner} (${doc.class}) for check ${p.check} at ${p.commit.slice(0, 8)}: ${doc.status} at ${doc.at.slice(0, 8)}`;
191
+ if (doc.status === "current") console.log(formatSuccess(line));
192
+ else console.error(formatError({ message: line, hint: "evidence is not reused for records that changed; run the check again" }));
193
+ }
194
+ return doc.status === "current" ? 0 : 1;
195
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * #2553: DSSE runner evidence over record hashes, verified offline against
3
+ * runner keys read at base. The attacker controls the change: a person's key
4
+ * posing as a runner, a runner key the change adds, a tampered payload, a
5
+ * swapped payload type, one runner speaking for another, and records edited
6
+ * after the evidence was signed.
7
+ */
8
+
9
+ import { execFileSync } from "node:child_process";
10
+ import { generateKeyPairSync } from "node:crypto";
11
+ import { readFileSync, writeFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { afterEach, describe, expect, test } from "vitest";
14
+ import { canonicalJson } from "../../effect-receipt";
15
+ import { loadRunnerKey, pae, signEnvelope, sshFingerprint, ed25519FromSsh, sshFromEd25519, verifyEnvelope, IN_TOTO_PAYLOAD_TYPE } from "./dsse";
16
+ import { EVIDENCE_OUTPUT_SCHEMA_ID, signRecordsEvidence, verifyEvidenceFile } from "./evidence-cli";
17
+ import { contract } from "../__fixtures__/contract-repo";
18
+ import evidenceSchema from "../evidence.schema.json";
19
+ import { verifyEvidence } from "./evidence";
20
+ import { policyAtBase, resolveBase } from "./provenance";
21
+ import { hasSshKeygen, note, TestRepo, writeRecordKind, writeRotation } from "./test-repo";
22
+
23
+ const repos: TestRepo[] = [];
24
+ afterEach(() => {
25
+ while (repos.length) repos.pop()!.cleanup();
26
+ });
27
+
28
+ function runnerKey(): { pem: string; pub: string } {
29
+ const { privateKey } = generateKeyPairSync("ed25519");
30
+ const pem = privateKey.export({ format: "pem", type: "pkcs8" }).toString();
31
+ return { pem, pub: loadRunnerKey(pem).publicKey };
32
+ }
33
+
34
+ describe("DSSE", () => {
35
+ test("PAE matches the DSSE v1 test vector", () => {
36
+ expect(pae("http://example.com/HelloWorld", Buffer.from("hello world")).toString()).toBe("DSSEv1 29 http://example.com/HelloWorld 11 hello world");
37
+ });
38
+
39
+ test.skipIf(!hasSshKeygen)("ssh-ed25519 keys round-trip, and fingerprints match ssh-keygen", () => {
40
+ const r = new TestRepo("keys");
41
+ repos.push(r);
42
+ const k = r.key("k");
43
+ expect(sshFromEd25519(ed25519FromSsh(k.pub))).toBe(k.pub);
44
+ const lf = execFileSync("ssh-keygen", ["-l", "-E", "sha256", "-f", `${k.file}.pub`], { encoding: "utf-8" }).split(" ")[1];
45
+ expect(sshFingerprint(k.pub)).toBe(lf);
46
+ });
47
+
48
+ test("a signature covers the payload type as well as the payload", () => {
49
+ const k = runnerKey();
50
+ const { key } = loadRunnerKey(k.pem);
51
+ const env = signEnvelope(IN_TOTO_PAYLOAD_TYPE, Buffer.from("{}"), key, k.pub);
52
+ const trusted = [{ principal: "ci", key: k.pub }];
53
+ expect(verifyEnvelope(env, trusted).ok).toBe(true);
54
+ expect(verifyEnvelope({ ...env, payloadType: "text/plain" }, trusted).ok).toBe(false);
55
+ expect(verifyEnvelope({ ...env, payload: Buffer.from("{ }").toString("base64") }, trusted).ok).toBe(false);
56
+ expect(verifyEnvelope(env, [{ principal: "ci", key: runnerKey().pub }]).ok).toBe(false);
57
+ expect(verifyEnvelope({ payload: "x" }, trusted).ok).toBe(false);
58
+ });
59
+ });
60
+
61
+ describe.skipIf(!hasSshKeygen)("runner evidence", () => {
62
+ /** main: alice signs; `ci` is a runner key in trust.json; two records. */
63
+ function setup(label: string) {
64
+ const r = new TestRepo(label);
65
+ repos.push(r);
66
+ const alice = r.key("alice");
67
+ const ci = runnerKey();
68
+ const kind = writeRecordKind(r);
69
+ r.write(".chant/allowed_signers", `alice@example.test ${alice.pub}\n`);
70
+ r.write(".chant/trust.json", JSON.stringify({ schema: 1, runners: [{ principal: "ci@example.test", class: "runner", key: ci.pub }] }));
71
+ r.commit("policy", alice);
72
+ r.write("records/n1.md", note("n1"));
73
+ r.write("records/n2.md", note("n2"));
74
+ r.commit("records", alice);
75
+ return { r, alice, ci, kind };
76
+ }
77
+
78
+ function runners(r: TestRepo) {
79
+ return policyAtBase(r.dir, resolveBase(r.dir, "main")).runners;
80
+ }
81
+
82
+ async function sign(r: TestRepo, kind: string, pem: string) {
83
+ return signRecordsEvidence({ repo: r.dir, kind, cwd: r.dir, keyPem: pem, checkId: "schema-lint", base: "main", environment: Buffer.from("node 22") });
84
+ }
85
+
86
+ test("evidence signed by a runner verifies offline, and goes stale when a record changes", async () => {
87
+ const { r, ci, kind, alice } = setup("good");
88
+ const s = await sign(r, kind, ci.pem);
89
+ if ("error" in s) throw new Error(s.error.message);
90
+ expect(s.statement.subject.map((x) => x.name)).toEqual(["records/n1.md", "records/n2.md"]);
91
+ expect(s.statement.predicate).toMatchObject({ runner: "ci@example.test", check: "schema-lint", environment: { sha256: expect.any(String) } });
92
+
93
+ let v = verifyEvidence(s.envelope, runners(r), r.dir, r.head());
94
+ expect(v).toMatchObject({ ok: true, status: "current", runner: "ci@example.test", class: "runner" });
95
+
96
+ r.write("records/n2.md", note("n2", "edited"));
97
+ r.commit("edit n2", alice);
98
+ v = verifyEvidence(s.envelope, runners(r), r.dir, r.head());
99
+ expect(v.ok && v.status).toBe("stale");
100
+ expect(v.ok && v.subjects.filter((x) => !x.matches).map((x) => x.name)).toEqual(["records/n2.md"]);
101
+ });
102
+
103
+ test("attack: a person's key cannot sign runner evidence, or be listed as a runner", async () => {
104
+ const { r, kind, alice } = setup("person");
105
+ // A developer's own ssh key is not a runner key.
106
+ const own = await sign(r, kind, readFileSync(alice.file, "utf-8"));
107
+ expect("error" in own && own.error.code).toBe("runner-key-invalid");
108
+ // A developer who converts their ed25519 key to PEM is refused by name.
109
+ const { privateKey } = generateKeyPairSync("ed25519");
110
+ const pem = privateKey.export({ format: "pem", type: "pkcs8" }).toString();
111
+ r.write(".chant/allowed_signers", `alice@example.test ${alice.pub}\ndev@example.test ${loadRunnerKey(pem).publicKey}\n`);
112
+ writeRotation(r, { previousText: `alice@example.test ${alice.pub}\n`, version: 2, by: [["alice@example.test", alice]] });
113
+ r.write(".chant/trust.json", JSON.stringify({ schema: 1, runners: [{ principal: "ci@example.test", class: "runner", key: loadRunnerKey(pem).publicKey }] }));
114
+ r.commit("dev key in both places", alice);
115
+ const dev = await sign(r, kind, pem);
116
+ expect("error" in dev && dev.error).toMatchObject({ code: "runner-key-is-signer", message: expect.stringMatching(/signer's key/) });
117
+ // Listing alice's key as a runner at base is refused when the policy is read.
118
+ r.write(".chant/trust.json", JSON.stringify({ schema: 1, runners: [{ principal: "laptop", class: "runner", key: alice.pub }] }));
119
+ r.commit("alice as a runner", alice);
120
+ const policy = policyAtBase(r.dir, resolveBase(r.dir, "main"));
121
+ expect(policy.runners).toEqual([]);
122
+ expect(policy.excludedRunners[0].reason).toMatch(/never to a signer/);
123
+ // A runner entry under a signer's principal is refused as well.
124
+ r.write(".chant/trust.json", JSON.stringify({ schema: 1, runners: [{ principal: "alice@example.test", class: "service", key: runnerKey().pub }] }));
125
+ r.commit("alice's name on a runner key", alice);
126
+ expect(policyAtBase(r.dir, resolveBase(r.dir, "main")).runners).toEqual([]);
127
+ });
128
+
129
+ test("attack: a runner key the change adds is not trusted until merged", async () => {
130
+ const { r, kind, alice } = setup("added");
131
+ r.git(["checkout", "-q", "-b", "change"]);
132
+ const rogue = runnerKey();
133
+ r.write(".chant/trust.json", JSON.stringify({ schema: 1, runners: [{ principal: "rogue", class: "runner", key: rogue.pub }] }));
134
+ r.commit("add a runner", alice);
135
+ const s = await sign(r, kind, rogue.pem);
136
+ expect("error" in s && s.error).toMatchObject({ code: "runner-key-unlisted", message: expect.stringMatching(/lists no runner with this key/) });
137
+ // Hand-made evidence with that key fails too.
138
+ const { key } = loadRunnerKey(rogue.pem);
139
+ const env = signEnvelope(IN_TOTO_PAYLOAD_TYPE, Buffer.from("{}"), key, rogue.pub);
140
+ expect(verifyEvidence(env, runners(r), r.dir, r.head())).toMatchObject({ ok: false, code: "envelope-untrusted" });
141
+ });
142
+
143
+ test("attack: one runner cannot put another runner's name in the statement", async () => {
144
+ const { r, ci, kind } = setup("impersonate");
145
+ const s = await sign(r, kind, ci.pem);
146
+ if ("error" in s) throw new Error(s.error.message);
147
+ const forged = { ...s.statement, predicate: { ...s.statement.predicate, runner: "release@example.test" } };
148
+ const { key } = loadRunnerKey(ci.pem);
149
+ const env = signEnvelope(IN_TOTO_PAYLOAD_TYPE, Buffer.from(canonicalJson(forged)), key, ci.pub);
150
+ const v = verifyEvidence(env, runners(r), r.dir, r.head());
151
+ expect(v.ok).toBe(false);
152
+ expect(!v.ok && v.reason).toMatch(/names runner/);
153
+ expect(!v.ok && v.code).toBe("evidence-runner-mismatch");
154
+ });
155
+
156
+ test("attack: a payload edited after signing does not verify", async () => {
157
+ const { r, ci, kind } = setup("tamper");
158
+ const s = await sign(r, kind, ci.pem);
159
+ if ("error" in s) throw new Error(s.error.message);
160
+ const statement = JSON.parse(Buffer.from(s.envelope.payload, "base64").toString());
161
+ statement.subject[0].digest.sha256 = "0".repeat(64);
162
+ const env = { ...s.envelope, payload: Buffer.from(JSON.stringify(statement)).toString("base64") };
163
+ expect(verifyEvidence(env, runners(r), r.dir, r.head())).toMatchObject({ ok: false, code: "envelope-untrusted" });
164
+ });
165
+
166
+ test("a removed runner key no longer verifies evidence it signed", async () => {
167
+ const { r, ci, kind, alice } = setup("revoked");
168
+ const s = await sign(r, kind, ci.pem);
169
+ if ("error" in s) throw new Error(s.error.message);
170
+ r.write(".chant/trust.json", JSON.stringify({ schema: 1 }));
171
+ r.commit("retire the runner", alice);
172
+ expect(verifyEvidence(s.envelope, runners(r), r.dir, r.head())).toMatchObject({ ok: false, code: "envelope-untrusted" });
173
+ });
174
+
175
+ test("attack: a statement signed by a listed runner, with a field the predicate does not define or a path outside the repository, is refused", async () => {
176
+ const { r, ci, kind } = setup("strict");
177
+ const s = await sign(r, kind, ci.pem);
178
+ if ("error" in s) throw new Error(s.error.message);
179
+ const { key } = loadRunnerKey(ci.pem);
180
+ const extra = { ...s.statement, predicate: { ...s.statement.predicate, reused: true } };
181
+ const escape = { ...s.statement, subject: [{ name: "../outside.md", digest: { sha256: "0".repeat(64) } }] };
182
+ for (const forged of [extra, escape]) {
183
+ const env = signEnvelope(IN_TOTO_PAYLOAD_TYPE, Buffer.from(canonicalJson(forged)), key, ci.pub);
184
+ expect(verifyEvidence(env, runners(r), r.dir, r.head())).toMatchObject({ ok: false, code: "evidence-statement-invalid" });
185
+ }
186
+ const other = signEnvelope("text/plain", Buffer.from(canonicalJson(s.statement)), key, ci.pub);
187
+ expect(verifyEvidence(other, runners(r), r.dir, r.head())).toMatchObject({ ok: false, code: "evidence-payload-type" });
188
+ expect(verifyEvidence({ ...s.envelope, payload: "not base64!" }, runners(r), r.dir, r.head())).toMatchObject({ ok: false, code: "envelope-invalid" });
189
+ });
190
+
191
+ test("one runner key under two names, or one name with two keys, is trusted under neither", () => {
192
+ const { r, alice } = setup("duplicate");
193
+ const a = runnerKey();
194
+ const b = runnerKey();
195
+ r.write(
196
+ ".chant/trust.json",
197
+ JSON.stringify({
198
+ schema: 1,
199
+ runners: [
200
+ { principal: "ci@example.test", class: "runner", key: a.pub },
201
+ { principal: "deploy@example.test", class: "service", key: a.pub },
202
+ { principal: "nightly@example.test", class: "runner", key: b.pub },
203
+ { principal: "nightly@example.test", class: "runner", key: runnerKey().pub },
204
+ ],
205
+ }),
206
+ );
207
+ r.commit("duplicates", alice);
208
+ const policy = policyAtBase(r.dir, resolveBase(r.dir, "main"));
209
+ expect(policy.runners).toEqual([]);
210
+ expect(policy.excludedRunners.map((e) => e.principal)).toEqual(["ci@example.test", "deploy@example.test", "nightly@example.test", "nightly@example.test"]);
211
+ });
212
+
213
+ test("verify --json prints a document evidence.schema.json accepts, for a result and for each refusal", async () => {
214
+ const { r, ci, kind, alice } = setup("contract");
215
+ const { expectValid } = contract(evidenceSchema);
216
+ const s = await sign(r, kind, ci.pem);
217
+ if ("error" in s) throw new Error(s.error.message);
218
+ const file = join(r.dir, "evidence.json");
219
+ writeFileSync(file, JSON.stringify(s.envelope));
220
+ const good = verifyEvidenceFile(r.dir, file, { base: "main" });
221
+ expect(good).toMatchObject({ $schema: EVIDENCE_OUTPUT_SCHEMA_ID, contract: 1, status: "current" });
222
+ expectValid(good);
223
+
224
+ writeFileSync(file, "{");
225
+ const unreadable = verifyEvidenceFile(r.dir, file, { base: "main" });
226
+ expect("error" in unreadable && unreadable.error.code).toBe("envelope-unreadable");
227
+ expectValid(unreadable);
228
+
229
+ const refused = await sign(r, kind, readFileSync(alice.file, "utf-8"));
230
+ if (!("error" in refused)) throw new Error("expected a refusal");
231
+ expectValid({ $schema: EVIDENCE_OUTPUT_SCHEMA_ID, contract: 1, chant: "0.0.0", at: null, error: refused.error });
232
+
233
+ // A signer history that breaks at base leaves no runner key trusted.
234
+ r.write(".chant/allowed_signers", `alice@example.test ${alice.pub}\nmallory@example.test ${r.key("mallory").pub}\n`);
235
+ r.commit("signers changed with no rotation", alice);
236
+ writeFileSync(file, JSON.stringify(s.envelope));
237
+ const broken = verifyEvidenceFile(r.dir, file, { base: "main" });
238
+ expect("error" in broken && broken.error.code).toBe("trust-policy-unreadable");
239
+ expectValid(broken);
240
+ });
241
+ });