@intentius/chant 0.124.40 → 0.124.41

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 (281) hide show
  1. package/dist/__fixtures__/oidc-emulator.d.ts +49 -0
  2. package/dist/__fixtures__/oidc-emulator.d.ts.map +1 -0
  3. package/dist/__fixtures__/oidc-emulator.js +89 -0
  4. package/dist/ci-handover.d.ts +117 -0
  5. package/dist/ci-handover.d.ts.map +1 -0
  6. package/dist/ci-handover.js +217 -0
  7. package/dist/ci-identity.d.ts +190 -0
  8. package/dist/ci-identity.d.ts.map +1 -0
  9. package/dist/ci-identity.js +219 -0
  10. package/dist/cli/handlers/approve.d.ts +2 -1
  11. package/dist/cli/handlers/approve.d.ts.map +1 -1
  12. package/dist/cli/handlers/approve.js +20 -6
  13. package/dist/cli/handlers/build.d.ts.map +1 -1
  14. package/dist/cli/handlers/build.js +15 -1
  15. package/dist/cli/handlers/fan-out.js +16 -3
  16. package/dist/cli/handlers/gate-resume.d.ts +3 -0
  17. package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
  18. package/dist/cli/handlers/gate-resume.js +15 -2
  19. package/dist/cli/handlers/lock.d.ts +8 -0
  20. package/dist/cli/handlers/lock.d.ts.map +1 -0
  21. package/dist/cli/handlers/lock.js +197 -0
  22. package/dist/cli/handlers/pr-env.d.ts +34 -0
  23. package/dist/cli/handlers/pr-env.d.ts.map +1 -0
  24. package/dist/cli/handlers/pr-env.js +436 -0
  25. package/dist/cli/handlers/pr.d.ts +74 -0
  26. package/dist/cli/handlers/pr.d.ts.map +1 -1
  27. package/dist/cli/handlers/pr.js +503 -53
  28. package/dist/cli/handlers/promote.d.ts.map +1 -1
  29. package/dist/cli/handlers/promote.js +9 -0
  30. package/dist/cli/handlers/query.d.ts +6 -0
  31. package/dist/cli/handlers/query.d.ts.map +1 -0
  32. package/dist/cli/handlers/query.js +80 -0
  33. package/dist/cli/main.d.ts.map +1 -1
  34. package/dist/cli/main.js +41 -2
  35. package/dist/cli/parse-args.d.ts.map +1 -1
  36. package/dist/cli/parse-args.js +31 -2
  37. package/dist/cli/registry.d.ts +12 -2
  38. package/dist/cli/registry.d.ts.map +1 -1
  39. package/dist/components/fan-out-run.d.ts +28 -0
  40. package/dist/components/fan-out-run.d.ts.map +1 -1
  41. package/dist/components/fan-out-run.js +27 -3
  42. package/dist/components/pr-environment.d.ts +298 -0
  43. package/dist/components/pr-environment.d.ts.map +1 -0
  44. package/dist/components/pr-environment.js +406 -0
  45. package/dist/components/pr-hooks.d.ts +66 -0
  46. package/dist/components/pr-hooks.d.ts.map +1 -0
  47. package/dist/components/pr-hooks.js +119 -0
  48. package/dist/components/pr-pipeline.d.ts +72 -2
  49. package/dist/components/pr-pipeline.d.ts.map +1 -1
  50. package/dist/components/pr-pipeline.js +96 -5
  51. package/dist/components/pr-policy.d.ts +109 -0
  52. package/dist/components/pr-policy.d.ts.map +1 -0
  53. package/dist/components/pr-policy.js +150 -0
  54. package/dist/components/pr-run.d.ts.map +1 -1
  55. package/dist/components/pr-run.js +5 -3
  56. package/dist/gate-decision.d.ts +15 -2
  57. package/dist/gate-decision.d.ts.map +1 -1
  58. package/dist/gate-decision.js +17 -2
  59. package/dist/gate-policy.d.ts +140 -0
  60. package/dist/gate-policy.d.ts.map +1 -0
  61. package/dist/gate-policy.js +242 -0
  62. package/dist/lifecycle/gate-checkpoint.d.ts +1 -1
  63. package/dist/lifecycle/gate-checkpoint.js +1 -1
  64. package/dist/{workspace/trust → lifecycle}/gate-seal.d.ts +1 -1
  65. package/dist/lifecycle/gate-seal.d.ts.map +1 -0
  66. package/dist/lifecycle/query.d.ts +68 -0
  67. package/dist/lifecycle/query.d.ts.map +1 -0
  68. package/dist/lifecycle/query.js +246 -0
  69. package/dist/lifecycle/run-ledger.d.ts +13 -0
  70. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  71. package/dist/lifecycle/run-ledger.js +16 -0
  72. package/dist/lifecycle/target-lock.d.ts +97 -0
  73. package/dist/lifecycle/target-lock.d.ts.map +1 -0
  74. package/dist/lifecycle/target-lock.js +286 -0
  75. package/dist/op/activities/activity-contracts.d.ts +30 -0
  76. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  77. package/dist/op/activities/activity-contracts.js +26 -0
  78. package/dist/op/activities/index.d.ts +2 -0
  79. package/dist/op/activities/index.d.ts.map +1 -1
  80. package/dist/op/activities/index.js +4 -0
  81. package/dist/op/activities/review-agent.d.ts +137 -0
  82. package/dist/op/activities/review-agent.d.ts.map +1 -0
  83. package/dist/op/activities/review-agent.js +266 -0
  84. package/dist/op/builders.d.ts +9 -0
  85. package/dist/op/builders.d.ts.map +1 -1
  86. package/dist/op/builders.js +11 -0
  87. package/dist/op/cron.d.ts +8 -0
  88. package/dist/op/cron.d.ts.map +1 -1
  89. package/dist/op/cron.js +51 -0
  90. package/dist/op/gate-approval.d.ts +20 -13
  91. package/dist/op/gate-approval.d.ts.map +1 -1
  92. package/dist/op/gate-approval.js +13 -18
  93. package/dist/op/gate-quorum.d.ts +131 -0
  94. package/dist/op/gate-quorum.d.ts.map +1 -0
  95. package/dist/op/gate-quorum.js +183 -0
  96. package/dist/op/gate-resume.d.ts +4 -2
  97. package/dist/op/gate-resume.d.ts.map +1 -1
  98. package/dist/op/gate-resume.js +5 -4
  99. package/dist/op/gate-review.js +1 -1
  100. package/dist/op/gate.d.ts +32 -4
  101. package/dist/op/gate.d.ts.map +1 -1
  102. package/dist/op/gate.js +40 -24
  103. package/dist/op/index.d.ts +6 -4
  104. package/dist/op/index.d.ts.map +1 -1
  105. package/dist/op/index.js +4 -3
  106. package/dist/op/local-executor.d.ts.map +1 -1
  107. package/dist/op/local-executor.js +9 -3
  108. package/dist/op/op-waves-run.d.ts +62 -1
  109. package/dist/op/op-waves-run.d.ts.map +1 -1
  110. package/dist/op/op-waves-run.js +190 -11
  111. package/dist/op/op-waves.d.ts +55 -3
  112. package/dist/op/op-waves.d.ts.map +1 -1
  113. package/dist/op/op-waves.js +63 -8
  114. package/dist/op/schedule-overdue.d.ts +57 -0
  115. package/dist/op/schedule-overdue.d.ts.map +1 -0
  116. package/dist/op/schedule-overdue.js +138 -0
  117. package/dist/override-gate.d.ts +2 -2
  118. package/dist/override-gate.d.ts.map +1 -1
  119. package/dist/override-gate.js +1 -1
  120. package/dist/pr-forge.d.ts +36 -0
  121. package/dist/pr-forge.d.ts.map +1 -1
  122. package/dist/pr-forge.js +82 -0
  123. package/dist/pr-loop.d.ts +90 -1
  124. package/dist/pr-loop.d.ts.map +1 -1
  125. package/dist/pr-loop.js +77 -3
  126. package/dist/pr-post.d.ts +72 -0
  127. package/dist/pr-post.d.ts.map +1 -0
  128. package/dist/pr-post.js +134 -0
  129. package/dist/wave-blocked.d.ts +79 -0
  130. package/dist/wave-blocked.d.ts.map +1 -0
  131. package/dist/wave-blocked.js +245 -0
  132. package/dist/wave-engine.d.ts +6 -0
  133. package/dist/wave-engine.d.ts.map +1 -1
  134. package/dist/wave-engine.js +2 -0
  135. package/dist/wave-runtime.mjs +242 -242
  136. package/dist/workspace/__fixtures__/two-member-workspace.d.ts +1 -0
  137. package/dist/workspace/__fixtures__/two-member-workspace.d.ts.map +1 -1
  138. package/dist/workspace/__fixtures__/two-member-workspace.js +1 -1
  139. package/dist/workspace/ci-branches.d.ts +51 -0
  140. package/dist/workspace/ci-branches.d.ts.map +1 -0
  141. package/dist/workspace/ci-branches.js +69 -0
  142. package/dist/workspace/ci-cloud-identity.d.ts +11 -0
  143. package/dist/workspace/ci-cloud-identity.d.ts.map +1 -0
  144. package/dist/workspace/ci-cloud-identity.js +24 -0
  145. package/dist/workspace/ci-locks.d.ts +12 -0
  146. package/dist/workspace/ci-locks.d.ts.map +1 -0
  147. package/dist/workspace/ci-locks.js +6 -0
  148. package/dist/workspace/ci-run.d.ts +17 -1
  149. package/dist/workspace/ci-run.d.ts.map +1 -1
  150. package/dist/workspace/ci-run.js +64 -3
  151. package/dist/workspace/declaration.d.ts +63 -0
  152. package/dist/workspace/declaration.d.ts.map +1 -1
  153. package/dist/workspace/declaration.js +72 -1
  154. package/dist/workspace/declaration.schema.json +147 -0
  155. package/dist/workspace/identity-gate-rule.d.ts +1 -1
  156. package/dist/workspace/identity-gate-rule.d.ts.map +1 -1
  157. package/dist/workspace/identity-gate-rule.js +1 -1
  158. package/dist/workspace/identity.d.ts +1 -1
  159. package/dist/workspace/identity.d.ts.map +1 -1
  160. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  161. package/dist/workspace/lineage-upgrade.js +8 -5
  162. package/dist/workspace/member-pipeline.d.ts +6 -0
  163. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  164. package/dist/workspace/member-pipeline.js +23 -0
  165. package/dist/workspace/pr-report.schema.json +73 -2
  166. package/dist/workspace/security.d.ts.map +1 -1
  167. package/dist/workspace/security.js +4 -0
  168. package/dist/workspace/status-gates.d.ts +3 -2
  169. package/dist/workspace/status-gates.d.ts.map +1 -1
  170. package/dist/workspace/status-gates.js +8 -7
  171. package/dist/workspace/status.d.ts.map +1 -1
  172. package/dist/workspace/status.js +4 -1
  173. package/dist/workspace/trust/seal.d.ts +2 -2
  174. package/dist/workspace/trust/seal.d.ts.map +1 -1
  175. package/dist/workspace/trust/seal.js +2 -2
  176. package/package.json +1 -1
  177. package/src/__fixtures__/oidc-emulator.ts +118 -0
  178. package/src/ci-handover.test.ts +209 -0
  179. package/src/ci-handover.ts +237 -0
  180. package/src/ci-identity.test.ts +201 -0
  181. package/src/ci-identity.ts +310 -0
  182. package/src/cli/handlers/approve.ts +18 -5
  183. package/src/cli/handlers/build.ts +15 -1
  184. package/src/cli/handlers/fan-out.ts +17 -4
  185. package/src/cli/handlers/gate-resume.ts +16 -2
  186. package/src/cli/handlers/lock.test.ts +98 -0
  187. package/src/cli/handlers/lock.ts +202 -0
  188. package/src/cli/handlers/operator.test.ts +3 -1
  189. package/src/cli/handlers/pr-env.ts +462 -0
  190. package/src/cli/handlers/pr-waves-blocked.test.ts +247 -0
  191. package/src/cli/handlers/pr.test.ts +184 -0
  192. package/src/cli/handlers/pr.ts +496 -59
  193. package/src/cli/handlers/promote.ts +9 -0
  194. package/src/cli/handlers/query.ts +78 -0
  195. package/src/cli/main.ts +41 -2
  196. package/src/cli/parse-args.ts +23 -2
  197. package/src/cli/registry.ts +12 -2
  198. package/src/components/fan-out-run.ts +39 -2
  199. package/src/components/fan-out-waves.test.ts +45 -1
  200. package/src/components/pr-environment.test.ts +249 -0
  201. package/src/components/pr-environment.ts +538 -0
  202. package/src/components/pr-hooks.test.ts +118 -0
  203. package/src/components/pr-hooks.ts +157 -0
  204. package/src/components/pr-pipeline.ts +130 -5
  205. package/src/components/pr-policy.test.ts +145 -0
  206. package/src/components/pr-policy.ts +211 -0
  207. package/src/components/pr-run.ts +5 -2
  208. package/src/gate-decision-bundle.test.ts +4 -3
  209. package/src/gate-decision.ts +39 -2
  210. package/src/gate-policy-bundle.test.ts +28 -0
  211. package/src/gate-policy.test.ts +162 -0
  212. package/src/gate-policy.ts +298 -0
  213. package/src/lifecycle/gate-checkpoint.ts +1 -1
  214. package/src/{workspace/trust → lifecycle}/gate-seal.ts +1 -1
  215. package/src/lifecycle/query.test.ts +91 -0
  216. package/src/lifecycle/query.ts +315 -0
  217. package/src/lifecycle/run-ledger.ts +18 -0
  218. package/src/lifecycle/target-lock.test.ts +195 -0
  219. package/src/lifecycle/target-lock.ts +347 -0
  220. package/src/op/activities/activity-contracts.ts +31 -0
  221. package/src/op/activities/index.ts +6 -0
  222. package/src/op/activities/review-agent.test.ts +240 -0
  223. package/src/op/activities/review-agent.ts +355 -0
  224. package/src/op/builders.ts +13 -0
  225. package/src/op/cron.ts +44 -0
  226. package/src/op/gate-alias.test.ts +2 -2
  227. package/src/op/gate-approval.test.ts +30 -9
  228. package/src/op/gate-approval.ts +23 -26
  229. package/src/op/gate-quorum.test.ts +139 -0
  230. package/src/op/gate-quorum.ts +248 -0
  231. package/src/op/gate-resume.ts +6 -3
  232. package/src/op/gate-review.ts +1 -1
  233. package/src/op/gate.ts +64 -23
  234. package/src/op/index.ts +13 -1
  235. package/src/op/local-executor.ts +9 -2
  236. package/src/op/op-waves-hook-targets.test.ts +126 -0
  237. package/src/op/op-waves-policy-hold.test.ts +66 -0
  238. package/src/op/op-waves-quorum.test.ts +101 -0
  239. package/src/op/op-waves-run.ts +233 -11
  240. package/src/op/op-waves.ts +83 -10
  241. package/src/op/schedule-overdue.test.ts +220 -0
  242. package/src/op/schedule-overdue.ts +161 -0
  243. package/src/override-gate-bundle.test.ts +1 -1
  244. package/src/override-gate.ts +2 -2
  245. package/src/pr-forge-bundle.test.ts +2 -2
  246. package/src/pr-forge.test.ts +33 -0
  247. package/src/pr-forge.ts +114 -0
  248. package/src/pr-loop-bundle.test.ts +2 -2
  249. package/src/pr-loop.test.ts +58 -0
  250. package/src/pr-loop.ts +146 -4
  251. package/src/pr-post.test.ts +141 -0
  252. package/src/pr-post.ts +159 -0
  253. package/src/wave-blocked-bundle.test.ts +25 -0
  254. package/src/wave-blocked.test.ts +146 -0
  255. package/src/wave-blocked.ts +271 -0
  256. package/src/wave-engine.ts +8 -0
  257. package/src/workspace/__fixtures__/two-member-workspace.ts +2 -2
  258. package/src/workspace/ci-branches.test.ts +62 -0
  259. package/src/workspace/ci-branches.ts +79 -0
  260. package/src/workspace/ci-cloud-identity.test.ts +53 -0
  261. package/src/workspace/ci-cloud-identity.ts +23 -0
  262. package/src/workspace/ci-locks.ts +16 -0
  263. package/src/workspace/ci-run-index.test.ts +2 -1
  264. package/src/workspace/ci-run.test.ts +107 -8
  265. package/src/workspace/ci-run.ts +72 -4
  266. package/src/workspace/declaration.schema.json +147 -0
  267. package/src/workspace/declaration.test.ts +76 -0
  268. package/src/workspace/declaration.ts +134 -2
  269. package/src/workspace/identity-gate-rule.ts +1 -1
  270. package/src/workspace/identity.ts +1 -1
  271. package/src/workspace/lineage-upgrade.ts +11 -8
  272. package/src/workspace/member-pipeline.test.ts +29 -0
  273. package/src/workspace/member-pipeline.ts +30 -0
  274. package/src/workspace/pr-report.schema.json +73 -2
  275. package/src/workspace/security.ts +4 -0
  276. package/src/workspace/status-gates.ts +9 -6
  277. package/src/workspace/status.ts +4 -1
  278. package/src/workspace/trust/gate-seal.test.ts +1 -1
  279. package/src/workspace/trust/seal.ts +2 -2
  280. package/dist/workspace/trust/gate-seal.d.ts.map +0 -1
  281. /package/dist/{workspace/trust → lifecycle}/gate-seal.js +0 -0
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Gate-time policy (#4294): a policy engine run over a plan's change set,
3
+ * with the policy read at base, whose rules deny a plan or hold it for a
4
+ * person.
5
+ *
6
+ * The engine is any command that prints conftest's JSON result
7
+ * (`conftest test --output json`) or OPA's (`opa eval --format json`). The
8
+ * rules are counted as conftest counts them:
9
+ *
10
+ * - `deny`, `violation`, `deny_<name>` and `violation_<name>` deny the plan.
11
+ * A denied plan applies only under an override that counts
12
+ * (`./override-gate.ts`), on top of the gate's own approval.
13
+ * - `hold` and `hold_<name>` hold the plan: it waits for a person's approval
14
+ * of its digest, even where the gate would let it through without one.
15
+ * conftest runs only deny and warn rules, so a hold needs the opa shape.
16
+ * - `warn` and `warn_<name>` are advice. They fail nothing.
17
+ *
18
+ * There is no outcome that lets a plan apply with nobody approving it: a
19
+ * non-empty change set always waits for a person (ws-104, ws-080). A rule
20
+ * named `allow_unattended` is read as no rule at all.
21
+ *
22
+ * A policy that cannot run, or gives no answer this module can read, is an
23
+ * error, and the caller stops the apply: no path waives a policy.
24
+ *
25
+ * Like `./override-gate.ts` this module reads what it is handed and imports
26
+ * only node built-ins, so a product can bundle it into one file
27
+ * (`gate-policy-bundle.test.ts`). The pull-request jobs' use of it is in
28
+ * `./components/pr-policy.ts`.
29
+ */
30
+
31
+ import { spawnSync } from "node:child_process";
32
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
33
+ import { tmpdir } from "node:os";
34
+ import { join } from "node:path";
35
+
36
+ /** The result shapes chant reads: conftest's and OPA's. */
37
+ export const GATE_POLICY_ENGINES = ["conftest", "opa"] as const;
38
+ export type GatePolicyEngine = (typeof GATE_POLICY_ENGINES)[number];
39
+
40
+ /** Where the policy is read when the settings name no `path`. */
41
+ export const DEFAULT_POLICY_PATH = "policy";
42
+
43
+ /** The settings of one policy step, read at base. */
44
+ export interface GatePolicySettings {
45
+ /** The result shape, and the default command. Default `conftest`. */
46
+ engine?: GatePolicyEngine;
47
+ /** The policy directory, relative to the declaration at base. Default {@link DEFAULT_POLICY_PATH}. */
48
+ path?: string;
49
+ /** The package to read. conftest default: every namespace. opa default: `main`. */
50
+ namespace?: string;
51
+ /**
52
+ * The command to run instead of the engine's own, as argv. `{policy}` is
53
+ * the policy directory exported from base and `{input}` the input file. It
54
+ * prints the result shape `engine` names.
55
+ */
56
+ command?: string[];
57
+ }
58
+
59
+ /** A rule that matched, and what it said. */
60
+ export interface GatePolicyFinding {
61
+ /** `<package>.<rule>`, such as `main.deny_public_bucket`. */
62
+ rule: string;
63
+ message: string;
64
+ }
65
+
66
+ /** What an engine's answer holds. */
67
+ export interface GatePolicyFindings {
68
+ denials: GatePolicyFinding[];
69
+ holds: GatePolicyFinding[];
70
+ warnings: string[];
71
+ }
72
+
73
+ /**
74
+ * One policy check's outcome. `denied` wins over `held`. `error`: the
75
+ * policy did not run or gave no readable answer, and the apply stops.
76
+ */
77
+ export interface GatePolicyVerdict {
78
+ result: "passed" | "denied" | "held" | "error";
79
+ engine: GatePolicyEngine;
80
+ denials: GatePolicyFinding[];
81
+ /** The ids of the rules that denied, sorted, each once: what an override names. */
82
+ rules: string[];
83
+ holds: GatePolicyFinding[];
84
+ warnings: string[];
85
+ error?: string;
86
+ }
87
+
88
+ export const DENY_RULE = /^(deny|violation)(_[A-Za-z0-9]+)*$/;
89
+ export const WARN_RULE = /^warn(_[A-Za-z0-9]+)*$/;
90
+ export const HOLD_RULE = /^hold(_[A-Za-z0-9]+)*$/;
91
+
92
+ const ruleId = (query: string): string => query.replace(/^data\./, "");
93
+
94
+ function messageOf(v: unknown): string {
95
+ if (typeof v === "string") return v;
96
+ if (v !== null && typeof v === "object" && typeof (v as { msg?: unknown }).msg === "string") return (v as { msg: string }).msg;
97
+ return JSON.stringify(v);
98
+ }
99
+
100
+ /** The findings in `conftest test --output json`. Undefined when it is not that shape. */
101
+ export function conftestFindings(stdout: string): GatePolicyFindings | undefined {
102
+ let results: unknown;
103
+ try {
104
+ results = JSON.parse(stdout);
105
+ } catch {
106
+ return undefined;
107
+ }
108
+ if (!Array.isArray(results)) return undefined;
109
+ const out: GatePolicyFindings = { denials: [], holds: [], warnings: [] };
110
+ for (const r of results as Array<{ namespace?: string; failures?: Array<{ msg?: unknown; metadata?: { query?: unknown } }>; warnings?: Array<{ msg?: unknown }> }>) {
111
+ if (r === null || typeof r !== "object") return undefined;
112
+ for (const f of r.failures ?? []) {
113
+ // conftest names the query that denied in the failure's metadata; an older one gives only the namespace.
114
+ const rule = typeof f.metadata?.query === "string" ? ruleId(f.metadata.query) : `${r.namespace ?? "main"}.deny`;
115
+ out.denials.push({ rule, message: messageOf(f.msg) });
116
+ }
117
+ for (const w of r.warnings ?? []) out.warnings.push(messageOf(w.msg));
118
+ }
119
+ return out;
120
+ }
121
+
122
+ /** The rules of one package's value, and with `nested` its child packages one level down. */
123
+ function packageFindings(doc: Record<string, unknown>, out: GatePolicyFindings, pkg: string, nested: boolean): void {
124
+ for (const [name, value] of Object.entries(doc).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
125
+ // A rule matches when its value is true, a message, or a non-empty set of messages.
126
+ const said = Array.isArray(value) ? value.map(messageOf) : value === true ? [name] : typeof value === "string" && value !== "" ? [value] : [];
127
+ if (DENY_RULE.test(name)) {
128
+ for (const message of said) out.denials.push({ rule: `${pkg}.${name}`, message });
129
+ } else if (HOLD_RULE.test(name)) {
130
+ for (const message of said) out.holds.push({ rule: `${pkg}.${name}`, message });
131
+ } else if (WARN_RULE.test(name)) {
132
+ out.warnings.push(...said);
133
+ } else if (nested && value !== null && typeof value === "object" && !Array.isArray(value)) {
134
+ packageFindings(value as Record<string, unknown>, out, `${pkg}.${name}`, false);
135
+ }
136
+ }
137
+ }
138
+
139
+ /**
140
+ * The findings in `opa eval --format json data.<namespace>`. The query's
141
+ * value is the package: its rules are read by name. An array value is a
142
+ * deny set named after the query. A query that is undefined has no rule
143
+ * that matched. Undefined when the output is not that shape.
144
+ */
145
+ export function opaFindings(stdout: string, namespace = "main"): GatePolicyFindings | undefined {
146
+ let parsed: { result?: Array<{ expressions?: Array<{ value?: unknown }> }>; errors?: unknown };
147
+ try {
148
+ parsed = JSON.parse(stdout);
149
+ } catch {
150
+ return undefined;
151
+ }
152
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed) || parsed.errors !== undefined) return undefined;
153
+ const value = parsed.result?.[0]?.expressions?.[0]?.value;
154
+ const out: GatePolicyFindings = { denials: [], holds: [], warnings: [] };
155
+ if (value === undefined) return out;
156
+ if (Array.isArray(value)) {
157
+ out.denials.push(...value.map((v) => ({ rule: namespace, message: messageOf(v) })));
158
+ return out;
159
+ }
160
+ if (value === null || typeof value !== "object") return undefined;
161
+ packageFindings(value as Record<string, unknown>, out, namespace, true);
162
+ return out;
163
+ }
164
+
165
+ /** A verdict from what the engine found. */
166
+ export function gatePolicyVerdict(engine: GatePolicyEngine, found: GatePolicyFindings): GatePolicyVerdict {
167
+ const rules = [...new Set(found.denials.map((d) => d.rule))].sort();
168
+ return {
169
+ result: found.denials.length > 0 ? "denied" : found.holds.length > 0 ? "held" : "passed",
170
+ engine,
171
+ denials: found.denials,
172
+ rules,
173
+ holds: found.holds,
174
+ warnings: found.warnings,
175
+ };
176
+ }
177
+
178
+ const errorVerdict = (engine: GatePolicyEngine, error: string): GatePolicyVerdict => ({ result: "error", engine, denials: [], rules: [], holds: [], warnings: [], error });
179
+
180
+ /** The argv one check runs: the settings' `command` with its placeholders filled, or the engine's own. */
181
+ export function gatePolicyArgv(settings: GatePolicySettings, at: { policy: string; input: string }): string[] {
182
+ if (settings.command) return settings.command.map((a) => a.replaceAll("{policy}", at.policy).replaceAll("{input}", at.input));
183
+ if ((settings.engine ?? "conftest") === "opa") {
184
+ return ["opa", "eval", "--format", "json", "--data", at.policy, "--input", at.input, `data.${settings.namespace ?? "main"}`];
185
+ }
186
+ return ["conftest", "test", "--no-color", "--output", "json", "--policy", at.policy, ...(settings.namespace ? ["--namespace", settings.namespace] : ["--all-namespaces"]), at.input];
187
+ }
188
+
189
+ /** What one run of the engine printed and how it exited. `status` is null when it did not start. */
190
+ export interface GatePolicyRun {
191
+ status: number | null;
192
+ stdout: string;
193
+ stderr: string;
194
+ }
195
+
196
+ export type GatePolicyExec = (argv: string[], cwd: string) => GatePolicyRun;
197
+
198
+ /** Runs the engine with the job's environment. */
199
+ export const defaultGatePolicyExec: GatePolicyExec = (argv, cwd) => {
200
+ const r = spawnSync(argv[0]!, argv.slice(1), { cwd, encoding: "utf-8", maxBuffer: 64 * 1024 * 1024 });
201
+ return { status: r.error ? null : r.status, stdout: r.stdout ?? "", stderr: `${r.stderr ?? ""}${r.error ? r.error.message : ""}` };
202
+ };
203
+
204
+ export interface CheckGatePolicyOptions {
205
+ settings: GatePolicySettings;
206
+ /** The input document, or its JSON text. */
207
+ input: unknown;
208
+ /** The policy directory, already exported from base. */
209
+ policyDir: string;
210
+ /** Where the engine runs. Default the policy directory. */
211
+ cwd?: string;
212
+ exec?: GatePolicyExec;
213
+ }
214
+
215
+ const tail = (text: string): string => text.trim().split("\n").slice(-3).join(" ");
216
+
217
+ /** Run the engine once over `input` and read its answer. */
218
+ export function checkGatePolicy(options: CheckGatePolicyOptions): GatePolicyVerdict {
219
+ const { settings } = options;
220
+ const engine = settings.engine ?? "conftest";
221
+ const work = mkdtempSync(join(tmpdir(), "chant-policy-input-"));
222
+ try {
223
+ const file = join(work, "input.json");
224
+ writeFileSync(file, typeof options.input === "string" ? options.input : JSON.stringify(options.input));
225
+ const argv = gatePolicyArgv(settings, { policy: options.policyDir, input: file });
226
+ const r = (options.exec ?? defaultGatePolicyExec)(argv, options.cwd ?? options.policyDir);
227
+ if (r.status === null) return errorVerdict(engine, `${argv[0]} did not run: ${tail(r.stderr) || "no such command"}`);
228
+ const found = engine === "opa" ? opaFindings(r.stdout, settings.namespace ?? "main") : conftestFindings(r.stdout);
229
+ if (found === undefined) return errorVerdict(engine, `${argv[0]} gave no ${engine} result (exit ${r.status}): ${tail(r.stderr || r.stdout)}`);
230
+ // conftest exits 1 on a denial and 2 or more when it could not run; opa exits 0 on a query it ran.
231
+ if (found.denials.length === 0 && r.status !== 0) return errorVerdict(engine, `${argv[0]} exited ${r.status}: ${tail(r.stderr)}`);
232
+ return gatePolicyVerdict(engine, found);
233
+ } finally {
234
+ rmSync(work, { recursive: true, force: true });
235
+ }
236
+ }
237
+
238
+ /** The part of a tree a policy is exported from: a workspace tree at base fits. */
239
+ export interface GatePolicyTree {
240
+ stat(path: string): "file" | "dir" | undefined;
241
+ list(path: string): Array<{ name: string; type: "file" | "dir" }> | undefined;
242
+ read(path: string): string;
243
+ bytes?(path: string): Uint8Array;
244
+ }
245
+
246
+ /**
247
+ * Copy the policy directory `path` out of `tree` (the tree at base) into a
248
+ * new temporary directory, so the engine reads the policy as the base has
249
+ * it and never the change's copy. Returns the directory and how to remove
250
+ * it, or the reason there is no policy to read.
251
+ */
252
+ export function exportGatePolicy(tree: GatePolicyTree, path: string): { dir: string; cleanup: () => void } | { error: string } {
253
+ const root = path.replace(/^\.\/+/, "").replace(/\/+$/, "");
254
+ const kind = root === "" || root === "." ? "dir" : tree.stat(root);
255
+ if (kind === undefined) return { error: `the policy directory ${path} does not exist at base` };
256
+ const dir = mkdtempSync(join(tmpdir(), "chant-policy-"));
257
+ const cleanup = (): void => rmSync(dir, { recursive: true, force: true });
258
+ const copy = (from: string, to: string): void => {
259
+ for (const entry of tree.list(from) ?? []) {
260
+ const src = from === "" || from === "." ? entry.name : `${from}/${entry.name}`;
261
+ const dest = join(to, entry.name);
262
+ if (entry.type === "dir") {
263
+ mkdirSync(dest, { recursive: true });
264
+ copy(src, dest);
265
+ } else {
266
+ writeFileSync(dest, tree.bytes ? tree.bytes(src) : tree.read(src));
267
+ }
268
+ }
269
+ };
270
+ try {
271
+ if (kind === "file") writeFileSync(join(dir, root.split("/").pop()!), tree.bytes ? tree.bytes(root) : tree.read(root));
272
+ else copy(root, dir);
273
+ } catch (err) {
274
+ cleanup();
275
+ return { error: `the policy at ${path} could not be read at base: ${err instanceof Error ? err.message : String(err)}` };
276
+ }
277
+ return { dir, cleanup };
278
+ }
279
+
280
+ /** The settings, checked. Throws with what is wrong. `where` names them, such as `ci.policy`. */
281
+ export function assertGatePolicySettings(where: string, raw: unknown): asserts raw is GatePolicySettings {
282
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`${where} must be { engine?, path?, namespace?, command? }`);
283
+ for (const [key, value] of Object.entries(raw)) {
284
+ if (key === "engine") {
285
+ if (!(GATE_POLICY_ENGINES as readonly unknown[]).includes(value)) throw new Error(`${where}.engine must be ${GATE_POLICY_ENGINES.join(" or ")}, the result shape the command prints`);
286
+ } else if (key === "path" || key === "namespace") {
287
+ if (typeof value !== "string" || value.trim() === "") throw new Error(`${where}.${key} must be a non-empty string`);
288
+ if (key === "path" && (value.startsWith("/") || value.split("/").includes(".."))) throw new Error(`${where}.path must be a directory inside the workspace, relative to its declaration`);
289
+ } else if (key === "command") {
290
+ if (!Array.isArray(value) || value.length === 0 || !value.every((a) => typeof a === "string" && a !== "")) throw new Error(`${where}.command must be argv, a non-empty list of strings`);
291
+ if (!value.some((a: string) => a.includes("{input}"))) throw new Error(`${where}.command must pass the input file as {input}`);
292
+ } else if (key === "outcomes" || key === "allow_unattended" || key === "allowUnattended") {
293
+ throw new Error(`${where}.${key}: chant's policy step denies and holds only; a non-empty plan always waits for a person (ws-104), so there is no unattended outcome`);
294
+ } else {
295
+ throw new Error(`${where}.${key} is not a setting; the settings are engine, path, namespace and command`);
296
+ }
297
+ }
298
+ }
@@ -6,7 +6,7 @@
6
6
  * The run writes the file just before it appends the pending fact, and puts
7
7
  * the file's digest on that fact as `checkpointDigest`. `chant approve`
8
8
  * copies the digest onto the approval, the way it copies the plan digest, and
9
- * a seal covers it (`../workspace/trust/gate-seal.ts`). A later run that
9
+ * a seal covers it (`./gate-seal.ts`). A later run that
10
10
  * resumes at the gate reads the file back and checks it against the digest
11
11
  * the approver saw, so a value pushed onto the branch after the approval is
12
12
  * refused rather than carried into the steps after the gate.
@@ -19,7 +19,7 @@
19
19
  */
20
20
 
21
21
  import { createHash, createPublicKey, verify, type KeyObject } from "node:crypto";
22
- import type { Signer } from "./trust-files";
22
+ import type { Signer } from "../workspace/trust/trust-files";
23
23
 
24
24
  /**
25
25
  * The ssh signature namespace of a gate approval's seal (#3163, ws-080), so a
@@ -0,0 +1,91 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import type { LedgerReadPort } from "./ledger-port";
3
+ import { firstWord, formatQueryResult, loadQueryData, QUERY_SCHEMA_VERSION, QUERY_TABLES, QueryError, runQuery, SCHEMA_TABLE } from "./query";
4
+ import { makeRelease } from "./release-store";
5
+
6
+ /** A port over a map of `<dir>/<file>` to text, the shape `gitLedgerReadPort` reads. */
7
+ function port(files: Record<string, string>): LedgerReadPort {
8
+ const dirs = (): string[] => [...new Set(Object.keys(files).map((p) => p.slice(0, p.indexOf("/"))))];
9
+ return {
10
+ read: async (dir, file) => files[`${dir}/${file}`] ?? null,
11
+ list: async (dir) => Object.keys(files).filter((p) => p.startsWith(`${dir}/`)).map((p) => p.slice(dir.length + 1)),
12
+ trees: async () => dirs(),
13
+ };
14
+ }
15
+
16
+ const lines = (...rows: unknown[]): string => rows.map((r) => (typeof r === "string" ? r : JSON.stringify(r))).join("\n") + "\n";
17
+
18
+ const release = makeRelease({ member: null, commit: "c".repeat(40), artifactDigests: [{ component: "api", kind: "image", path: "dist/api.tar", digest: "sha256:aa" }], targets: [{ env: "prod", templates: [], params: [] }] });
19
+
20
+ const ledger = port({
21
+ "prod/aws.json": JSON.stringify({ lexicon: "aws", environment: "prod", commit: "a".repeat(40), timestamp: "2026-10-01T00:00:00Z", resources: { Bucket: { type: "AWS::S3::Bucket", status: "CREATE_COMPLETE", physicalId: "my-bucket", attributes: { Arn: "arn:aws:s3:::my-bucket" } }, Queue: { type: "AWS::SQS::Queue", status: "CREATE_COMPLETE" } } }),
22
+ "prod/edge__k8s.json": JSON.stringify({ lexicon: "k8s", environment: "prod", commit: "b".repeat(40), timestamp: "2026-10-02T00:00:00Z", depth: "deep", resources: { Web: { type: "K8s::Apps::Deployment", status: "Available", ownership: "owned" } }, unobserved: { Gone: { reason: "not-found" } } }),
23
+ "prod/observation-baseline.json": JSON.stringify({ version: 1, environment: "prod" }),
24
+ "prod/releases.jsonl": lines(
25
+ { version: 1, component: "api", env: "prod", digest: "sha256:aa", gitSha: "c".repeat(40), runId: "r1", runOrigin: { forge: "github", url: "https://example.test/run/1" }, timestamp: "2026-10-03T00:00:00Z", actor: "ci", releaseDigest: release.releaseDigest },
26
+ "{not json",
27
+ ),
28
+ "prod/runs__deploy.jsonl": lines(
29
+ { version: 1, id: "run-1", op: "deploy", env: "prod", started: "2026-10-03T00:00:00Z", ended: "2026-10-03T00:01:00Z", status: "fail", labels: { Env: "prod" }, outcomes: {}, phases: [], failure: { code: "command-failed", reason: "exit 1", phase: "apply" } },
30
+ { version: 1, id: "run-2", op: "deploy", env: "prod", started: "2026-10-04T00:00:00Z", ended: "2026-10-04T00:01:00Z", status: "ok", labels: {}, outcomes: { url: "https://x" }, phases: [] },
31
+ ),
32
+ "_gates/deploy.jsonl": lines(
33
+ { version: 1, kind: "pending", op: "deploy", gate: "prod-approval", timestamp: "2026-10-03T00:00:00Z", expiresAt: "2026-10-10T00:00:00Z", planDigest: "sha256:p1", origin: "cli" },
34
+ { version: 1, op: "deploy", gate: "prod-approval", resolvedBy: "alice", timestamp: "2026-10-03T01:00:00Z", planDigest: "sha256:p1", approver: { kind: "human" } },
35
+ ),
36
+ [`_releases/${release.releaseDigest.replace(/:/g, "_")}.json`]: JSON.stringify(release),
37
+ "_releases/sha256_tampered.json": JSON.stringify({ ...release, releaseDigest: "sha256:tampered" }),
38
+ "_releases/cuts.jsonl": lines({ version: 1, releaseDigest: release.releaseDigest, label: "v1.0.0", actor: "alice", timestamp: "2026-10-02T12:00:00Z", runId: null, runOrigin: null, signature: null }),
39
+ });
40
+
41
+ describe("chant query (#4297)", () => {
42
+ test("loads every ledger into its table and counts what it can't read", async () => {
43
+ const load = await loadQueryData([{ member: null, port: ledger }]);
44
+ expect(load.data.snapshots).toHaveLength(2);
45
+ expect(load.data.resources.map((r) => r.name)).toEqual(["Bucket", "Queue", "Web"]);
46
+ expect(load.data.resources[2]).toMatchObject({ stack: "edge", lexicon: "k8s", ownership: "owned" });
47
+ expect(load.data.snapshots[1]).toMatchObject({ depth: "deep", resources: 1, unobserved: 1 });
48
+ expect(load.data.runs.map((r) => r.status)).toEqual(["fail", "ok"]);
49
+ expect(load.data.gates.map((g) => g.kind)).toEqual(["pending", "resolution"]);
50
+ expect(load.data.deploys).toHaveLength(1);
51
+ expect(load.data.releases).toEqual([expect.objectContaining({ release_digest: release.releaseDigest, envs: "prod" })]);
52
+ expect(load.data.cuts[0]).toMatchObject({ label: "v1.0.0", signed: 0 });
53
+ expect(load.malformed).toMatchObject({ deploys: 1, releases: 1, runs: 0, gates: 0 });
54
+ });
55
+
56
+ test("runs SQL over the tables and joins them", async () => {
57
+ const load = await loadQueryData([{ member: null, port: ledger }]);
58
+ const byType = await runQuery("SELECT lexicon, count(*) AS n FROM resources GROUP BY lexicon ORDER BY lexicon", load);
59
+ expect(byType.columns).toEqual(["lexicon", "n"]);
60
+ expect(byType.rows).toEqual([{ lexicon: "aws", n: 2 }, { lexicon: "k8s", n: 1 }]);
61
+ const joined = await runQuery("SELECT d.component, c.label FROM deploys d JOIN cuts c ON c.release_digest = d.release_digest", load);
62
+ expect(joined.rows).toEqual([{ component: "api", label: "v1.0.0" }]);
63
+ const arn = await runQuery("SELECT json_extract(attributes, '$.Arn') AS arn FROM resources WHERE name = 'Bucket'", load);
64
+ expect(arn.rows[0].arn).toBe("arn:aws:s3:::my-bucket");
65
+ expect(arn.schemaVersion).toBe(QUERY_SCHEMA_VERSION);
66
+ expect(formatQueryResult(arn)).toContain("(1 row)");
67
+ });
68
+
69
+ test("chant_schema lists every column with the schema version", async () => {
70
+ const load = await loadQueryData([]);
71
+ const r = await runQuery(`SELECT DISTINCT version, table_name FROM ${SCHEMA_TABLE} ORDER BY table_name`, load);
72
+ expect(r.rows.map((x) => x.table_name)).toEqual(Object.keys(QUERY_TABLES).sort());
73
+ expect(new Set(r.rows.map((x) => x.version))).toEqual(new Set([QUERY_SCHEMA_VERSION]));
74
+ });
75
+
76
+ test("each member's rows name the member", async () => {
77
+ const load = await loadQueryData([{ member: "api", port: ledger }, { member: "web", port: port({}) }]);
78
+ const r = await runQuery("SELECT DISTINCT member FROM runs", load);
79
+ expect(r.rows).toEqual([{ member: "api" }]);
80
+ });
81
+
82
+ test("refuses anything but a read", async () => {
83
+ const load = await loadQueryData([]);
84
+ await expect(runQuery("DELETE FROM runs", load)).rejects.toBeInstanceOf(QueryError);
85
+ await expect(runQuery(" ", load)).rejects.toBeInstanceOf(QueryError);
86
+ // A WITH that writes is stopped by query_only.
87
+ await expect(runQuery("WITH x AS (SELECT 1) INSERT INTO runs (id) SELECT * FROM x", load)).rejects.toBeInstanceOf(QueryError);
88
+ await expect(runQuery("SELECT nope FROM runs", load)).rejects.toThrow(/does not run/);
89
+ expect(firstWord("-- note\n /* c */ select 1")).toBe("select");
90
+ });
91
+ });