@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,178 @@
1
+ /**
2
+ * `chant workspace signers [rotate [--threshold <n>] | sign --key <file>]`
3
+ * (#2553): show the signer history, and propose a new signer set.
4
+ *
5
+ * Rotating is three steps: edit the signers file, run `rotate` to write the
6
+ * rotation file for the next version, and have a threshold of the current
7
+ * signers each run `sign` with their own key. `chant workspace verify` then
8
+ * checks the result against the set at base.
9
+ */
10
+
11
+ import { execFileSync } from "node:child_process";
12
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { formatError, formatSuccess } from "../../cli/format";
15
+ import type { CommandContext } from "../../cli/registry";
16
+ import { readerVersion } from "../declaration";
17
+ import type { ReasonCode } from "../reason-codes";
18
+ import { gitRoot } from "../record-source";
19
+ import { parseAllowedSigners, readTrustPolicy } from "./policy";
20
+ import { resolveBase } from "./provenance";
21
+ import { gitRevisionSource } from "../record-source";
22
+ import {
23
+ distinctHolders,
24
+ rotationFileSchema,
25
+ rotationPath,
26
+ rotationStatement,
27
+ ROTATION_NAMESPACE,
28
+ signerHistory,
29
+ signersDigest,
30
+ type RotationFile,
31
+ type RotationRefusalCode,
32
+ type SignerHistory,
33
+ } from "./rotation";
34
+ import { verifySshSignature } from "./ssh-commit";
35
+
36
+ const USAGE = "chant workspace signers [--base <rev>] [--json] | signers rotate [--threshold <n>] | signers sign --key <ssh private key>";
37
+
38
+ export const SIGNERS_CONTRACT_VERSION = 1;
39
+ export const SIGNERS_OUTPUT_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/signers/v1/signers.schema.json";
40
+
41
+ /** Why `chant workspace signers` prints no history. Each is in `reason-codes.ts` and `signers.schema.json`. */
42
+ export const SIGNERS_ERROR_CODES = ["not-a-git-repository", "revision-unknown", "signers-file-missing"] as const satisfies readonly ReasonCode[];
43
+ export type SignersErrorCode = (typeof SIGNERS_ERROR_CODES)[number];
44
+
45
+ /** What `chant workspace signers --json` prints. */
46
+ export type SignersDocument =
47
+ | {
48
+ $schema: string;
49
+ contract: number;
50
+ chant: string;
51
+ base: string;
52
+ signersPath: string;
53
+ versions: Array<{ version: number; commit: string | null; digest: string | null; threshold: number; principals: string[]; signedBy: string[] }>;
54
+ broken: { commit: string | null; code: RotationRefusalCode; message: string } | null;
55
+ }
56
+ | { $schema: string; contract: number; chant: string; error: { code: SignersErrorCode; message: string } };
57
+
58
+ function head() {
59
+ return { $schema: SIGNERS_OUTPUT_SCHEMA_ID, contract: SIGNERS_CONTRACT_VERSION, chant: readerVersion() };
60
+ }
61
+
62
+ /** The signer history at `base` as the `--json` document. */
63
+ export function signersDocument(repo: string, base: string, signersPath: string, history: SignerHistory): SignersDocument {
64
+ return {
65
+ ...head(),
66
+ base,
67
+ signersPath,
68
+ versions: history.versions.map((v) => ({
69
+ version: v.version,
70
+ commit: v.commit,
71
+ digest: v.version === 0 ? null : v.digest,
72
+ threshold: v.threshold,
73
+ principals: [...new Set(v.signers.map((s) => s.principal))].sort(),
74
+ signedBy: v.signedBy,
75
+ })),
76
+ broken: history.broken ? { commit: history.broken.commit, code: history.broken.code, message: history.broken.reason } : null,
77
+ };
78
+ }
79
+
80
+ function fail(message: string): number {
81
+ console.error(formatError({ message, hint: USAGE }));
82
+ return 1;
83
+ }
84
+
85
+ export async function runWorkspaceSigners(ctx: CommandContext): Promise<number> {
86
+ const { args } = ctx;
87
+ const sub = args.extraPositional;
88
+ const refuse = (code: SignersErrorCode, message: string): number => {
89
+ if (args.json && !sub) console.log(JSON.stringify({ ...head(), error: { code, message } }, null, 2));
90
+ else console.error(formatError({ message, hint: USAGE }));
91
+ return 1;
92
+ };
93
+ const repo = gitRoot(process.cwd());
94
+ if (!repo) return refuse("not-a-git-repository", "signer history is read from git, and this directory is not in a git repository");
95
+ const base = resolveBase(repo, args.base);
96
+ if (!base.commit) return refuse("revision-unknown", base.problem ?? "no base revision");
97
+ // The policy as written at base; its history is checked below, not taken on trust.
98
+ const policy = readTrustPolicy(gitRevisionSource(repo, base.commit), base.commit);
99
+ if (!policy.active) return refuse("signers-file-missing", `there is no signers file (${policy.signersPath}) at base ${base.commit.slice(0, 8)}`);
100
+ const history = signerHistory(repo, base.commit, policy.signersPath);
101
+ const latest = history.versions.at(-1);
102
+
103
+ if (!sub) {
104
+ if (args.json) {
105
+ console.log(JSON.stringify(signersDocument(repo, base.commit, policy.signersPath, history), null, 2));
106
+ return history.broken ? 1 : 0;
107
+ }
108
+ for (const v of history.versions) {
109
+ if (v.version === 0) {
110
+ console.log(` ${v.commit?.slice(0, 8)} signers file removed`);
111
+ continue;
112
+ }
113
+ const by = v.signedBy.length ? `, signed by ${v.signedBy.join(", ")}` : ", the first version";
114
+ console.log(` ${v.commit?.slice(0, 8)} version ${v.version}: ${new Set(v.signers.map((s) => s.principal)).size} signers, threshold ${v.threshold}${by}`);
115
+ }
116
+ if (history.broken) return fail(`the history breaks at ${history.broken.commit?.slice(0, 8)} (${history.broken.code}): ${history.broken.reason}`);
117
+ return 0;
118
+ }
119
+ if (history.broken || !latest || latest.version === 0) return fail("the signer history at base is not valid, so there is no version to rotate from");
120
+
121
+ const signersFile = join(repo, policy.signersPath);
122
+ const rotationFile = join(repo, rotationPath(policy.signersPath));
123
+ const text = readFileSync(signersFile, "utf-8");
124
+
125
+ if (sub === "rotate") {
126
+ const threshold = args.threshold !== undefined ? Number(args.threshold) : latest.threshold;
127
+ if (!Number.isInteger(threshold) || threshold < 1) return fail("--threshold takes a whole number of at least 1");
128
+ const holders = distinctHolders(parseAllowedSigners(text).signers);
129
+ if (threshold > holders) return fail(`--threshold ${threshold} is more than the ${holders} distinct signers in the new set`);
130
+ const rotation: RotationFile = { schema: 1, version: latest.version + 1, previous: latest.digest, threshold, signatures: [] };
131
+ writeFileSync(rotationFile, JSON.stringify(rotation, null, 2) + "\n");
132
+ console.log(
133
+ formatSuccess(
134
+ `wrote ${rotationPath(policy.signersPath)} for version ${rotation.version}; ${latest.threshold} of the version ${latest.version} signers must now run chant workspace signers sign --key <their key>`,
135
+ ),
136
+ );
137
+ return 0;
138
+ }
139
+
140
+ if (sub === "sign") {
141
+ if (!args.key) return fail("--key <ssh private key> is required");
142
+ if (!existsSync(rotationFile)) return fail(`there is no ${rotationPath(policy.signersPath)}; run chant workspace signers rotate first`);
143
+ let raw: unknown;
144
+ try {
145
+ raw = JSON.parse(readFileSync(rotationFile, "utf-8"));
146
+ } catch {
147
+ return fail(`${rotationPath(policy.signersPath)} is not JSON`);
148
+ }
149
+ const parsed = rotationFileSchema.safeParse(raw);
150
+ if (!parsed.success) return fail(`${rotationPath(policy.signersPath)} is invalid`);
151
+ if (parsed.data.version !== latest.version + 1 || parsed.data.previous !== latest.digest) {
152
+ return fail(`${rotationPath(policy.signersPath)} is not the next version after version ${latest.version} at base; run chant workspace signers rotate again`);
153
+ }
154
+ const rotation = parsed.data;
155
+ const statement = rotationStatement(rotation, signersDigest(text));
156
+ let pub: string;
157
+ try {
158
+ pub = execFileSync("ssh-keygen", ["-y", "-f", args.key], { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim().split(/\s+/).slice(0, 2).join(" ");
159
+ } catch (err) {
160
+ return fail(`ssh-keygen could not read ${args.key}: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`);
161
+ }
162
+ const me = latest.signers.find((s) => s.key === pub);
163
+ if (!me) return fail(`this key is not in version ${latest.version} of the signer set, so its signature would not count`);
164
+ let sig: string;
165
+ try {
166
+ sig = execFileSync("ssh-keygen", ["-q", "-Y", "sign", "-n", ROTATION_NAMESPACE, "-f", args.key], { input: statement, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
167
+ } catch (err) {
168
+ return fail(`ssh-keygen could not sign with ${args.key}: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`);
169
+ }
170
+ const check = verifySshSignature([me], statement, sig, ROTATION_NAMESPACE);
171
+ if (!check.ok) return fail(`the signature did not verify: ${check.reason}`);
172
+ rotation.signatures = [...rotation.signatures.filter((s) => s.principal !== me.principal), { principal: me.principal, signature: sig }];
173
+ writeFileSync(rotationFile, JSON.stringify(rotation, null, 2) + "\n");
174
+ console.log(formatSuccess(`signed version ${rotation.version} as ${me.principal} (${rotation.signatures.length} of ${latest.threshold} needed)`));
175
+ return 0;
176
+ }
177
+ return fail(`Unknown signers subcommand: ${sub}`);
178
+ }
@@ -116,15 +116,19 @@ export const sshCommitAttestor: CommitAttestor = {
116
116
  const memo = `${ctx.repo}\0${commit}\0${renderAllowedSigners(signers)}`;
117
117
  const hit = cache.get(memo);
118
118
  if (hit) return hit;
119
- const answer = attest(ctx.repo, commit, signers);
119
+ const raw = execFileSync("git", ["cat-file", "commit", commit], { cwd: ctx.repo, maxBuffer: 64 * 1024 * 1024 });
120
+ const answer = judgeRaw(raw, objectFormat(ctx.repo) === "sha256" ? "gpgsig-sha256" : "gpgsig", signers);
120
121
  cache.set(memo, answer);
121
122
  return answer;
122
123
  },
124
+ attestRaw(ctx: AttestorContext, raw: Buffer): CommitAttestation {
125
+ // A commit from another repository: its object format shows in its tree line.
126
+ const tree = /^tree ([0-9a-f]+)$/m.exec(raw.subarray(0, 200).toString("latin1"))?.[1];
127
+ return judgeRaw(raw, tree?.length === 64 ? "gpgsig-sha256" : "gpgsig", ctx.policy.signers);
128
+ },
123
129
  };
124
130
 
125
- function attest(repo: string, commit: string, signers: Signer[]): CommitAttestation {
126
- const raw = execFileSync("git", ["cat-file", "commit", commit], { cwd: repo, maxBuffer: 64 * 1024 * 1024 });
127
- const header = objectFormat(repo) === "sha256" ? "gpgsig-sha256" : "gpgsig";
131
+ function judgeRaw(raw: Buffer, header: string, signers: Signer[]): CommitAttestation {
128
132
  const { payload, signature } = splitSignedCommit(raw, header);
129
133
  if (!signature) return { level: "unattested", attestor: NAME, reason: "the commit is not signed" };
130
134
  if (!signature.startsWith("-----BEGIN SSH SIGNATURE-----")) {
@@ -136,3 +140,48 @@ function attest(repo: string, commit: string, signers: Signer[]): CommitAttestat
136
140
  if (r.missing) return { level: "attested-unverifiable-here", attestor: NAME, reason: r.reason };
137
141
  return { level: "unattested", attestor: NAME, reason: r.reason };
138
142
  }
143
+
144
+ /**
145
+ * The public key inside an armored ssh signature (the SSHSIG format), as
146
+ * `<type> <base64>`, or undefined when the text is not one. The key is what
147
+ * made the signature; whether anyone trusts it is a separate question.
148
+ */
149
+ export function sshSignatureKey(signature: string): string | undefined {
150
+ const body = signature
151
+ .replace(/-----BEGIN SSH SIGNATURE-----/, "")
152
+ .replace(/-----END SSH SIGNATURE-----/, "")
153
+ .replace(/\s+/g, "");
154
+ let buf: Buffer;
155
+ try {
156
+ buf = Buffer.from(body, "base64");
157
+ } catch {
158
+ return undefined;
159
+ }
160
+ if (buf.length < 14 || buf.subarray(0, 6).toString("latin1") !== "SSHSIG") return undefined;
161
+ const len = buf.readUInt32BE(10);
162
+ const blob = buf.subarray(14, 14 + len);
163
+ if (blob.length !== len || len < 4) return undefined;
164
+ const typeLen = blob.readUInt32BE(0);
165
+ const type = blob.subarray(4, 4 + typeLen).toString("latin1");
166
+ if (!/^[a-z0-9@.-]+$/.test(type)) return undefined;
167
+ return `${type} ${blob.toString("base64")}`;
168
+ }
169
+
170
+ /**
171
+ * Whether an ssh signature over `payload` in `namespace` is intact, whoever
172
+ * made it (`ssh-keygen -Y check-novalidate`). Returns the key's fingerprint,
173
+ * false when it does not check, or null when ssh-keygen is not installed.
174
+ */
175
+ export function sshSignatureIntact(payload: Buffer, signature: string, namespace: string): string | false | null {
176
+ const dir = mkdtempSync(join(tmpdir(), "chant-intact-"));
177
+ try {
178
+ const sigFile = join(dir, "signature");
179
+ writeFileSync(sigFile, signature);
180
+ const r = sshKeygen(["-Y", "check-novalidate", "-n", namespace, "-s", sigFile], payload);
181
+ if (r.missing) return null;
182
+ if (r.status !== 0) return false;
183
+ return /key (SHA256:[A-Za-z0-9+/=]+)/.exec(r.stdout + r.stderr)?.[1] ?? "";
184
+ } finally {
185
+ rmSync(dir, { recursive: true, force: true });
186
+ }
187
+ }
@@ -8,6 +8,7 @@ import { execFileSync, spawnSync } from "node:child_process";
8
8
  import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
9
9
  import { tmpdir } from "node:os";
10
10
  import { dirname, join } from "node:path";
11
+ import { ROTATION_NAMESPACE, rotationStatement, signersDigest } from "./rotation";
11
12
 
12
13
  export const hasSshKeygen = spawnSync("ssh-keygen", ["-Y"], { stdio: "ignore" }).error === undefined;
13
14
 
@@ -109,6 +110,23 @@ export class TestRepo {
109
110
  }
110
111
  }
111
112
 
113
+ /**
114
+ * Write `<signers>.rotation.json` for the signers file now in the working
115
+ * tree, as the next version after `previousText`, signed by `by` in the
116
+ * rotation namespace (#2553).
117
+ */
118
+ export function writeRotation(
119
+ repo: TestRepo,
120
+ opts: { previousText: string; version: number; threshold?: number; by: Array<[string, Key]>; namespace?: string; signersPath?: string },
121
+ ): void {
122
+ const path = opts.signersPath ?? ".chant/allowed_signers";
123
+ const text = readFileSync(join(repo.dir, path), "utf-8");
124
+ const rotation = { version: opts.version, previous: signersDigest(opts.previousText), threshold: opts.threshold ?? 1 };
125
+ const statement = rotationStatement(rotation, signersDigest(text));
126
+ const signatures = opts.by.map(([principal, key]) => ({ principal, signature: repo.sshSign(key, statement, opts.namespace ?? ROTATION_NAMESPACE) }));
127
+ repo.write(`${path}.rotation.json`, JSON.stringify({ schema: 1, ...rotation, signatures }, null, 2));
128
+ }
129
+
112
130
  /** A record kind reading `records/*.md`, and its schema, written into `repo`. */
113
131
  export function writeRecordKind(repo: TestRepo): string {
114
132
  repo.write(
@@ -15,7 +15,7 @@ import { queryRecords, type RecordsDocument, type RecordView } from "../records-
15
15
  import { activeAttestors } from "./attestor";
16
16
  import { parseAllowedSigners } from "./policy";
17
17
  import { verifyChange } from "./verify";
18
- import { hasSshKeygen, note, TestRepo, writeRecordKind, type Key } from "./test-repo";
18
+ import { hasSshKeygen, note, TestRepo, writeRecordKind, writeRotation, type Key } from "./test-repo";
19
19
 
20
20
  const repos: TestRepo[] = [];
21
21
  function repo(label: string): TestRepo {
@@ -90,8 +90,11 @@ describe.skipIf(!hasSshKeygen)("the ssh-commit attestor, against the policy at b
90
90
  const kind = writeRecordKind(r);
91
91
  r.write(".chant/allowed_signers", signers(["alice@example.test", alice], ["bob@example.test", bob]));
92
92
  if (opts.admin) r.write(".chant/trust.json", JSON.stringify({ schema: 1, roles: { admin: ["alice@example.test"] } }));
93
+ r.commit("policy", alice);
94
+ // A separate commit: the one that adds the signers file is judged by the
95
+ // set before it, which is none (#2553), so it attests nothing itself.
93
96
  r.write("records/n1.md", note("n1"));
94
- r.commit("policy and first record", alice);
97
+ r.commit("first record", alice);
95
98
  r.git(["checkout", "-q", "-b", "change"]);
96
99
  return { r, alice, bob, mallory, kind };
97
100
  }
@@ -181,7 +184,9 @@ describe.skipIf(!hasSshKeygen)("the ssh-commit attestor, against the policy at b
181
184
  test("attack: a backdated commit by a key removed from the signers before base", async () => {
182
185
  const { r, alice, bob, kind } = baseline("backdated-revoked");
183
186
  r.git(["checkout", "-q", "main"]);
187
+ const v1 = r.git(["show", "main:.chant/allowed_signers"]);
184
188
  r.write(".chant/allowed_signers", signers(["alice@example.test", alice]));
189
+ writeRotation(r, { previousText: v1, version: 2, by: [["alice@example.test", alice]] });
185
190
  r.commit("remove bob", alice);
186
191
  r.git(["checkout", "-q", "-b", "late"]);
187
192
  r.write("records/n2.md", note("n2"));
@@ -299,6 +304,7 @@ describe.skipIf(!hasSshKeygen)("the ssh-commit attestor, against the policy at b
299
304
 
300
305
  r.git(["reset", "-q", "--hard", "main"]);
301
306
  r.write(".chant/allowed_signers", signers(["alice@example.test", alice], ["bob@example.test", bob], ["carol@example.test", r.key("carol2")]));
307
+ writeRotation(r, { previousText: r.git(["show", "main:.chant/allowed_signers"]), version: 2, by: [["alice@example.test", alice]] });
302
308
  r.commit("add carol", alice);
303
309
  report = await verify(r);
304
310
  expect(report.ok).toBe(true);
@@ -49,6 +49,7 @@ function printReport(r: ChangeReport): void {
49
49
  lines.push(` ${c.commit.slice(0, 8)} ${c.level}${who} ${c.subject}${note}`);
50
50
  }
51
51
  for (const w of r.protectedWrites) lines.push(` protected write ${w.commit.slice(0, 8)} ${w.paths.join(", ")}: ${w.allowed ? "allowed" : "refused"}, ${w.reason}`);
52
+ if (r.rotation && "to" in r.rotation) lines.push(` signer set: version ${r.rotation.from} to ${r.rotation.to}, signed by ${r.rotation.signedBy.join(", ")}`);
52
53
  for (const n of r.notes) lines.push(n);
53
54
  console.log(lines.join("\n"));
54
55
  if (r.ok) console.log(formatSuccess(`verified ${r.commits.length} commits against the policy at base`));
@@ -16,6 +16,7 @@ import { execFileSync } from "node:child_process";
16
16
  import { type CommitAttestor, type ProvenanceLevel } from "./attestor";
17
17
  import { policyAtBase, commitProvenance, resolveBase, type BaseSource, type RecordProvenance } from "./provenance";
18
18
  import { policyWriters, protectedPaths, type ExcludedSigner, type TrustPolicy } from "./policy";
19
+ import { fileAt, nextVersion, rotationPath, signerHistory, type RotationRefusalCode } from "./rotation";
19
20
 
20
21
  export interface CommitVerdict extends RecordProvenance {
21
22
  commit: string;
@@ -51,6 +52,12 @@ export interface ChangeReport {
51
52
  /** Protected paths whose content differs between the merge base and head. */
52
53
  protectedChanged: string[];
53
54
  protectedWrites: ProtectedWrite[];
55
+ /**
56
+ * When the change edits the signer set (#2553): the version it proposes and
57
+ * who signed it, or, when the new set is refused, the version it had to
58
+ * follow and why.
59
+ */
60
+ rotation: { from: number; to: number; signedBy: string[] } | { from: number; code: RotationRefusalCode; reason: string } | null;
54
61
  notes: string[];
55
62
  failures: string[];
56
63
  ok: boolean;
@@ -111,6 +118,7 @@ export function verifyChange(opts: VerifyOptions): ChangeReport {
111
118
  protectedPaths: protectedPaths(policy),
112
119
  protectedChanged: [],
113
120
  protectedWrites: [],
121
+ rotation: null,
114
122
  notes: [],
115
123
  failures: [],
116
124
  ok: false,
@@ -183,6 +191,20 @@ export function verifyChange(opts: VerifyOptions): ChangeReport {
183
191
  }
184
192
  }
185
193
 
194
+ // A new signer set must be signed by a threshold of the set at base (#2553).
195
+ const rotation = rotationPath(policy.signersPath);
196
+ if (changedPaths.includes(policy.signersPath) || changedPaths.includes(rotation)) {
197
+ const next = { signers: fileAt(repo, head, policy.signersPath), rotation: fileAt(repo, head, rotation) };
198
+ const latest = signerHistory(repo, base.commit, policy.signersPath).versions.at(-1);
199
+ if (next.signers !== undefined && latest && latest.version > 0) {
200
+ const v = nextVersion(latest, next, null);
201
+ if ("code" in v) {
202
+ report.rotation = { from: latest.version, code: v.code, reason: v.reason };
203
+ report.failures.push(`the signer set at head is not a valid rotation of the set at base (${v.code}): ${v.reason}`);
204
+ } else if (v !== latest) report.rotation = { from: latest.version, to: v.version, signedBy: v.signedBy };
205
+ }
206
+ }
207
+
186
208
  if (opts.require) {
187
209
  for (const v of report.commits) {
188
210
  if (v.skipped || v.level === opts.require) continue;
@@ -182,11 +182,15 @@
182
182
  "amend-supersede-instead",
183
183
  "record-sign-failed",
184
184
  "ratify-quorum-not-met",
185
+ "write-scope-member",
186
+ "write-scope-kind",
187
+ "agent-unknown",
185
188
  "record-unparseable",
186
189
  "record-schema-invalid",
187
190
  "record-id-duplicate",
188
191
  "record-supersedes-unknown",
189
192
  "record-supersedes-conflict",
193
+ "record-seal-mismatch",
190
194
  "session-seal-mismatch",
191
195
  "session-verdict-unknown-record",
192
196
  "work-kind-missing",