@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,207 @@
1
+ /**
2
+ * Runner evidence (#2553, #2524 D5 "Evidence").
3
+ *
4
+ * A runner, meaning a CI job or a service, states that a check ran over a
5
+ * set of records at one commit. The statement is an in-toto Statement v1:
6
+ * each record is a subject, named by its path and hashed, and the predicate
7
+ * binds the commit, its tree, the check, and the digests of the claim and of
8
+ * the environment when given. The runner signs it in a DSSE envelope with its
9
+ * own Ed25519 key.
10
+ *
11
+ * Verifying needs only the envelope, the repository's objects and the runner
12
+ * keys the policy at base lists, so it works offline. Evidence is never
13
+ * reused on trust: a verdict reports, subject by subject, whether the records
14
+ * still hash the same, and evidence for records that changed is `stale`.
15
+ */
16
+
17
+ import { createHash } from "node:crypto";
18
+ import { execFileSync } from "node:child_process";
19
+ import { z } from "zod";
20
+ import { canonicalJson } from "../../effect-receipt";
21
+ import type { ReasonCode } from "../reason-codes";
22
+ import { IN_TOTO_PAYLOAD_TYPE, signEnvelope, verifyEnvelope, type DsseEnvelope } from "./dsse";
23
+ import type { KeyObject } from "node:crypto";
24
+ import type { RunnerKey } from "./policy";
25
+
26
+ export const STATEMENT_TYPE = "https://in-toto.io/Statement/v1";
27
+ export const RUNNER_EVIDENCE_PREDICATE = "https://intentius.io/chant/runner-evidence/v1";
28
+
29
+ /**
30
+ * Why evidence is refused, by `evidence sign` or `evidence verify` (#2553).
31
+ * Each is in `reason-codes.ts` and in `evidence.schema.json`. A sign or verify
32
+ * that can't read the record kind fails with the records read's own code.
33
+ */
34
+ export const EVIDENCE_ERROR_CODES = [
35
+ "not-a-git-repository",
36
+ "revision-unknown",
37
+ "trust-policy-unreadable",
38
+ "envelope-unreadable",
39
+ "envelope-invalid",
40
+ "envelope-untrusted",
41
+ "evidence-payload-type",
42
+ "evidence-statement-invalid",
43
+ "evidence-runner-mismatch",
44
+ "runner-key-invalid",
45
+ "runner-key-is-signer",
46
+ "runner-key-unlisted",
47
+ "kind-unreadable",
48
+ "kind-invalid",
49
+ "schema-unreadable",
50
+ "schema-id-mismatch",
51
+ "schema-invalid",
52
+ "location-missing",
53
+ ] as const satisfies readonly ReasonCode[];
54
+ export type EvidenceErrorCode = (typeof EVIDENCE_ERROR_CODES)[number];
55
+
56
+ const sha256Hex = z.string().regex(/^[0-9a-f]{64}$/);
57
+ const fullCommit = z.string().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/);
58
+
59
+ /** The statement a runner signs. Strict, so a verifier never acts on a field it does not know. */
60
+ export const evidenceStatementSchema = z
61
+ .object({
62
+ _type: z.literal(STATEMENT_TYPE),
63
+ subject: z
64
+ .array(
65
+ z
66
+ .object({
67
+ name: z
68
+ .string()
69
+ .min(1)
70
+ .refine((n) => !n.startsWith("/") && !n.split("/").includes(".."), "a path inside the repository"),
71
+ digest: z.object({ sha256: sha256Hex }).strict(),
72
+ })
73
+ .strict(),
74
+ ),
75
+ predicateType: z.literal(RUNNER_EVIDENCE_PREDICATE),
76
+ predicate: z
77
+ .object({
78
+ runner: z.string().min(1),
79
+ commit: fullCommit,
80
+ tree: fullCommit,
81
+ check: z.string().min(1),
82
+ claim: z.object({ sha256: sha256Hex }).strict().optional(),
83
+ environment: z.object({ sha256: sha256Hex }).strict().optional(),
84
+ })
85
+ .strict(),
86
+ })
87
+ .strict();
88
+ export type EvidenceStatement = z.infer<typeof evidenceStatementSchema>;
89
+
90
+ /** SHA-256 of a record's text, line endings normalised as seals do (#2524 D4). */
91
+ export function recordHash(text: string): string {
92
+ return createHash("sha256").update(text.replace(/\r\n?/g, "\n"), "utf8").digest("hex");
93
+ }
94
+
95
+ function sha256(bytes: Buffer | string): string {
96
+ return createHash("sha256").update(bytes).digest("hex");
97
+ }
98
+
99
+ function git(repo: string, args: string[]): string {
100
+ return execFileSync("git", args, { cwd: repo, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 256 * 1024 * 1024 });
101
+ }
102
+
103
+ function blobAt(repo: string, commit: string, path: string): string | undefined {
104
+ try {
105
+ return git(repo, ["cat-file", "blob", `${commit}:${path}`]);
106
+ } catch {
107
+ return undefined;
108
+ }
109
+ }
110
+
111
+ export interface EvidenceInput {
112
+ repo: string;
113
+ /** The full commit id the check ran at. Records are hashed as committed there. */
114
+ commit: string;
115
+ paths: string[];
116
+ runner: string;
117
+ check: string;
118
+ claim?: Buffer;
119
+ environment?: Buffer;
120
+ }
121
+
122
+ export function buildStatement(input: EvidenceInput): EvidenceStatement {
123
+ const subject = [...input.paths].sort().map((name) => {
124
+ const text = blobAt(input.repo, input.commit, name);
125
+ if (text === undefined) throw new Error(`${name} is not in commit ${input.commit.slice(0, 8)}`);
126
+ return { name, digest: { sha256: recordHash(text) } };
127
+ });
128
+ return {
129
+ _type: STATEMENT_TYPE,
130
+ subject,
131
+ predicateType: RUNNER_EVIDENCE_PREDICATE,
132
+ predicate: {
133
+ runner: input.runner,
134
+ commit: input.commit,
135
+ tree: git(input.repo, ["rev-parse", `${input.commit}^{tree}`]).trim(),
136
+ check: input.check,
137
+ ...(input.claim ? { claim: { sha256: sha256(input.claim) } } : {}),
138
+ ...(input.environment ? { environment: { sha256: sha256(input.environment) } } : {}),
139
+ },
140
+ };
141
+ }
142
+
143
+ export function signEvidence(statement: EvidenceStatement, key: KeyObject, publicKey: string): DsseEnvelope {
144
+ return signEnvelope(IN_TOTO_PAYLOAD_TYPE, Buffer.from(canonicalJson(statement), "utf8"), key, publicKey);
145
+ }
146
+
147
+ export interface SubjectVerdict {
148
+ name: string;
149
+ signed: string;
150
+ /** The hash at the revision checked, or null when the record is not there. */
151
+ now: string | null;
152
+ matches: boolean;
153
+ }
154
+
155
+ export type EvidenceVerdict =
156
+ | {
157
+ ok: true;
158
+ /** `current` when every subject still hashes the same at the revision checked; otherwise `stale`. */
159
+ status: "current" | "stale";
160
+ runner: string;
161
+ class: RunnerKey["class"];
162
+ keyid: string;
163
+ statement: EvidenceStatement;
164
+ subjects: SubjectVerdict[];
165
+ }
166
+ | { ok: false; code: EvidenceErrorCode; reason: string };
167
+
168
+ /**
169
+ * Verify an envelope against the runner keys at base, then compare each
170
+ * subject with the record as committed at `at`.
171
+ */
172
+ export function verifyEvidence(envelope: unknown, runners: readonly RunnerKey[], repo: string, at: string): EvidenceVerdict {
173
+ const v = verifyEnvelope(envelope, runners);
174
+ if (!v.ok) return v;
175
+ if (v.payloadType !== IN_TOTO_PAYLOAD_TYPE) return { ok: false, code: "evidence-payload-type", reason: `payload type ${v.payloadType} is not ${IN_TOTO_PAYLOAD_TYPE}` };
176
+ let raw: unknown;
177
+ try {
178
+ raw = JSON.parse(v.payload.toString("utf8"));
179
+ } catch {
180
+ return { ok: false, code: "evidence-statement-invalid", reason: "the payload is not JSON" };
181
+ }
182
+ const parsed = evidenceStatementSchema.safeParse(raw);
183
+ if (!parsed.success) {
184
+ const detail = parsed.error.issues.map((i) => `${i.path.join(".") || "/"}: ${i.message}`).join("; ");
185
+ return { ok: false, code: "evidence-statement-invalid", reason: `the payload is not a ${RUNNER_EVIDENCE_PREDICATE} statement: ${detail}` };
186
+ }
187
+ const statement = parsed.data;
188
+ // The runner named inside must be the one whose key signed: a runner cannot speak for another.
189
+ if (statement.predicate.runner !== v.principal) {
190
+ return { ok: false, code: "evidence-runner-mismatch", reason: `the statement names runner ${JSON.stringify(statement.predicate.runner)}, but ${v.principal}'s key signed it` };
191
+ }
192
+ const subjects = statement.subject.map((s) => {
193
+ const text = blobAt(repo, at, s.name);
194
+ const now = text === undefined ? null : recordHash(text);
195
+ return { name: s.name, signed: s.digest.sha256, now, matches: now !== null && now === s.digest.sha256 };
196
+ });
197
+ const runner = runners.find((r) => r.principal === v.principal)!;
198
+ return {
199
+ ok: true,
200
+ status: subjects.every((s) => s.matches) ? "current" : "stale",
201
+ runner: v.principal,
202
+ class: runner.class,
203
+ keyid: v.keyid,
204
+ statement,
205
+ subjects,
206
+ };
207
+ }
@@ -219,6 +219,49 @@ export const trustConfigSchema = z
219
219
  * the commits `from..to`.
220
220
  */
221
221
  adopted: z.array(z.object({ from: fullCommit.optional(), to: fullCommit, note: z.string().optional() }).strict()).optional(),
222
+ /**
223
+ * Keys that sign runner evidence (#2553). Each belongs to a service or a CI
224
+ * identity, never to a person: a key or principal the signers file lists
225
+ * is refused here.
226
+ */
227
+ runners: z
228
+ .array(
229
+ z
230
+ .object({
231
+ principal: z.string().min(1),
232
+ class: z.enum(["runner", "service"]),
233
+ key: z.string().regex(/^ssh-ed25519 [A-Za-z0-9+/]+={0,2}$/, "an ssh-ed25519 public key, with no comment"),
234
+ })
235
+ .strict(),
236
+ )
237
+ .optional(),
238
+ /**
239
+ * Signers admitted for the work one return brought back (#2552, D10,
240
+ * ws-004). Each entry names the return by its id (the file
241
+ * `.chant/returns/<id>.json` the import wrote) and the keys it admits.
242
+ * An admitted key verifies only the returned commits and seals of that
243
+ * return, never a commit made in this repository.
244
+ */
245
+ admitted: z
246
+ .array(
247
+ z
248
+ .object({
249
+ return: z.string().regex(/^ret-[0-9a-f]{12}$/, "a return id, ret-<12 hex>"),
250
+ signers: z
251
+ .array(
252
+ z
253
+ .object({
254
+ principal: z.string().min(1),
255
+ key: z.string().regex(/^(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}$/, "an ssh public key, with no comment"),
256
+ })
257
+ .strict(),
258
+ )
259
+ .min(1),
260
+ note: z.string().optional(),
261
+ })
262
+ .strict(),
263
+ )
264
+ .optional(),
222
265
  })
223
266
  .strict();
224
267
 
@@ -227,6 +270,14 @@ export type TrustConfig = z.infer<typeof trustConfigSchema>;
227
270
  /** The role whose holders may change the policy. Without any grant of it, every signer may. */
228
271
  export const ADMIN_ROLE = "admin";
229
272
 
273
+ /** A key that signs runner evidence: a service or CI identity. */
274
+ export interface RunnerKey {
275
+ principal: string;
276
+ class: "runner" | "service";
277
+ /** `ssh-ed25519 <base64>`. */
278
+ key: string;
279
+ }
280
+
230
281
  /** The policy a check applies, as read at base. */
231
282
  export interface TrustPolicy {
232
283
  /** The full commit id the policy was read at, or null when there is no base. */
@@ -239,13 +290,32 @@ export interface TrustPolicy {
239
290
  excluded: ExcludedSigner[];
240
291
  roles: Record<string, string[]>;
241
292
  adopted: Array<{ from?: string; to: string }>;
293
+ /** Runner and service keys that may sign runner evidence (#2553). */
294
+ runners: RunnerKey[];
295
+ /** Runner entries that are not used, and why. */
296
+ excludedRunners: Array<{ principal: string; reason: string }>;
297
+ /** Signers admitted per return (#2552), keyed by return id. They verify that return's commits and seals only. */
298
+ admitted: Record<string, Signer[]>;
299
+ /**
300
+ * Set on a copy of the policy used for one returned record (#2552): the
301
+ * return it came back in. A seal by a principal the policy does not list
302
+ * then reads as unverifiable rather than unlisted, until an admin admits
303
+ * the signer. Never set on the policy read at base.
304
+ */
305
+ returnedFrom?: string;
242
306
  /** Why the policy could not be read, when it could not. Nothing verifies then. */
243
307
  problems: string[];
308
+ /**
309
+ * The signer set in effect at a commit's position in the base's history
310
+ * (#2553). Absent, every commit is judged by `signers`.
311
+ */
312
+ signersAt?: (commit: string) => { version: number; signers: Signer[] } | null;
244
313
  }
245
314
 
246
- /** Paths whose change is a protected write under `policy`. */
315
+ /** Paths whose change is a protected write under `policy`: the config, the signers file and its rotation file (#2553). */
247
316
  export function protectedPaths(policy: TrustPolicy): string[] {
248
- return [...new Set([TRUST_CONFIG_PATH, policy.signersPath])].sort();
317
+ const s = policy.signersPath;
318
+ return [...new Set([TRUST_CONFIG_PATH, s, posix.join(posix.dirname(s), `${posix.basename(s)}.rotation.json`)])].sort();
249
319
  }
250
320
 
251
321
  /** Principals allowed to change the policy: the admins when any are granted, otherwise every signer. */
@@ -257,7 +327,7 @@ export function policyWriters(policy: TrustPolicy): Set<string> {
257
327
 
258
328
  /** A policy with nothing in it: attestation off. */
259
329
  export function emptyPolicy(base: string | null, problems: string[] = []): TrustPolicy {
260
- return { base, signersPath: DEFAULT_SIGNERS_PATH, active: false, signers: [], excluded: [], roles: {}, adopted: [], problems };
330
+ return { base, signersPath: DEFAULT_SIGNERS_PATH, active: false, signers: [], excluded: [], roles: {}, adopted: [], runners: [], excludedRunners: [], admitted: {}, problems };
261
331
  }
262
332
 
263
333
  /**
@@ -285,6 +355,22 @@ export function readTrustPolicy(source: RecordSource, base: string | null): Trus
285
355
  const signersPath = config.signers ?? DEFAULT_SIGNERS_PATH;
286
356
  const text = readOptional(source, signersPath);
287
357
  const set = text === undefined ? { signers: [], excluded: [] } : parseAllowedSigners(text);
358
+ // A runner key is a machine's, never a person's (#2553).
359
+ const humanKeys = new Set(set.signers.map((s) => s.key));
360
+ const humanNames = new Set(set.signers.map((s) => s.principal));
361
+ const runners: RunnerKey[] = [];
362
+ const excludedRunners: Array<{ principal: string; reason: string }> = [];
363
+ const all = config.runners ?? [];
364
+ for (const r of all) {
365
+ if (humanKeys.has(r.key) || humanNames.has(r.principal)) {
366
+ excludedRunners.push({ principal: r.principal, reason: `${signersPath} lists this ${humanKeys.has(r.key) ? "key" : "principal"}; runner keys belong to a service or CI identity, never to a signer` });
367
+ } else if (all.filter((o) => o.key === r.key || o.principal === r.principal).length > 1) {
368
+ // One key under two names, or one name with two entries, would leave it unclear who signed.
369
+ excludedRunners.push({ principal: r.principal, reason: "another runner entry has the same key or principal; each runner is one principal with one key" });
370
+ } else {
371
+ runners.push(r);
372
+ }
373
+ }
288
374
  return {
289
375
  base,
290
376
  signersPath,
@@ -293,10 +379,31 @@ export function readTrustPolicy(source: RecordSource, base: string | null): Trus
293
379
  excluded: set.excluded,
294
380
  roles: config.roles ?? {},
295
381
  adopted: config.adopted ?? [],
382
+ runners,
383
+ excludedRunners,
384
+ admitted: admittedOf(config),
296
385
  problems: [],
297
386
  };
298
387
  }
299
388
 
389
+ /** The admitted signers, by return id. Two entries for one return add up. */
390
+ function admittedOf(config: TrustConfig): Record<string, Signer[]> {
391
+ const out: Record<string, Signer[]> = {};
392
+ for (const a of config.admitted ?? []) {
393
+ out[a.return] = [...(out[a.return] ?? []), ...a.signers.map((s) => ({ principal: s.principal, key: s.key, line: 0 }))];
394
+ }
395
+ return out;
396
+ }
397
+
398
+ /**
399
+ * The policy for work that came back in return `id` (#2552): the signers at
400
+ * base plus the ones admitted for that return, marked with the return so a
401
+ * seal by an unknown signer reads as unverifiable.
402
+ */
403
+ export function returnedPolicy(policy: TrustPolicy, id: string): TrustPolicy {
404
+ return { ...policy, signers: [...policy.signers, ...(policy.admitted[id] ?? [])], returnedFrom: id };
405
+ }
406
+
300
407
  /** A file's text through `source`, or undefined when it is not there. */
301
408
  export function readOptional(source: RecordSource, path: string): string | undefined {
302
409
  const dir = posix.dirname(path);
@@ -15,6 +15,7 @@ import { execFileSync } from "node:child_process";
15
15
  import { gitRevisionSource } from "../record-source";
16
16
  import { attestCommit, type CommitAttestor, type ProvenanceLevel } from "./attestor";
17
17
  import { emptyPolicy, readTrustPolicy, type TrustPolicy } from "./policy";
18
+ import { SignerPositions, signerHistory } from "./rotation";
18
19
 
19
20
  /** What `records` reports for one record. */
20
21
  export interface RecordProvenance {
@@ -25,6 +26,12 @@ export interface RecordProvenance {
25
26
  principal?: string;
26
27
  key?: string;
27
28
  reason: string;
29
+ /**
30
+ * Set when the content came back in a return (#2552): the return's id, the
31
+ * commit in the returned copy that the level judges, and the commit here
32
+ * that holds the content. `commit` above is that commit here.
33
+ */
34
+ returned?: { id: string; commit?: string; importedIn: string | null };
28
35
  }
29
36
 
30
37
  /** Where the base revision came from. */
@@ -70,10 +77,22 @@ export function resolveBase(repo: string, explicit?: string): ResolvedBase {
70
77
  return { commit: null, from: null, problem: "no base revision: pass --base <rev> (there is no origin/HEAD, main or master)" };
71
78
  }
72
79
 
73
- /** The policy at `base`, or an inactive one when there is no base. */
80
+ /**
81
+ * The policy at `base`, or an inactive one when there is no base. With a
82
+ * signers file, its history is verified too (#2553): every version must be
83
+ * signed by a threshold of the one before, or nothing verifies.
84
+ */
74
85
  export function policyAtBase(repo: string, base: ResolvedBase): TrustPolicy {
75
86
  if (!base.commit) return emptyPolicy(null, base.problem ? [base.problem] : []);
76
- return readTrustPolicy(gitRevisionSource(repo, base.commit), base.commit);
87
+ const policy = readTrustPolicy(gitRevisionSource(repo, base.commit), base.commit);
88
+ if (!policy.active || policy.problems.length > 0) return policy;
89
+ const history = signerHistory(repo, base.commit, policy.signersPath);
90
+ if (history.broken) {
91
+ const at = history.broken.commit ? ` at ${history.broken.commit.slice(0, 8)}` : "";
92
+ return { ...policy, problems: [`the signer history of ${policy.signersPath} is broken${at}: ${history.broken.reason}`] };
93
+ }
94
+ const positions = new SignerPositions(repo, base.commit, history);
95
+ return { ...policy, signersAt: (commit) => positions.versionFor(commit) };
77
96
  }
78
97
 
79
98
  /**
@@ -126,7 +145,18 @@ export function isAdopted(repo: string, policy: TrustPolicy, commit: string): bo
126
145
 
127
146
  /** A commit's provenance level under `policy`. */
128
147
  export function commitProvenance(repo: string, policy: TrustPolicy, commit: string, attestors: readonly CommitAttestor[]): RecordProvenance {
129
- const a = attestCommit({ repo, policy }, commit, attestors);
148
+ // Judge the commit by the signer set in effect where it entered the base's history (#2553).
149
+ let judged = policy;
150
+ if (policy.signersAt) {
151
+ const v = policy.signersAt(commit);
152
+ judged = { ...policy, signers: v?.signers ?? [] };
153
+ if (!v || v.version === 0) {
154
+ const none = "no signer set was in effect where this commit entered the base's history";
155
+ if (isAdopted(repo, policy, commit)) return { level: "adopted", commit, reason: `in a commit range the policy at base adopts; ${none}` };
156
+ return { level: "unattested", commit, reason: none };
157
+ }
158
+ }
159
+ const a = attestCommit({ repo, policy: judged }, commit, attestors);
130
160
  if (a.level === "unattested" && isAdopted(repo, policy, commit)) {
131
161
  return { level: "adopted", commit, attestor: a.attestor, reason: `in a commit range the policy at base adopts; ${a.reason}` };
132
162
  }
@@ -147,6 +177,12 @@ export interface ProvenanceQuery {
147
177
  at: string | null;
148
178
  paths: string[];
149
179
  attestors: readonly CommitAttestor[];
180
+ /**
181
+ * A second look at each committed path, given the provenance of the commit
182
+ * that holds it here. Returned work (#2552) uses it to judge the commit the
183
+ * content was made in, which a return carries. Undefined keeps the answer.
184
+ */
185
+ revisit?: (path: string, host: RecordProvenance) => RecordProvenance | undefined;
150
186
  }
151
187
 
152
188
  /**
@@ -175,7 +211,8 @@ export function recordProvenance(q: ProvenanceQuery): Map<string, RecordProvenan
175
211
  out.set(p, { level: "unattested", commit: null, reason: "no commit holds this file" });
176
212
  continue;
177
213
  }
178
- out.set(p, commitProvenance(q.repo, q.policy, c, q.attestors));
214
+ const host = commitProvenance(q.repo, q.policy, c, q.attestors);
215
+ out.set(p, q.revisit?.(p, host) ?? host);
179
216
  }
180
217
  return out;
181
218
  }
@@ -221,7 +221,7 @@ describe.skipIf(!hasSshKeygen)("author seals under a signers file at base", () =
221
221
  const digest = (await record(r)).digest;
222
222
  const doc = await amend(r, { state: "superseded" });
223
223
  if ("error" in doc) throw new Error(doc.error.message);
224
- expect(doc.changed).toEqual(["state", "seal"]);
224
+ expect(doc.changed).toEqual(["state", "seal", "closed_digest"]);
225
225
  expect(doc.sealDropped).toMatch(/moves its digest or its state/);
226
226
  expect((await record(r)).digest).toBe(digest);
227
227
  });