@intentius/chant 0.121.1 → 0.123.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 (161) hide show
  1. package/dist/cli/commands/build.d.ts +12 -0
  2. package/dist/cli/commands/build.d.ts.map +1 -1
  3. package/dist/cli/commands/lint.d.ts +16 -0
  4. package/dist/cli/commands/lint.d.ts.map +1 -1
  5. package/dist/cli/handlers/gate-resume.d.ts +1 -1
  6. package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
  7. package/dist/cli/handlers/operator.d.ts +1 -1
  8. package/dist/cli/handlers/operator.d.ts.map +1 -1
  9. package/dist/cli/handlers/override-approve.d.ts +17 -0
  10. package/dist/cli/handlers/override-approve.d.ts.map +1 -0
  11. package/dist/cli/handlers/run-wave.d.ts.map +1 -1
  12. package/dist/cli/main.d.ts.map +1 -1
  13. package/dist/cli/registry.d.ts +4 -0
  14. package/dist/cli/registry.d.ts.map +1 -1
  15. package/dist/gate-decision.d.ts +157 -0
  16. package/dist/gate-decision.d.ts.map +1 -0
  17. package/dist/lifecycle/gate-applied.d.ts +75 -0
  18. package/dist/lifecycle/gate-applied.d.ts.map +1 -0
  19. package/dist/lifecycle/gate-audit.d.ts +145 -0
  20. package/dist/lifecycle/gate-audit.d.ts.map +1 -0
  21. package/dist/lifecycle/gate-ledger-select.d.ts +54 -0
  22. package/dist/lifecycle/gate-ledger-select.d.ts.map +1 -0
  23. package/dist/lifecycle/gate-ledger.d.ts +1 -45
  24. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  25. package/dist/lifecycle/override-ledger.d.ts +34 -0
  26. package/dist/lifecycle/override-ledger.d.ts.map +1 -0
  27. package/dist/lifecycle/rollback.d.ts.map +1 -1
  28. package/dist/lifecycle/work-item-id.d.ts +22 -0
  29. package/dist/lifecycle/work-item-id.d.ts.map +1 -0
  30. package/dist/lifecycle/work-lease.d.ts +2 -2
  31. package/dist/lifecycle/work-lease.d.ts.map +1 -1
  32. package/dist/lint/post-synth.d.ts +20 -1
  33. package/dist/lint/post-synth.d.ts.map +1 -1
  34. package/dist/notify-verify.d.ts +62 -0
  35. package/dist/notify-verify.d.ts.map +1 -0
  36. package/dist/notify.d.ts +202 -0
  37. package/dist/notify.d.ts.map +1 -0
  38. package/dist/op/activities/activity-contracts.d.ts +4 -4
  39. package/dist/op/activities/gh-host.d.ts +37 -0
  40. package/dist/op/activities/gh-host.d.ts.map +1 -0
  41. package/dist/op/activities/lexicon-upgrade.d.ts +11 -0
  42. package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
  43. package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
  44. package/dist/op/activities/reconcile.d.ts +74 -76
  45. package/dist/op/activities/reconcile.d.ts.map +1 -1
  46. package/dist/op/gate-approval.d.ts +17 -1
  47. package/dist/op/gate-approval.d.ts.map +1 -1
  48. package/dist/op/gate-name.d.ts +8 -1
  49. package/dist/op/gate-name.d.ts.map +1 -1
  50. package/dist/op/gate-resume.d.ts +4 -2
  51. package/dist/op/gate-resume.d.ts.map +1 -1
  52. package/dist/op/gate-review.d.ts +2 -1
  53. package/dist/op/gate-review.d.ts.map +1 -1
  54. package/dist/op/gate.d.ts +43 -6
  55. package/dist/op/gate.d.ts.map +1 -1
  56. package/dist/op/index.d.ts +8 -6
  57. package/dist/op/index.d.ts.map +1 -1
  58. package/dist/op/op-waves-notify.d.ts +31 -0
  59. package/dist/op/op-waves-notify.d.ts.map +1 -0
  60. package/dist/op/op-waves-run.d.ts +91 -16
  61. package/dist/op/op-waves-run.d.ts.map +1 -1
  62. package/dist/op/op-waves.d.ts +99 -2
  63. package/dist/op/op-waves.d.ts.map +1 -1
  64. package/dist/op/operator.d.ts +15 -0
  65. package/dist/op/operator.d.ts.map +1 -1
  66. package/dist/op/work-lease-decl.d.ts.map +1 -1
  67. package/dist/op/work-lease-run.d.ts.map +1 -1
  68. package/dist/override-gate.d.ts +224 -0
  69. package/dist/override-gate.d.ts.map +1 -0
  70. package/dist/pr-forge.d.ts +198 -19
  71. package/dist/pr-forge.d.ts.map +1 -1
  72. package/dist/workspace/status-gates.d.ts +1 -0
  73. package/dist/workspace/status-gates.d.ts.map +1 -1
  74. package/dist/workspace/trust/gate-seal.d.ts +96 -0
  75. package/dist/workspace/trust/gate-seal.d.ts.map +1 -0
  76. package/dist/workspace/trust/policy.d.ts +2 -41
  77. package/dist/workspace/trust/policy.d.ts.map +1 -1
  78. package/dist/workspace/trust/seal.d.ts +2 -27
  79. package/dist/workspace/trust/seal.d.ts.map +1 -1
  80. package/dist/workspace/trust/trust-files.d.ts +57 -0
  81. package/dist/workspace/trust/trust-files.d.ts.map +1 -0
  82. package/dist/workspace/work-cli.d.ts.map +1 -1
  83. package/package.json +2 -1
  84. package/src/cli/commands/build.ts +37 -18
  85. package/src/cli/commands/carve-emit-state.test.ts +8 -2
  86. package/src/cli/commands/lint.test.ts +109 -1
  87. package/src/cli/commands/lint.ts +165 -1
  88. package/src/cli/handlers/__snapshots__/build-member.test.ts.snap +2 -2
  89. package/src/cli/handlers/gate-resume.ts +3 -2
  90. package/src/cli/handlers/operator.test.ts +12 -1
  91. package/src/cli/handlers/operator.ts +25 -13
  92. package/src/cli/handlers/override-approve.test.ts +87 -0
  93. package/src/cli/handlers/override-approve.ts +103 -0
  94. package/src/cli/handlers/run-wave.ts +10 -2
  95. package/src/cli/main.test.ts +8 -0
  96. package/src/cli/main.ts +15 -0
  97. package/src/cli/registry.ts +4 -0
  98. package/src/gate-decision-bundle.test.ts +56 -0
  99. package/src/gate-decision.test.ts +237 -0
  100. package/src/gate-decision.ts +321 -0
  101. package/src/lifecycle/gate-applied.ts +115 -0
  102. package/src/lifecycle/gate-audit-bundle.test.ts +47 -0
  103. package/src/lifecycle/gate-audit.test.ts +134 -0
  104. package/src/lifecycle/gate-audit.ts +438 -0
  105. package/src/lifecycle/gate-ledger-select.ts +113 -0
  106. package/src/lifecycle/gate-ledger.test.ts +22 -0
  107. package/src/lifecycle/gate-ledger.ts +9 -104
  108. package/src/lifecycle/override-ledger.test.ts +63 -0
  109. package/src/lifecycle/override-ledger.ts +69 -0
  110. package/src/lifecycle/rollback.ts +5 -1
  111. package/src/lifecycle/work-item-id.test.ts +71 -0
  112. package/src/lifecycle/work-item-id.ts +35 -0
  113. package/src/lifecycle/work-lease.ts +4 -5
  114. package/src/lint/post-synth.test.ts +29 -0
  115. package/src/lint/post-synth.ts +21 -2
  116. package/src/notify-bundle.test.ts +50 -0
  117. package/src/notify-verify.test.ts +59 -0
  118. package/src/notify-verify.ts +93 -0
  119. package/src/notify.schema.json +72 -0
  120. package/src/notify.test.ts +279 -0
  121. package/src/notify.ts +398 -0
  122. package/src/op/activities/gh-host.test.ts +130 -0
  123. package/src/op/activities/gh-host.ts +79 -0
  124. package/src/op/activities/lexicon-upgrade.ts +22 -1
  125. package/src/op/activities/propose-upgrade.ts +5 -1
  126. package/src/op/activities/reconcile.test.ts +284 -280
  127. package/src/op/activities/reconcile.ts +128 -131
  128. package/src/op/gate-approval.test.ts +175 -7
  129. package/src/op/gate-approval.ts +27 -1
  130. package/src/op/gate-name.ts +10 -2
  131. package/src/op/gate-resume.ts +6 -3
  132. package/src/op/gate-review.ts +4 -7
  133. package/src/op/gate.ts +117 -16
  134. package/src/op/index.ts +20 -5
  135. package/src/op/op-waves-notify.test.ts +127 -0
  136. package/src/op/op-waves-notify.ts +101 -0
  137. package/src/op/op-waves-run.ts +527 -104
  138. package/src/op/op-waves.test.ts +383 -3
  139. package/src/op/op-waves.ts +187 -6
  140. package/src/op/operator.test.ts +29 -0
  141. package/src/op/operator.ts +27 -2
  142. package/src/op/run-context.test.ts +2 -2
  143. package/src/op/work-lease-decl.ts +4 -4
  144. package/src/op/work-lease-run.test.ts +5 -0
  145. package/src/op/work-lease-run.ts +4 -3
  146. package/src/override-gate-bundle.test.ts +47 -0
  147. package/src/override-gate.test.ts +239 -0
  148. package/src/override-gate.ts +395 -0
  149. package/src/pr-forge-bundle.test.ts +40 -0
  150. package/src/pr-forge.test.ts +303 -0
  151. package/src/pr-forge.ts +502 -47
  152. package/src/workspace/records-write-contract.test.ts +3 -2
  153. package/src/workspace/status-gates.ts +7 -4
  154. package/src/workspace/trust/gate-seal.test.ts +156 -0
  155. package/src/workspace/trust/gate-seal.ts +254 -0
  156. package/src/workspace/trust/policy.ts +10 -165
  157. package/src/workspace/trust/seal.ts +19 -34
  158. package/src/workspace/trust/trust-files.test.ts +50 -0
  159. package/src/workspace/trust/trust-files.ts +245 -0
  160. package/src/workspace/work-cli.ts +5 -3
  161. package/src/workspace/work-history.test.ts +7 -0
@@ -104,14 +104,15 @@ describe("real output validates", () => {
104
104
  });
105
105
 
106
106
  test("records new, as a dry run against the chant repo's own decisions", async () => {
107
- const doc = await newRecord({ kind: KIND, fields, dryRun: true, cwd: REPO });
107
+ // The repo holds ws- and sql- decisions (#3751), so a new one names its prefix.
108
+ const doc = await newRecord({ kind: KIND, fields, prefix: "ws", dryRun: true, cwd: REPO });
108
109
  expectValid(newSchema, doc);
109
110
  expect(doc).toMatchObject({ dryRun: true, path: expect.stringMatching(/^docs\/design\/decisions\/ws-\d{3}-a-decision-written-through-chant\.md$/) });
110
111
  });
111
112
 
112
113
  test("each command written, dry-run and refused", async () => {
113
114
  const root = copyDecisions();
114
- const made = await newRecord({ kind: KIND, fields, cwd: root });
115
+ const made = await newRecord({ kind: KIND, fields, prefix: "ws", cwd: root });
115
116
  expectValid(newSchema, made);
116
117
  if ("error" in made) throw new Error(made.error.message);
117
118
  expectValid(newSchema, await newRecord({ kind: KIND, fields: "[]", cwd: root }));
@@ -16,13 +16,14 @@
16
16
  * gate in its environment is the anchor, only approvals recorded since it and
17
17
  * for its plan count, and a gate with an `approval` block counts them with
18
18
  * `tallyGateApprovals`, as `describeApprovalProgress` does after an approve.
19
+ * Its human quorum reaches back to `humanQuorumSince` (#3206), as a run's does.
19
20
  */
20
21
 
21
22
  import { execFileSync } from "node:child_process";
22
23
  import { samePlanDigest } from "../lifecycle/plan-digest";
23
24
  import type { GateResolutionRecord, PendingGateRecord } from "../lifecycle/gate-ledger";
24
25
  import { isPendingGateExpired, latestPendingGate, latestResolutionForPlan, parseGateLedger } from "../lifecycle/gate-ledger";
25
- import { tallyGateApprovals, type GateApprovalRule } from "../op/gate";
26
+ import { humanQuorumSince, tallyGateApprovals, type GateApprovalRule } from "../op/gate";
26
27
  import type { ReasonCode } from "./reason-codes";
27
28
 
28
29
  /** Why a member's gates can't be listed. Closed: a new code is a contract change. */
@@ -139,7 +140,7 @@ export function approveCommand(component: string, gate: string, env: string | nu
139
140
  const approvalOf = (r: GateResolutionRecord): StatusGateApproval => ({ principal: r.resolvedBy, channel: r.origin ?? null, relayedBy: r.relayedBy ?? null, at: r.timestamp });
140
141
 
141
142
  /** One gate in one environment, decided against its newest pending fact. */
142
- function decide(component: string, standing: PendingGateRecord, all: GateResolutionRecord[], now: string, rule: GateApprovalRule | null): StatusGate {
143
+ function decide(component: string, standing: PendingGateRecord, facts: PendingGateRecord[], all: GateResolutionRecord[], now: string, rule: GateApprovalRule | null): StatusGate {
143
144
  const gate = standing.gate;
144
145
  // #3163: under identity.gates, an unsigned approval, or one from outside the class, doesn't count, as a run reads it.
145
146
  const resolutions = rule === null ? all : all.filter((r) => r.gate !== gate || rule.refuses(r) === null);
@@ -155,7 +156,8 @@ function decide(component: string, standing: PendingGateRecord, all: GateResolut
155
156
  let needed = 1;
156
157
  let mismatched: GateResolutionRecord | undefined;
157
158
  if (standing.approval) {
158
- const tally = tallyGateApprovals(own, gate, since, planDigest, standing.approval);
159
+ // #3206: a human approval from before a fact re-recorded for a mode or context change still counts.
160
+ const tally = tallyGateApprovals(own, gate, since, planDigest, standing.approval, humanQuorumSince(facts, standing));
159
161
  approved = tally.permit !== undefined || tally.counted.length >= tally.need;
160
162
  approvals = tally.permit && !tally.counted.includes(tally.permit) ? [...tally.counted, tally.permit] : tally.counted;
161
163
  needed = tally.need;
@@ -207,7 +209,8 @@ export function gatesInLedger(component: string, content: string, envs: readonly
207
209
  byEnv.set(env, [...(byEnv.get(env) ?? []), p]);
208
210
  }
209
211
  for (const env of [...byEnv.keys()].sort((a, b) => envs.indexOf(a ?? "") - envs.indexOf(b ?? ""))) {
210
- gates.push(decide(component, latestPendingGate(byEnv.get(env)!, gate)!, resolutions, now, rules(gate)));
212
+ const envFacts = byEnv.get(env)!;
213
+ gates.push(decide(component, latestPendingGate(envFacts, gate)!, envFacts, resolutions, now, rules(gate)));
211
214
  }
212
215
  }
213
216
  return { gates, malformed };
@@ -0,0 +1,156 @@
1
+ /**
2
+ * #3763, #3785: a gate approval's seal, checked with node:crypto, answers as
3
+ * ssh-keygen does. Each seal is made by `ssh-keygen -Y sign` and checked three
4
+ * ways: {@link gateSealRefusal} here, `checkGateSeal` (which now uses the same
5
+ * verifier), and `ssh-keygen -Y verify` run directly against the same signers
6
+ * text, so the agreement does not rest on chant's own code alone.
7
+ */
8
+
9
+ import { execFileSync } from "node:child_process";
10
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { afterAll, describe, expect, test } from "vitest";
14
+ import { GATE_SEAL_NAMESPACE, gateSealPayload, gateSealRefusal, sshKeyFingerprint, verifySshSignatureWith, type GateSigners, type SealedGateApproval } from "./gate-seal";
15
+ import { checkGateSeal } from "./seal";
16
+ import { emptyPolicy, parseAllowedSigners, type TrustPolicy } from "./policy";
17
+ import { hasSshKeygen } from "./test-repo";
18
+
19
+ const dir = mkdtempSync(join(tmpdir(), "chant-3763-seal-"));
20
+ afterAll(() => rmSync(dir, { recursive: true, force: true }));
21
+
22
+ interface Key {
23
+ file: string;
24
+ /** `<type> <base64>`. */
25
+ pub: string;
26
+ }
27
+
28
+ function key(name: string, args: string[]): Key {
29
+ const file = join(dir, `key-${name}`);
30
+ execFileSync("ssh-keygen", ["-q", ...args, "-N", "", "-C", name, "-f", file]);
31
+ return { file, pub: readFileSync(`${file}.pub`, "utf-8").trim().split(" ").slice(0, 2).join(" ") };
32
+ }
33
+
34
+ function sign(k: Key, payload: Buffer, namespace = GATE_SEAL_NAMESPACE): string {
35
+ const f = join(dir, `payload-${Math.random().toString(36).slice(2)}`);
36
+ writeFileSync(f, payload);
37
+ execFileSync("ssh-keygen", ["-q", "-Y", "sign", "-n", namespace, "-f", k.file, f], { stdio: "ignore" });
38
+ return readFileSync(`${f}.sig`, "utf-8");
39
+ }
40
+
41
+ const blob = (k: Key): Buffer => Buffer.from(k.pub.split(" ")[1]!, "base64");
42
+
43
+ function approval(over: Partial<SealedGateApproval> = {}): SealedGateApproval {
44
+ return { op: "migrations", gate: "migrations-wave-1", planDigest: "jcs1-sha256:abc", resolvedBy: "alice", timestamp: "2026-10-11T00:00:00.000Z", ...over };
45
+ }
46
+
47
+ const signersText = new WeakMap<TrustPolicy, string>();
48
+
49
+ function policyOf(text: string): TrustPolicy {
50
+ const set = parseAllowedSigners(text);
51
+ const policy: TrustPolicy = { ...emptyPolicy("0".repeat(40)), active: true, signers: set.signers, excluded: set.excluded };
52
+ signersText.set(policy, text);
53
+ return policy;
54
+ }
55
+
56
+ /** Both checks, as `{ ours, keygen }`: null or the refusal, and whether ssh-keygen attested it. */
57
+ function both(policy: TrustPolicy, a: SealedGateApproval): { ours: string | null; keygen: boolean } {
58
+ const ours = gateSealRefusal(policy as GateSigners, a);
59
+ // checkGateSeal shares the verifier; it must answer as the rule does.
60
+ expect(checkGateSeal(policy, a).attested === true).toBe(ours === null);
61
+ // ssh-keygen -Y verify, run directly, against the same signers text.
62
+ const seal = a.seal as { signature?: string } | undefined;
63
+ let keygen = false;
64
+ if (typeof seal?.signature === "string") {
65
+ const f = join(dir, `v-${Math.random().toString(36).slice(2)}`);
66
+ writeFileSync(`${f}.signers`, signersText.get(policy) ?? "");
67
+ writeFileSync(`${f}.sig`, seal.signature);
68
+ try {
69
+ execFileSync("ssh-keygen", ["-Y", "verify", "-f", `${f}.signers`, "-I", a.resolvedBy.normalize("NFKC").trim().toLowerCase(), "-n", GATE_SEAL_NAMESPACE, "-s", `${f}.sig`], { input: gateSealPayload(a), stdio: ["pipe", "ignore", "ignore"] });
70
+ keygen = true;
71
+ } catch {
72
+ keygen = false;
73
+ }
74
+ }
75
+ return { ours, keygen };
76
+ }
77
+
78
+ describe.skipIf(!hasSshKeygen)("an SSHSIG checked with node:crypto", () => {
79
+ const keys: Array<[string, string[]]> = [
80
+ ["ed25519", ["-t", "ed25519"]],
81
+ ["rsa", ["-t", "rsa", "-b", "2048"]],
82
+ ["ecdsa-256", ["-t", "ecdsa", "-b", "256"]],
83
+ ["ecdsa-384", ["-t", "ecdsa", "-b", "384"]],
84
+ ["ecdsa-521", ["-t", "ecdsa", "-b", "521"]],
85
+ ];
86
+
87
+ test.each(keys)("a %s seal verifies, and an edited approval does not", (name, args) => {
88
+ const k = key(`alice-${name}`, args);
89
+ const a = approval();
90
+ const signature = sign(k, gateSealPayload(a));
91
+ const r = verifySshSignatureWith(signature, gateSealPayload(a), GATE_SEAL_NAMESPACE, [blob(k)]);
92
+ expect(r).toEqual({ ok: true, key: sshKeyFingerprint(blob(k)) });
93
+ // The fingerprint is the one ssh-keygen prints.
94
+ expect(execFileSync("ssh-keygen", ["-l", "-f", `${k.file}.pub`], { encoding: "utf-8" })).toContain(sshKeyFingerprint(blob(k)));
95
+
96
+ const policy = policyOf(`alice ${k.pub}\n`);
97
+ const seal = { signer: "alice", key: "SHA256:x", signature };
98
+ expect(both(policy, { ...a, seal })).toEqual({ ours: null, keygen: true });
99
+ for (const edited of [{ planDigest: "jcs1-sha256:other" }, { timestamp: "2026-10-12T00:00:00.000Z" }, { relayedBy: "bot" }, { environment: "prod" }]) {
100
+ const res = both(policy, { ...a, ...edited, seal });
101
+ expect(res.keygen).toBe(false);
102
+ expect(res.ours).toMatch(/does not verify for alice/);
103
+ }
104
+ });
105
+
106
+ test("a seal in another namespace, or by a key listed for someone else, does not count either way", () => {
107
+ const alice = key("alice-ns", ["-t", "ed25519"]);
108
+ const bob = key("bob-ns", ["-t", "ed25519"]);
109
+ const a = approval();
110
+ const policy = policyOf(`alice ${alice.pub}\nbob ${bob.pub}\n`);
111
+
112
+ const otherNs = { signer: "alice", key: "k", signature: sign(alice, gateSealPayload(a), "chant-review") };
113
+ expect(both(policy, { ...a, seal: otherNs }).keygen).toBe(false);
114
+ expect(both(policy, { ...a, seal: otherNs }).ours).toMatch(/chant-review namespace/);
115
+
116
+ const byBob = { signer: "alice", key: "k", signature: sign(bob, gateSealPayload(a)) };
117
+ expect(both(policy, { ...a, seal: byBob }).keygen).toBe(false);
118
+ expect(both(policy, { ...a, seal: byBob }).ours).toMatch(/does not list/);
119
+ });
120
+
121
+ test("a signers line's namespaces option is honoured", () => {
122
+ const k = key("alice-opt", ["-t", "ed25519"]);
123
+ const a = approval();
124
+ const seal = { signer: "alice", key: "k", signature: sign(k, gateSealPayload(a)) };
125
+ expect(both(policyOf(`alice namespaces="chant-gate,git" ${k.pub}\n`), { ...a, seal })).toEqual({ ours: null, keygen: true });
126
+ expect(both(policyOf(`alice namespaces="chant-*" ${k.pub}\n`), { ...a, seal })).toEqual({ ours: null, keygen: true });
127
+ const refused = both(policyOf(`alice namespaces="git" ${k.pub}\n`), { ...a, seal });
128
+ expect(refused.keygen).toBe(false);
129
+ expect(refused.ours).not.toBeNull();
130
+ });
131
+
132
+ test("principals compare as chant compares them", () => {
133
+ const k = key("alice-case", ["-t", "ed25519"]);
134
+ const a = approval({ resolvedBy: "github:Alice" });
135
+ const seal = { signer: "github:alice", key: "k", signature: sign(k, gateSealPayload(a)) };
136
+ expect(both(policyOf(`github:alice ${k.pub}\n`), { ...a, seal })).toEqual({ ours: null, keygen: true });
137
+ });
138
+ });
139
+
140
+ describe("the refusals that need no signature", () => {
141
+ const signers = (over: Partial<GateSigners> = {}): GateSigners => ({ base: "a".repeat(40), signersPath: ".chant/allowed_signers", active: true, signers: [], problems: [], ...over });
142
+
143
+ test("no signers file at base passes nothing", () => {
144
+ expect(gateSealRefusal(signers({ active: false }), approval())).toBe(`the gate is sealed, and there is no signers file (.chant/allowed_signers) at base ${"a".repeat(12)}`);
145
+ expect(gateSealRefusal(signers({ active: false, problems: [".chant/trust.json is invalid"] }), approval())).toMatch(/: \.chant\/trust\.json is invalid$/);
146
+ });
147
+
148
+ test("an unsigned, malformed, or someone else's seal", () => {
149
+ const listed = signers({ signers: parseAllowedSigners("alice ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOv1\n").signers });
150
+ expect(gateSealRefusal(listed, approval())).toBe("the approval of gate migrations-wave-1 by alice carries no seal");
151
+ expect(gateSealRefusal(listed, approval({ seal: { signer: "alice" } }))).toMatch(/malformed/);
152
+ expect(gateSealRefusal(listed, approval({ seal: { signer: "bob", signature: "x" } }))).toMatch(/is by bob, and its approver is alice/);
153
+ expect(gateSealRefusal(listed, approval({ resolvedBy: "carol", seal: { signer: "carol", signature: "x" } }))).toBe("carol has no key in .chant/allowed_signers at base, so the seal can't count");
154
+ expect(gateSealRefusal(listed, approval({ seal: { signer: "alice", signature: "not a signature" } }))).toMatch(/not an armored ssh signature/);
155
+ });
156
+ });
@@ -0,0 +1,254 @@
1
+ /**
2
+ * A gate approval's seal (#3163, ws-080), checked with node:crypto (#3763).
3
+ *
4
+ * `chant approve --sign` seals an approval with `ssh-keygen -Y sign` in the
5
+ * `chant-gate` namespace, over {@link gateSealPayload}. Signing shells to
6
+ * ssh-keygen; checking does not. This module checks a seal with node:crypto
7
+ * and no zod, and `checkGateSeal` (`./seal.ts`) and `sealedApprovalRule` use
8
+ * it, so an apply job needs no ssh-keygen. A tool that decides a gate from one bundled file
9
+ * (terragucci's apply job) runs the same rule: `@intentius/chant/gate-decision`
10
+ * exports it.
11
+ *
12
+ * An SSHSIG (OpenSSH's PROTOCOL.sshsig) holds the public key, the namespace,
13
+ * the hash algorithm and the signature over `"SSHSIG"`, the namespace, the
14
+ * reserved field, the algorithm and the message's hash. Ed25519, ECDSA (P-256,
15
+ * P-384, P-521), RSA with SHA-2, and the security-key forms of Ed25519 and
16
+ * P-256 verify, with the touch flag required, as ssh-keygen requires it for a
17
+ * signers line with no `no-touch-required` option (which the signers parser
18
+ * does not accept). An RSA signature over SHA-1 does not verify.
19
+ */
20
+
21
+ import { createHash, createPublicKey, verify, type KeyObject } from "node:crypto";
22
+ import type { Signer } from "./trust-files";
23
+
24
+ /**
25
+ * The ssh signature namespace of a gate approval's seal (#3163, ws-080), so a
26
+ * verdict, record or commit signature can't stand in for one.
27
+ */
28
+ export const GATE_SEAL_NAMESPACE = "chant-gate";
29
+
30
+ /** The gate approval a seal covers (#3163): the fields of a gate resolution line that say what was approved, by whom and when. */
31
+ export interface SealedGateApproval {
32
+ op: string;
33
+ gate: string;
34
+ environment?: string;
35
+ planDigest?: string;
36
+ resolvedBy: string;
37
+ /** Who relayed the approval for `resolvedBy` (#3402), when someone did. */
38
+ relayedBy?: string;
39
+ timestamp: string;
40
+ seal?: unknown;
41
+ }
42
+
43
+ /**
44
+ * The bytes a gate approval's seal signs (#3163): the op (or component), the
45
+ * gate, the environment, the plan digest, the approver and the time, joined
46
+ * by LF with no final newline. An approval that binds no environment or plan
47
+ * signs an empty line for it. A relayed approval (#3402) signs a seventh line,
48
+ * `relayed-by <principal>`, so the relay can't be added, changed or removed
49
+ * after sealing; an approval nobody relayed signs the six lines it always did,
50
+ * so seals made before #3402 still verify.
51
+ */
52
+ export function gateSealPayload(a: SealedGateApproval): Buffer {
53
+ const relay = a.relayedBy !== undefined ? `\nrelayed-by ${a.relayedBy}` : "";
54
+ return Buffer.from(`${a.op}\n${a.gate}\n${a.environment ?? ""}\n${a.planDigest ?? ""}\n${a.resolvedBy}\n${a.timestamp}${relay}`, "utf-8");
55
+ }
56
+
57
+ // ── SSHSIG ───────────────────────────────────────────────────────────────
58
+
59
+ /** A reader over ssh wire format: `take(n)` bytes, or `take()` one length-prefixed string. */
60
+ function reader(buf: Buffer): (n?: number) => Buffer {
61
+ let at = 0;
62
+ const take = (n?: number): Buffer => {
63
+ const len = n ?? take(4).readUInt32BE(0);
64
+ if (at + len > buf.length) throw new Error("the signature is truncated");
65
+ return buf.subarray(at, (at += len));
66
+ };
67
+ return take;
68
+ }
69
+
70
+ const wire = (b: Buffer | string): Buffer => {
71
+ const v = Buffer.from(b);
72
+ const n = Buffer.alloc(4);
73
+ n.writeUInt32BE(v.length);
74
+ return Buffer.concat([n, v]);
75
+ };
76
+ const hash = (alg: string, b: Buffer): Buffer => createHash(alg).update(b).digest();
77
+ const b64url = (b: Buffer): string => b.toString("base64url");
78
+ /** An mpint without its sign byte, left-padded to `size` bytes when given. */
79
+ const unsigned = (b: Buffer, size?: number): Buffer => {
80
+ let v = b;
81
+ while (v.length > 1 && v[0] === 0) v = v.subarray(1);
82
+ return size === undefined || v.length >= size ? v : Buffer.concat([Buffer.alloc(size - v.length), v]);
83
+ };
84
+
85
+ /** `SHA256:<base64>`, the fingerprint ssh-keygen prints for a public key blob. */
86
+ export function sshKeyFingerprint(blob: Buffer): string {
87
+ return `SHA256:${hash("sha256", blob).toString("base64").replace(/=+$/, "")}`;
88
+ }
89
+
90
+ const CURVES: Record<string, { crv: string; size: number; hash: string }> = {
91
+ nistp256: { crv: "P-256", size: 32, hash: "sha256" },
92
+ nistp384: { crv: "P-384", size: 48, hash: "sha384" },
93
+ nistp521: { crv: "P-521", size: 66, hash: "sha512" },
94
+ };
95
+
96
+ /** The key a public key blob holds, what it signs with, and the security key's application when it is one. */
97
+ function publicKey(blob: Buffer): { type: string; key: KeyObject; ecdsa?: { size: number; hash: string }; application?: Buffer } {
98
+ const take = reader(blob);
99
+ const type = take().toString();
100
+ if (type === "ssh-ed25519" || type === "sk-ssh-ed25519@openssh.com") {
101
+ const key = createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: b64url(take()) }, format: "jwk" });
102
+ return { type, key, ...(type.startsWith("sk-") ? { application: take() } : {}) };
103
+ }
104
+ if (type.startsWith("ecdsa-sha2-") || type === "sk-ecdsa-sha2-nistp256@openssh.com") {
105
+ const curve = CURVES[take().toString()];
106
+ if (!curve) throw new Error(`unsupported curve in ${type}`);
107
+ const q = take();
108
+ if (q[0] !== 4 || q.length !== 1 + 2 * curve.size) throw new Error("the ECDSA key is not an uncompressed point");
109
+ const key = createPublicKey({
110
+ key: { kty: "EC", crv: curve.crv, x: b64url(q.subarray(1, 1 + curve.size)), y: b64url(q.subarray(1 + curve.size)) },
111
+ format: "jwk",
112
+ });
113
+ return { type, key, ecdsa: curve, ...(type.startsWith("sk-") ? { application: take() } : {}) };
114
+ }
115
+ if (type === "ssh-rsa") {
116
+ const e = unsigned(take());
117
+ const n = unsigned(take());
118
+ return { type, key: createPublicKey({ key: { kty: "RSA", e: b64url(e), n: b64url(n) }, format: "jwk" }) };
119
+ }
120
+ throw new Error(`unsupported key type ${type}`);
121
+ }
122
+
123
+ /** What {@link verifySshSignatureWith} found. */
124
+ export type SshSignatureCheck = { ok: true; key: string } | { ok: false; reason: string };
125
+
126
+ /**
127
+ * Whether `armored`, an SSHSIG as `ssh-keygen -Y sign` writes it, signs
128
+ * `message` in `namespace` with one of `keys` (public key blobs, ssh wire
129
+ * format). Never throws. On success, `key` is the signing key's fingerprint.
130
+ */
131
+ export function verifySshSignatureWith(armored: string, message: Buffer, namespace: string, keys: readonly Buffer[]): SshSignatureCheck {
132
+ try {
133
+ const body = /-----BEGIN SSH SIGNATURE-----([^-]*)-----END SSH SIGNATURE-----/.exec(armored)?.[1];
134
+ if (body === undefined) return { ok: false, reason: "it is not an armored ssh signature" };
135
+ const take = reader(Buffer.from(body.replace(/\s+/g, ""), "base64"));
136
+ if (take(6).toString() !== "SSHSIG" || take(4).readUInt32BE(0) !== 1) return { ok: false, reason: "it is not an SSHSIG, version 1" };
137
+ const pk = take();
138
+ const ns = take();
139
+ const reserved = take();
140
+ const alg = take().toString();
141
+ const sigWire = take();
142
+ if (ns.toString() !== namespace) return { ok: false, reason: `it is signed in the ${ns.toString()} namespace, not ${namespace}` };
143
+ if (alg !== "sha256" && alg !== "sha512") return { ok: false, reason: `it hashes with ${alg}, and only sha256 and sha512 are accepted` };
144
+ if (!keys.some((k) => k.equals(pk))) return { ok: false, reason: "signed by a key the policy at base does not list for the approver" };
145
+ let data = Buffer.concat([Buffer.from("SSHSIG"), wire(ns), wire(reserved), wire(alg), wire(hash(alg, message))]);
146
+ const { type, key, ecdsa, application } = publicKey(pk);
147
+ const sig = reader(sigWire);
148
+ const sigType = sig().toString();
149
+ const raw = sig();
150
+ const fingerprint = sshKeyFingerprint(pk);
151
+ const answer = (ok: boolean): SshSignatureCheck => (ok ? { ok: true, key: fingerprint } : { ok: false, reason: "the signature does not match the signed fields" });
152
+ if (type === "ssh-rsa") {
153
+ if (sigType !== "rsa-sha2-256" && sigType !== "rsa-sha2-512") return { ok: false, reason: `an RSA signature of type ${sigType} is not accepted` };
154
+ return answer(verify(`sha${sigType.slice(9)}`, data, key, raw));
155
+ }
156
+ if (sigType !== type) return { ok: false, reason: `a ${sigType} signature from a ${type} key` };
157
+ if (application !== undefined) {
158
+ // A security key signs its application, its flags and counter, then the data's hash; ssh-keygen requires the touch flag.
159
+ const flags = sig(1);
160
+ if (!(flags[0]! & 1)) return { ok: false, reason: "the security key was not touched (the user-presence flag is unset)" };
161
+ data = Buffer.concat([hash("sha256", application), flags, sig(4), hash("sha256", data)]);
162
+ }
163
+ if (ecdsa) {
164
+ const rs = reader(raw);
165
+ const r = unsigned(rs(), ecdsa.size);
166
+ const s = unsigned(rs(), ecdsa.size);
167
+ return answer(verify(application !== undefined ? "sha256" : ecdsa.hash, data, { key, dsaEncoding: "ieee-p1363" }, Buffer.concat([r, s])));
168
+ }
169
+ return answer(verify(null, data, key, raw));
170
+ } catch (err) {
171
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) };
172
+ }
173
+ }
174
+
175
+ /**
176
+ * The key blob an armored SSHSIG carries, or null when it is not one. For
177
+ * `ssh-keygen -Y check-novalidate`'s question, whether the signature is
178
+ * intact whoever's key it is: verify against this key.
179
+ */
180
+ export function sshSignatureKey(armored: string): Buffer | null {
181
+ try {
182
+ const body = /-----BEGIN SSH SIGNATURE-----([^-]*)-----END SSH SIGNATURE-----/.exec(armored)?.[1];
183
+ if (body === undefined) return null;
184
+ const take = reader(Buffer.from(body.replace(/\s+/g, ""), "base64"));
185
+ if (take(6).toString() !== "SSHSIG" || take(4).readUInt32BE(0) !== 1) return null;
186
+ return Buffer.from(take());
187
+ } catch {
188
+ return null;
189
+ }
190
+ }
191
+
192
+ // ── the rule ─────────────────────────────────────────────────────────────
193
+
194
+ /**
195
+ * The signers a sealed gate is judged by, read at base. chant's own
196
+ * `TrustPolicy` (`./policy.ts`) is one; `gateSignersAt` in
197
+ * `@intentius/chant/gate-decision` reads one without zod.
198
+ */
199
+ export interface GateSigners {
200
+ /** The commit the signers were read at, or null when there is no base. */
201
+ base: string | null;
202
+ /** The signers path used. */
203
+ signersPath: string;
204
+ /** Whether a signers file exists at base. False: no seal verifies, so a sealed gate passes nothing. */
205
+ active: boolean;
206
+ signers: Signer[];
207
+ /** Why the trust config could not be read, when it could not. `active` is false then. */
208
+ problems: string[];
209
+ }
210
+
211
+ const normalise = (name: string): string => name.normalize("NFKC").trim().toLowerCase();
212
+
213
+ /** Whether ssh-keygen's `namespaces="..."` pattern list admits `namespace`: a `*`/`?` pattern matches, and a `!` pattern that matches refuses. */
214
+ export function namespaceAllowed(list: string | undefined, namespace: string): boolean {
215
+ if (list === undefined) return true;
216
+ let allowed = false;
217
+ for (const raw of list.split(",")) {
218
+ const negated = raw.startsWith("!");
219
+ const pattern = negated ? raw.slice(1) : raw;
220
+ const re = new RegExp(`^${pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")}$`);
221
+ if (!re.test(namespace)) continue;
222
+ if (negated) return false;
223
+ allowed = true;
224
+ }
225
+ return allowed;
226
+ }
227
+
228
+ /**
229
+ * Why this approval does not count toward a sealed gate, or null when it
230
+ * does: its seal verifies, for the approver it names, against a key `signers`
231
+ * lists for that approver in the `chant-gate` namespace. With no signers file
232
+ * at base, nothing counts. The same rule as `sealedApprovalRule` in
233
+ * `../../op/gate-review.ts`, which checks with ssh-keygen.
234
+ */
235
+ export function gateSealRefusal(signers: GateSigners, a: SealedGateApproval): string | null {
236
+ const where = `${signers.signersPath} at base`;
237
+ if (!signers.active) {
238
+ const why = signers.problems.length > 0 ? `: ${signers.problems.join("; ")}` : "";
239
+ return `the gate is sealed, and there is no signers file (${signers.signersPath}) at base ${signers.base?.slice(0, 12) ?? ""}`.trim() + why;
240
+ }
241
+ const what = `the approval of gate ${a.gate} by ${a.resolvedBy}`;
242
+ if (a.seal === undefined || a.seal === null) return `${what} carries no seal`;
243
+ const seal = a.seal as { signer?: unknown; signature?: unknown };
244
+ if (typeof seal !== "object" || Array.isArray(seal) || typeof seal.signer !== "string" || typeof seal.signature !== "string") {
245
+ return `the seal on ${what} is malformed: it needs signer and signature`;
246
+ }
247
+ if (normalise(seal.signer) !== normalise(a.resolvedBy)) return `the seal on ${what} is by ${seal.signer}, and its approver is ${a.resolvedBy || "empty"}`;
248
+ const keys = signers.signers
249
+ .filter((s) => normalise(s.principal) === normalise(a.resolvedBy) && namespaceAllowed(s.namespaces, GATE_SEAL_NAMESPACE))
250
+ .map((s) => Buffer.from(s.key.split(" ")[1] ?? "", "base64"));
251
+ if (keys.length === 0) return `${a.resolvedBy} has no key in ${where}, so the seal can't count`;
252
+ const r = verifySshSignatureWith(seal.signature, gateSealPayload(a), GATE_SEAL_NAMESPACE, keys);
253
+ return r.ok ? null : `the seal does not verify for ${a.resolvedBy} against ${where}: ${r.reason}`;
254
+ }
@@ -24,172 +24,17 @@
24
24
  import { posix } from "node:path";
25
25
  import { z } from "zod";
26
26
  import type { RecordSource } from "../record-source";
27
+ import { DEFAULT_SIGNERS_PATH, TRUST_CONFIG_PATH, parseAllowedSigners, type ExcludedSigner, type Signer } from "./trust-files";
27
28
 
28
- /** The trust config, relative to the repository root. Fixed: it is how the signers path is overridden. */
29
- export const TRUST_CONFIG_PATH = ".chant/trust.json";
30
-
31
- /** Where the signers are when `trust.json` names no other path. */
32
- export const DEFAULT_SIGNERS_PATH = ".chant/allowed_signers";
33
-
34
- // ── allowed_signers ──────────────────────────────────────────────────────────
35
-
36
- /** Key types ssh-keygen accepts in an allowed_signers line. */
37
- const KEY_TYPES = new Set([
38
- "ssh-ed25519",
39
- "ssh-rsa",
40
- "ecdsa-sha2-nistp256",
41
- "ecdsa-sha2-nistp384",
42
- "ecdsa-sha2-nistp521",
43
- "sk-ssh-ed25519@openssh.com",
44
- "sk-ecdsa-sha2-nistp256@openssh.com",
45
- ]);
46
-
47
- /** One usable signer: a principal and the public key it signs with. */
48
- export interface Signer {
49
- principal: string;
50
- /** `<type> <base64>`, with no comment. */
51
- key: string;
52
- /** The `namespaces="..."` option as written, or undefined when the key may sign any namespace. */
53
- namespaces?: string;
54
- /** 1-based line in the signers file. */
55
- line: number;
56
- }
57
-
58
- /** A line of the signers file that is not used, and why. */
59
- export interface ExcludedSigner {
60
- line: number;
61
- reason: string;
62
- }
63
-
64
- export interface SignerSet {
65
- signers: Signer[];
66
- excluded: ExcludedSigner[];
67
- }
68
-
69
- /**
70
- * Split one line into fields. Double quotes group, so an option such as
71
- * `namespaces="git,file"` stays one field.
72
- */
73
- function fields(line: string): string[] {
74
- const out: string[] = [];
75
- let cur = "";
76
- let quoted = false;
77
- let any = false;
78
- for (const ch of line) {
79
- if (ch === '"') {
80
- quoted = !quoted;
81
- cur += ch;
82
- any = true;
83
- } else if (!quoted && (ch === " " || ch === "\t")) {
84
- if (any) out.push(cur);
85
- cur = "";
86
- any = false;
87
- } else {
88
- cur += ch;
89
- any = true;
90
- }
91
- }
92
- if (any) out.push(cur);
93
- return out;
94
- }
95
-
96
- /** `a="x,y",b` as `{ a: "x,y", b: "" }`. */
97
- function options(field: string): Map<string, string> {
98
- const opts = new Map<string, string>();
99
- let cur = "";
100
- let quoted = false;
101
- const flush = () => {
102
- if (!cur) return;
103
- const eq = cur.indexOf("=");
104
- const name = (eq < 0 ? cur : cur.slice(0, eq)).toLowerCase();
105
- const value = eq < 0 ? "" : cur.slice(eq + 1).replace(/^"|"$/g, "");
106
- opts.set(name, value);
107
- cur = "";
108
- };
109
- for (const ch of field) {
110
- if (ch === '"') quoted = !quoted;
111
- if (ch === "," && !quoted) flush();
112
- else cur += ch;
113
- }
114
- flush();
115
- return opts;
116
- }
117
-
118
- /**
119
- * Parse an allowed_signers file, keeping only lines whose meaning does not
120
- * depend on anything the signer controls.
121
- *
122
- * Refused, each with a reason, rather than passed to ssh-keygen:
123
- *
124
- * - `valid-after` and `valid-before`. git checks them against the commit's own
125
- * date, which whoever makes the commit sets. A backdated commit would pass a
126
- * window its key has left. Revocation is by position in history (#2553),
127
- * never by date.
128
- * - `cert-authority`. A certificate's validity is also a date, and a CA key
129
- * vouches for keys this file never names.
130
- * - Principal patterns (`*`, `?`, `!`). A signer is named by one exact
131
- * principal, so role grants can name it.
132
- */
133
- export function parseAllowedSigners(text: string): SignerSet {
134
- const signers: Signer[] = [];
135
- const excluded: ExcludedSigner[] = [];
136
- const lines = text.replace(/\r\n?/g, "\n").split("\n");
137
- for (let i = 0; i < lines.length; i++) {
138
- const line = i + 1;
139
- const raw = lines[i].trim();
140
- if (!raw || raw.startsWith("#")) continue;
141
- const f = fields(raw);
142
- if (f.length < 3) {
143
- excluded.push({ line, reason: "expected principals, optional options, a key type and a key" });
144
- continue;
145
- }
146
- const [principals, second] = f;
147
- const hasOptions = !KEY_TYPES.has(second);
148
- const type = hasOptions ? f[2] : second;
149
- const blob = hasOptions ? f[3] : f[2];
150
- if (!KEY_TYPES.has(type) || !blob || !/^[A-Za-z0-9+/]+={0,2}$/.test(blob)) {
151
- excluded.push({ line, reason: `no supported public key (${type ?? "none"})` });
152
- continue;
153
- }
154
- const opts = hasOptions ? options(second) : new Map<string, string>();
155
- const timed = ["valid-after", "valid-before"].filter((o) => opts.has(o));
156
- if (timed.length > 0) {
157
- excluded.push({
158
- line,
159
- reason: `${timed.join(" and ")} is checked against the commit's own date, which its author sets; revoke a key by removing it`,
160
- });
161
- continue;
162
- }
163
- if (opts.has("cert-authority")) {
164
- excluded.push({ line, reason: "cert-authority keys are not supported: a certificate's validity is a date the signer controls" });
165
- continue;
166
- }
167
- const unknown = [...opts.keys()].filter((o) => o !== "namespaces");
168
- if (unknown.length > 0) {
169
- excluded.push({ line, reason: `unknown option ${unknown.join(", ")}` });
170
- continue;
171
- }
172
- const names = principals.replace(/^"|"$/g, "").split(",").filter(Boolean);
173
- const pattern = names.find((p) => /[*?!]/.test(p));
174
- if (pattern !== undefined || names.length === 0) {
175
- excluded.push({ line, reason: `principal ${JSON.stringify(pattern ?? principals)} is a pattern; name one signer exactly` });
176
- continue;
177
- }
178
- for (const principal of names) {
179
- signers.push({ principal, key: `${type} ${blob}`, namespaces: opts.get("namespaces"), line });
180
- }
181
- }
182
- return { signers, excluded };
183
- }
184
-
185
- /** The signers as an allowed_signers file ssh-keygen reads, holding only the usable lines. */
186
- export function renderAllowedSigners(signers: Signer[]): string {
187
- return (
188
- signers
189
- .map((s) => `${JSON.stringify(s.principal)}${s.namespaces !== undefined ? ` namespaces=${JSON.stringify(s.namespaces)}` : ""} ${s.key}`)
190
- .join("\n") + "\n"
191
- );
192
- }
29
+ export {
30
+ DEFAULT_SIGNERS_PATH,
31
+ TRUST_CONFIG_PATH,
32
+ parseAllowedSigners,
33
+ renderAllowedSigners,
34
+ type ExcludedSigner,
35
+ type Signer,
36
+ type SignerSet,
37
+ } from "./trust-files";
193
38
 
194
39
  // ── trust.json ───────────────────────────────────────────────────────────────
195
40