@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
@@ -28,7 +28,11 @@ import { homedir, tmpdir } from "node:os";
28
28
  import { join, resolve } from "node:path";
29
29
  import type { SealCode } from "../records";
30
30
  import { type TrustPolicy } from "./policy";
31
+ import type { Signer } from "./trust-files";
31
32
  import { sshKeygen, verifySshSignature } from "./ssh-commit";
33
+ import { GATE_SEAL_NAMESPACE, gateSealPayload, namespaceAllowed, sshSignatureKey, verifySshSignatureWith, type SealedGateApproval } from "./gate-seal";
34
+
35
+ export { GATE_SEAL_NAMESPACE, gateSealPayload, type SealedGateApproval } from "./gate-seal";
32
36
 
33
37
  /** The ssh signature namespace of a verdict seal. */
34
38
  export const REVIEW_SEAL_NAMESPACE = "chant-review";
@@ -36,12 +40,6 @@ export const REVIEW_SEAL_NAMESPACE = "chant-review";
36
40
  /** The ssh signature namespace of a record's author seal (#2688). */
37
41
  export const RECORD_SEAL_NAMESPACE = "chant-record";
38
42
 
39
- /**
40
- * The ssh signature namespace of a gate approval's seal (#3163, ws-080), so a
41
- * verdict, record or commit signature can't stand in for one.
42
- */
43
- export const GATE_SEAL_NAMESPACE = "chant-gate";
44
-
45
43
  /** A seal as a review entry holds it. */
46
44
  export interface VerdictSeal {
47
45
  /** The principal who signed: the reviewer, as the signers file names them. */
@@ -87,33 +85,6 @@ export function recordSealPayload(record: string, digest: string, author: string
87
85
  return Buffer.from(`${record}\n${digest}\n${author}\n${state ?? ""}`, "utf-8");
88
86
  }
89
87
 
90
- /** The gate approval a seal covers (#3163): the fields of a gate resolution line that say what was approved, by whom and when. */
91
- export interface SealedGateApproval {
92
- op: string;
93
- gate: string;
94
- environment?: string;
95
- planDigest?: string;
96
- resolvedBy: string;
97
- /** Who relayed the approval for `resolvedBy` (#3402), when someone did. */
98
- relayedBy?: string;
99
- timestamp: string;
100
- seal?: unknown;
101
- }
102
-
103
- /**
104
- * The bytes a gate approval's seal signs (#3163): the op (or component), the
105
- * gate, the environment, the plan digest, the approver and the time, joined
106
- * by LF with no final newline. An approval that binds no environment or plan
107
- * signs an empty line for it. A relayed approval (#3402) signs a seventh line,
108
- * `relayed-by <principal>`, so the relay can't be added, changed or removed
109
- * after sealing; an approval nobody relayed signs the six lines it always did,
110
- * so seals made before #3402 still verify.
111
- */
112
- export function gateSealPayload(a: SealedGateApproval): Buffer {
113
- const relay = a.relayedBy !== undefined ? `\nrelayed-by ${a.relayedBy}` : "";
114
- return Buffer.from(`${a.op}\n${a.gate}\n${a.environment ?? ""}\n${a.planDigest ?? ""}\n${a.resolvedBy}\n${a.timestamp}${relay}`, "utf-8");
115
- }
116
-
117
88
  /** The record an author seal covers (#2688). */
118
89
  export interface SealedRecord {
119
90
  record: string | null;
@@ -239,14 +210,28 @@ function checkSeal(policy: TrustPolicy, s: SealSubject): SealCheck {
239
210
  if (listed.length === 0) {
240
211
  return { attested: false, code: "seal-signer-unlisted", message: `${s.principal} has no key in ${where}, so the seal can't count` };
241
212
  }
242
- const r = verifySshSignature(listed, s.payload, seal.signature, s.namespace);
213
+ const r = s.namespace === GATE_SEAL_NAMESPACE ? verifyGate(listed, s.payload, seal.signature) : verifySshSignature(listed, s.payload, seal.signature, s.namespace);
243
214
  if (r.ok) return { attested: true, message: `sealed by ${r.principal}${r.key ? ` with ${r.key}` : ""}, a signer ${where} lists`, ...(r.key ? { key: r.key } : {}) };
244
215
  if (r.missing) return { attested: null, code: "seal-unverifiable", message: `the seal by ${s.principal} can't be checked here: ${r.reason}` };
245
216
  return { attested: false, code: "seal-signature-invalid", message: `the seal does not verify for ${s.principal} against ${where}: ${r.reason}` };
246
217
  }
247
218
 
219
+ /** A gate seal checked with node:crypto (#3785) against the keys `listed` for the approver in the gate namespace. */
220
+ function verifyGate(listed: Signer[], payload: Buffer, signature: string): { ok: true; principal: string; key: string; missing?: undefined } | { ok: false; missing?: false; reason: string } {
221
+ const keys = listed.filter((x) => namespaceAllowed(x.namespaces, GATE_SEAL_NAMESPACE)).map((x) => Buffer.from(x.key.split(" ")[1] ?? "", "base64"));
222
+ const r = verifySshSignatureWith(signature, payload, GATE_SEAL_NAMESPACE, keys);
223
+ return r.ok ? { ok: true, principal: listed[0]!.principal, key: r.key } : { ok: false, reason: r.reason };
224
+ }
225
+
248
226
  /** A seal checked with no signers file: is the signature over these bytes intact? */
249
227
  function checkIntegrity(principal: string, payload: Buffer, signature: string, namespace: string): SealCheck {
228
+ if (namespace === GATE_SEAL_NAMESPACE) {
229
+ // A gate seal is checked with node:crypto (#3785), against the key it carries.
230
+ const pk = sshSignatureKey(signature);
231
+ const r = pk ? verifySshSignatureWith(signature, payload, namespace, [pk]) : ({ ok: false, reason: "it is not an SSHSIG" } as const);
232
+ if (!r.ok) return { attested: false, code: "seal-signature-invalid", message: `the seal by ${principal} does not verify over this approval, even without a signers file` };
233
+ return { attested: null, code: "seal-unverifiable", message: `the signature is intact (${r.key}), and there is no signers file at base to say whose key it is`, key: r.key };
234
+ }
250
235
  const dir = mkdtempSync(join(tmpdir(), "chant-seal-"));
251
236
  try {
252
237
  const sigFile = join(dir, "signature");
@@ -0,0 +1,50 @@
1
+ /**
2
+ * #3763: the zod-free check of `.chant/trust.json` accepts exactly what the
3
+ * trust policy's zod schema accepts, so a bundled reader turns attestation
4
+ * off for the same files chant does.
5
+ */
6
+
7
+ import { describe, expect, test } from "vitest";
8
+ import { trustConfigSchema } from "./policy";
9
+ import { trustConfigProblems } from "./trust-files";
10
+
11
+ const commit = "a".repeat(40);
12
+ const ed = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOv1";
13
+
14
+ const cases: Array<[string, unknown]> = [
15
+ ["the smallest", { schema: 1 }],
16
+ ["everything", {
17
+ schema: 1,
18
+ signers: "ops/allowed_signers",
19
+ roles: { admin: ["alice"], "release-1": ["bob", "carol"] },
20
+ adopted: [{ to: commit }, { from: commit, to: "b".repeat(64), note: "import" }],
21
+ runners: [{ principal: "ci", class: "runner", key: ed }],
22
+ admitted: [{ return: "ret-0123456789ab", signers: [{ principal: "dana", key: "ssh-rsa AAAAB3Nza==" }], note: "n" }],
23
+ }],
24
+ ["schema 2", { schema: 2 }],
25
+ ["no schema", {}],
26
+ ["not an object", [1]],
27
+ ["null", null],
28
+ ["an unknown key", { schema: 1, extra: true }],
29
+ ["an absolute signers path", { schema: 1, signers: "/etc/signers" }],
30
+ ["a signers path with ..", { schema: 1, signers: "a/../b" }],
31
+ ["a signers path that is not normal", { schema: 1, signers: "./a" }],
32
+ ["a signers path with a backslash", { schema: 1, signers: "a\\b" }],
33
+ ["an empty signers path", { schema: 1, signers: "" }],
34
+ ["a role name with capitals", { schema: 1, roles: { Admin: ["a"] } }],
35
+ ["a role with an empty principal", { schema: 1, roles: { admin: [""] } }],
36
+ ["roles as a list", { schema: 1, roles: [] }],
37
+ ["a short adopted commit", { schema: 1, adopted: [{ to: "abc" }] }],
38
+ ["an adopted range with an extra key", { schema: 1, adopted: [{ to: commit, by: "x" }] }],
39
+ ["a runner with an rsa key", { schema: 1, runners: [{ principal: "ci", class: "runner", key: "ssh-rsa AAAA" }] }],
40
+ ["a runner of another class", { schema: 1, runners: [{ principal: "ci", class: "human", key: ed }] }],
41
+ ["an admission with no signers", { schema: 1, admitted: [{ return: "ret-0123456789ab", signers: [] }] }],
42
+ ["an admission with a bad id", { schema: 1, admitted: [{ return: "ret-1", signers: [{ principal: "d", key: ed }] }] }],
43
+ ["an admitted key with a comment", { schema: 1, admitted: [{ return: "ret-0123456789ab", signers: [{ principal: "d", key: `${ed} dana@laptop` }] }] }],
44
+ ];
45
+
46
+ describe("trustConfigProblems", () => {
47
+ test.each(cases)("agrees with the zod schema: %s", (_name, raw) => {
48
+ expect(trustConfigProblems(raw).length === 0).toBe(trustConfigSchema.safeParse(raw).success);
49
+ });
50
+ });
@@ -0,0 +1,245 @@
1
+ /**
2
+ * The trust files' plain readers (#3763): the signers file's parser, and a
3
+ * check of `.chant/trust.json` that says what `./policy.ts`'s zod schema
4
+ * says, so a bundle can read both without zod. `./policy.ts` re-exports the
5
+ * parser; `trust-files.test.ts` holds the check to the schema.
6
+ */
7
+
8
+ import { posix } from "node:path";
9
+
10
+ /** The trust config, relative to the repository root. Fixed: it is how the signers path is overridden. */
11
+ export const TRUST_CONFIG_PATH = ".chant/trust.json";
12
+
13
+ /** Where the signers are when `trust.json` names no other path. */
14
+ export const DEFAULT_SIGNERS_PATH = ".chant/allowed_signers";
15
+
16
+ // ── allowed_signers ──────────────────────────────────────────────────────────
17
+
18
+ /** Key types ssh-keygen accepts in an allowed_signers line. */
19
+ const KEY_TYPES = new Set([
20
+ "ssh-ed25519",
21
+ "ssh-rsa",
22
+ "ecdsa-sha2-nistp256",
23
+ "ecdsa-sha2-nistp384",
24
+ "ecdsa-sha2-nistp521",
25
+ "sk-ssh-ed25519@openssh.com",
26
+ "sk-ecdsa-sha2-nistp256@openssh.com",
27
+ ]);
28
+
29
+ /** One usable signer: a principal and the public key it signs with. */
30
+ export interface Signer {
31
+ principal: string;
32
+ /** `<type> <base64>`, with no comment. */
33
+ key: string;
34
+ /** The `namespaces="..."` option as written, or undefined when the key may sign any namespace. */
35
+ namespaces?: string;
36
+ /** 1-based line in the signers file. */
37
+ line: number;
38
+ }
39
+
40
+ /** A line of the signers file that is not used, and why. */
41
+ export interface ExcludedSigner {
42
+ line: number;
43
+ reason: string;
44
+ }
45
+
46
+ export interface SignerSet {
47
+ signers: Signer[];
48
+ excluded: ExcludedSigner[];
49
+ }
50
+
51
+ /**
52
+ * Split one line into fields. Double quotes group, so an option such as
53
+ * `namespaces="git,file"` stays one field.
54
+ */
55
+ function fields(line: string): string[] {
56
+ const out: string[] = [];
57
+ let cur = "";
58
+ let quoted = false;
59
+ let any = false;
60
+ for (const ch of line) {
61
+ if (ch === '"') {
62
+ quoted = !quoted;
63
+ cur += ch;
64
+ any = true;
65
+ } else if (!quoted && (ch === " " || ch === "\t")) {
66
+ if (any) out.push(cur);
67
+ cur = "";
68
+ any = false;
69
+ } else {
70
+ cur += ch;
71
+ any = true;
72
+ }
73
+ }
74
+ if (any) out.push(cur);
75
+ return out;
76
+ }
77
+
78
+ /** `a="x,y",b` as `{ a: "x,y", b: "" }`. */
79
+ function options(field: string): Map<string, string> {
80
+ const opts = new Map<string, string>();
81
+ let cur = "";
82
+ let quoted = false;
83
+ const flush = () => {
84
+ if (!cur) return;
85
+ const eq = cur.indexOf("=");
86
+ const name = (eq < 0 ? cur : cur.slice(0, eq)).toLowerCase();
87
+ const value = eq < 0 ? "" : cur.slice(eq + 1).replace(/^"|"$/g, "");
88
+ opts.set(name, value);
89
+ cur = "";
90
+ };
91
+ for (const ch of field) {
92
+ if (ch === '"') quoted = !quoted;
93
+ if (ch === "," && !quoted) flush();
94
+ else cur += ch;
95
+ }
96
+ flush();
97
+ return opts;
98
+ }
99
+
100
+ /**
101
+ * Parse an allowed_signers file, keeping only lines whose meaning does not
102
+ * depend on anything the signer controls.
103
+ *
104
+ * Refused, each with a reason, rather than passed to ssh-keygen:
105
+ *
106
+ * - `valid-after` and `valid-before`. git checks them against the commit's own
107
+ * date, which whoever makes the commit sets. A backdated commit would pass a
108
+ * window its key has left. Revocation is by position in history (#2553),
109
+ * never by date.
110
+ * - `cert-authority`. A certificate's validity is also a date, and a CA key
111
+ * vouches for keys this file never names.
112
+ * - Principal patterns (`*`, `?`, `!`). A signer is named by one exact
113
+ * principal, so role grants can name it.
114
+ */
115
+ export function parseAllowedSigners(text: string): SignerSet {
116
+ const signers: Signer[] = [];
117
+ const excluded: ExcludedSigner[] = [];
118
+ const lines = text.replace(/\r\n?/g, "\n").split("\n");
119
+ for (let i = 0; i < lines.length; i++) {
120
+ const line = i + 1;
121
+ const raw = lines[i].trim();
122
+ if (!raw || raw.startsWith("#")) continue;
123
+ const f = fields(raw);
124
+ if (f.length < 3) {
125
+ excluded.push({ line, reason: "expected principals, optional options, a key type and a key" });
126
+ continue;
127
+ }
128
+ const [principals, second] = f;
129
+ const hasOptions = !KEY_TYPES.has(second);
130
+ const type = hasOptions ? f[2] : second;
131
+ const blob = hasOptions ? f[3] : f[2];
132
+ if (!KEY_TYPES.has(type) || !blob || !/^[A-Za-z0-9+/]+={0,2}$/.test(blob)) {
133
+ excluded.push({ line, reason: `no supported public key (${type ?? "none"})` });
134
+ continue;
135
+ }
136
+ const opts = hasOptions ? options(second) : new Map<string, string>();
137
+ const timed = ["valid-after", "valid-before"].filter((o) => opts.has(o));
138
+ if (timed.length > 0) {
139
+ excluded.push({
140
+ line,
141
+ reason: `${timed.join(" and ")} is checked against the commit's own date, which its author sets; revoke a key by removing it`,
142
+ });
143
+ continue;
144
+ }
145
+ if (opts.has("cert-authority")) {
146
+ excluded.push({ line, reason: "cert-authority keys are not supported: a certificate's validity is a date the signer controls" });
147
+ continue;
148
+ }
149
+ const unknown = [...opts.keys()].filter((o) => o !== "namespaces");
150
+ if (unknown.length > 0) {
151
+ excluded.push({ line, reason: `unknown option ${unknown.join(", ")}` });
152
+ continue;
153
+ }
154
+ const names = principals.replace(/^"|"$/g, "").split(",").filter(Boolean);
155
+ const pattern = names.find((p) => /[*?!]/.test(p));
156
+ if (pattern !== undefined || names.length === 0) {
157
+ excluded.push({ line, reason: `principal ${JSON.stringify(pattern ?? principals)} is a pattern; name one signer exactly` });
158
+ continue;
159
+ }
160
+ for (const principal of names) {
161
+ signers.push({ principal, key: `${type} ${blob}`, namespaces: opts.get("namespaces"), line });
162
+ }
163
+ }
164
+ return { signers, excluded };
165
+ }
166
+
167
+ /** The signers as an allowed_signers file ssh-keygen reads, holding only the usable lines. */
168
+ export function renderAllowedSigners(signers: Signer[]): string {
169
+ return (
170
+ signers
171
+ .map((s) => `${JSON.stringify(s.principal)}${s.namespaces !== undefined ? ` namespaces=${JSON.stringify(s.namespaces)}` : ""} ${s.key}`)
172
+ .join("\n") + "\n"
173
+ );
174
+ }
175
+
176
+ // ── trust.json, without zod ──────────────────────────────────────────────────
177
+
178
+ const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
179
+ const FULL_COMMIT = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
180
+ const SSH_ED25519 = /^ssh-ed25519 [A-Za-z0-9+/]+={0,2}$/;
181
+ const SSH_KEY = /^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp(256|384|521)|sk-ssh-ed25519@openssh\.com|sk-ecdsa-sha2-nistp256@openssh\.com) [A-Za-z0-9+/]+={0,2}$/;
182
+
183
+ /** A repository-relative path: no `..`, not absolute, `/` separators. */
184
+ export function isRepoPath(p: unknown): p is string {
185
+ return typeof p === "string" && p.length > 0 && !p.startsWith("/") && !p.includes("\\") && posix.normalize(p) === p && !p.split("/").includes("..");
186
+ }
187
+
188
+ /** Whether `o` holds only `keys`, as zod's `.strict()` requires. */
189
+ const only = (o: Record<string, unknown>, keys: string[]): boolean => Object.keys(o).every((k) => keys.includes(k));
190
+ const optional = (v: unknown, ok: (v: unknown) => boolean): boolean => v === undefined || ok(v);
191
+ const nonEmpty = (v: unknown): boolean => typeof v === "string" && v.length > 0;
192
+
193
+ /**
194
+ * What is wrong with a parsed `.chant/trust.json`, one line per field, or an
195
+ * empty list when it is valid. It accepts exactly what `trustConfigSchema`
196
+ * in `./policy.ts` accepts, so a reader without zod treats a file as chant
197
+ * does: an invalid one turns attestation off.
198
+ */
199
+ export function trustConfigProblems(raw: unknown): string[] {
200
+ if (!isObject(raw)) return ["/: expected an object"];
201
+ const problems: string[] = [];
202
+ const check = (field: string, ok: boolean, message: string) => {
203
+ if (!ok) problems.push(`${field}: ${message}`);
204
+ };
205
+ check("/", only(raw, ["schema", "signers", "roles", "adopted", "runners", "admitted"]), "unknown key");
206
+ check("schema", raw.schema === 1, "expected 1");
207
+ check("signers", optional(raw.signers, isRepoPath), "a path inside the repository, with / separators and no ..");
208
+ check(
209
+ "roles",
210
+ optional(raw.roles, (r) => isObject(r) && Object.entries(r).every(([k, v]) => /^[a-z][a-z0-9-]*$/.test(k) && Array.isArray(v) && v.every(nonEmpty))),
211
+ "role names to lists of principals",
212
+ );
213
+ check(
214
+ "adopted",
215
+ optional(raw.adopted, (a) =>
216
+ Array.isArray(a) &&
217
+ a.every((e) => isObject(e) && only(e, ["from", "to", "note"]) && optional(e.from, (f) => typeof f === "string" && FULL_COMMIT.test(f)) && typeof e.to === "string" && FULL_COMMIT.test(e.to) && optional(e.note, (n) => typeof n === "string")),
218
+ ),
219
+ "commit ranges of full commit ids",
220
+ );
221
+ check(
222
+ "runners",
223
+ optional(raw.runners, (a) =>
224
+ Array.isArray(a) &&
225
+ a.every((e) => isObject(e) && only(e, ["principal", "class", "key"]) && nonEmpty(e.principal) && (e.class === "runner" || e.class === "service") && typeof e.key === "string" && SSH_ED25519.test(e.key)),
226
+ ),
227
+ "runner keys: a principal, a class and an ssh-ed25519 key",
228
+ );
229
+ check(
230
+ "admitted",
231
+ optional(raw.admitted, (a) =>
232
+ Array.isArray(a) &&
233
+ a.every(
234
+ (e) =>
235
+ isObject(e) && only(e, ["return", "signers", "note"]) &&
236
+ typeof e.return === "string" && /^ret-[0-9a-f]{12}$/.test(e.return) &&
237
+ optional(e.note, (n) => typeof n === "string") &&
238
+ Array.isArray(e.signers) && e.signers.length > 0 &&
239
+ e.signers.every((s) => isObject(s) && only(s, ["principal", "key"]) && nonEmpty(s.principal) && typeof s.key === "string" && SSH_KEY.test(s.key)),
240
+ ),
241
+ ),
242
+ "admitted signers, by return id",
243
+ );
244
+ return problems;
245
+ }
@@ -39,7 +39,8 @@ import {
39
39
  releaseWorkLease,
40
40
  renewWorkLease,
41
41
  workLeaseRef,
42
- WORK_ITEM_ID_PATTERN,
42
+ isWorkItemId,
43
+ workItemIdProblem,
43
44
  WORK_LEASE_OUTCOMES,
44
45
  type KeptAttempt,
45
46
  type WorkLease,
@@ -203,7 +204,8 @@ export interface WorkLeaseRequest {
203
204
  export async function workLease(req: WorkLeaseRequest): Promise<WorkLeaseDocument> {
204
205
  const doc = { $schema: WORK_LEASE_OUTPUT_SCHEMA_ID, contract: WORK_LEASE_CONTRACT_VERSION };
205
206
  try {
206
- if (!WORK_ITEM_ID_PATTERN.test(req.id) || req.id.includes("..")) throw new WorkError("write-usage-invalid", `${JSON.stringify(req.id)} is not a work item id`);
207
+ const idProblem = workItemIdProblem(req.id);
208
+ if (idProblem) throw new WorkError("write-usage-invalid", idProblem);
207
209
  if (req.outcome !== undefined && !isWorkLeaseOutcome(req.outcome)) {
208
210
  throw new WorkError("write-usage-invalid", `${JSON.stringify(req.outcome)} is not a lease outcome: use one of ${WORK_LEASE_OUTCOMES.join(", ")}`);
209
211
  }
@@ -381,7 +383,7 @@ export function attemptsOf(claims: readonly LeaseClaim[], limit: number | null):
381
383
  export async function workHistory(req: { id: string; cwd: string; kind?: string }): Promise<WorkHistoryDocument> {
382
384
  const head = { $schema: WORK_HISTORY_OUTPUT_SCHEMA_ID, contract: WORK_HISTORY_CONTRACT_VERSION, chant: readerVersion() };
383
385
  try {
384
- if (!WORK_ITEM_ID_PATTERN.test(req.id) || req.id.includes("..")) throw new WorkError("work-item-unknown", `${JSON.stringify(req.id)} is not a work item id`);
386
+ if (!isWorkItemId(req.id)) throw new WorkError("work-item-unknown", `${JSON.stringify(req.id)} is not a work item id`);
385
387
  if (!gitRoot(req.cwd)) throw new WorkError("not-a-git-repository", "a work lease's history is on the chant/lifecycle branch, and this directory is not in a git repository");
386
388
  const { loaded, root, record } = await findWorkItem(req.id, req.cwd, req.kind);
387
389
  const leaseCwd = dirname(loaded.file);
@@ -138,6 +138,13 @@ describe("workspace work history (#2785)", () => {
138
138
  expect(formatWorkHistory(doc)).toContain(`kept: ${kept}`);
139
139
  });
140
140
 
141
+ test("an id git rejects as a ref name is refused before any git call (#2815)", async () => {
142
+ for (const id of ["upgrade-.", "x.lock", "a..b"]) {
143
+ const doc = await workLease({ verb: "claim", id, holder: "worker-b", cwd: root });
144
+ expect(doc).toMatchObject({ error: { code: "write-usage-invalid" } });
145
+ }
146
+ });
147
+
141
148
  test("a release outside the closed list of outcomes is refused (#3147)", async () => {
142
149
  await expect(releaseWorkLease("W-002", "worker-b", { cwd: kindDir, outcome: "skipped" })).rejects.toThrow(/not a work lease outcome/);
143
150
  const doc = await workLease({ verb: "release", id: "W-002", holder: "worker-b", cwd: root, outcome: "skipped" });