@intentius/chant 0.78.0 → 0.79.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 (43) hide show
  1. package/dist/cli/handlers/operator.d.ts +17 -0
  2. package/dist/cli/handlers/operator.d.ts.map +1 -1
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/cli/registry.d.ts +4 -0
  5. package/dist/cli/registry.d.ts.map +1 -1
  6. package/dist/lifecycle/gate-ledger.d.ts +22 -0
  7. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  8. package/dist/lint/rules/op/index.d.ts +2 -1
  9. package/dist/lint/rules/op/index.d.ts.map +1 -1
  10. package/dist/lint/rules/op/ops015-gate-approval.d.ts +14 -0
  11. package/dist/lint/rules/op/ops015-gate-approval.d.ts.map +1 -0
  12. package/dist/op/builders.d.ts +4 -0
  13. package/dist/op/builders.d.ts.map +1 -1
  14. package/dist/op/gate-approval.d.ts +142 -0
  15. package/dist/op/gate-approval.d.ts.map +1 -0
  16. package/dist/op/gate.d.ts +54 -0
  17. package/dist/op/gate.d.ts.map +1 -1
  18. package/dist/op/index.d.ts +4 -1
  19. package/dist/op/index.d.ts.map +1 -1
  20. package/dist/op/local-executor.d.ts +4 -0
  21. package/dist/op/local-executor.d.ts.map +1 -1
  22. package/dist/op/op-ir.d.ts +2 -0
  23. package/dist/op/op-ir.d.ts.map +1 -1
  24. package/dist/op/types.d.ts +7 -0
  25. package/dist/op/types.d.ts.map +1 -1
  26. package/package.json +1 -1
  27. package/src/cli/handlers/operator.test.ts +85 -0
  28. package/src/cli/handlers/operator.ts +116 -1
  29. package/src/cli/main.ts +9 -0
  30. package/src/cli/registry.ts +4 -0
  31. package/src/lifecycle/gate-ledger.ts +22 -0
  32. package/src/lint/rules/op/index.ts +4 -2
  33. package/src/lint/rules/op/ops015-gate-approval.test.ts +67 -0
  34. package/src/lint/rules/op/ops015-gate-approval.ts +57 -0
  35. package/src/op/builders.ts +10 -1
  36. package/src/op/gate-approval.test.ts +258 -0
  37. package/src/op/gate-approval.ts +258 -0
  38. package/src/op/gate.ts +148 -10
  39. package/src/op/index.ts +9 -1
  40. package/src/op/local-executor.test.ts +66 -0
  41. package/src/op/local-executor.ts +44 -1
  42. package/src/op/op-ir.ts +4 -0
  43. package/src/op/types.ts +7 -0
@@ -7,6 +7,7 @@
7
7
  import type { EffectReceiptRef } from "./receipt-store.js";
8
8
  import type { ActivityProfileName } from "./activity-profiles.js";
9
9
  import type { StepOutputRef } from "./step-output-ref.js";
10
+ import type { GateApproval } from "./gate-approval.js";
10
11
  export interface OpConfig {
11
12
  /** Kebab-case identifier. Names the Op's output directory (`dist/ops/<name>/`), and is the name `chant run <name>` and another Op's `depends` refer to. */
12
13
  name: string;
@@ -184,6 +185,12 @@ export interface GateStepBase {
184
185
  * phase publishes no digest is not thereby unrunnable.
185
186
  */
186
187
  plan?: string | StepOutputRef;
188
+ /**
189
+ * Quorum, roles and a policy for who may pass this gate (#2508). Absent,
190
+ * the gate passes on one approval, as every gate did before. See
191
+ * `./gate-approval.ts`.
192
+ */
193
+ approval?: GateApproval;
187
194
  }
188
195
  /**
189
196
  * A human approval decided against the gate ledger. The name lives on `gate`;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/op/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/op/types.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.78.0",
3
+ "version": "0.79.0",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -17,6 +17,7 @@ const readGateLedgerMock = vi.fn();
17
17
  const readRunLedgerMock = vi.fn();
18
18
  const pushLifecycleMock = vi.fn();
19
19
  const requireLifecycleLedgerMock = vi.fn();
20
+ const loadGatePolicyEvaluatorMock = vi.fn();
20
21
 
21
22
  vi.mock("../../op/operator", async () => {
22
23
  const actual = await vi.importActual<typeof import("../../op/operator")>("../../op/operator");
@@ -69,6 +70,11 @@ vi.mock("../../lifecycle/git", async () => {
69
70
  };
70
71
  });
71
72
 
73
+ vi.mock("../../op/gate-approval", async () => {
74
+ const actual = await vi.importActual<typeof import("../../op/gate-approval")>("../../op/gate-approval");
75
+ return { ...actual, loadGatePolicyEvaluator: (...args: unknown[]) => loadGatePolicyEvaluatorMock(...args) };
76
+ });
77
+
72
78
  // Imported after the mocks above are registered.
73
79
  const { runOperator, runOperatorStatus, runOperatorLog, runApprove } = await import("./operator");
74
80
 
@@ -979,3 +985,82 @@ describe("runApprove — a push that does not land is reported (#2309 review)",
979
985
  errSpy.mockRestore();
980
986
  });
981
987
  });
988
+
989
+ describe("runApprove — a gate with an approval policy (#2508)", () => {
990
+ const TEXT = "permit (principal is Chant::Agent, action, resource);\n";
991
+ const POLICY = { kind: "gate-policy" as const, lexicon: "cedar", name: "ship", version: "sha256:v1", text: TEXT };
992
+
993
+ function seedPolicyGate(mode: "log-only" | "enforce"): void {
994
+ readGateLedgerMock.mockResolvedValue({
995
+ resolutions: [],
996
+ pending: [{
997
+ version: 1, kind: "pending", op: "fountain-apply", gate: "rollout-gate",
998
+ timestamp: "2026-01-01T00:00:00.000Z", expiresAt: "2099-01-01T00:00:00.000Z", planDigest: PLAN_A,
999
+ approval: { quorum: { count: 2, roles: ["maintainer"] }, policy: POLICY, mode, context: { risk: "low" } },
1000
+ }],
1001
+ malformed: 0,
1002
+ });
1003
+ appendGateResolutionMock.mockImplementation(async (input: Record<string, unknown>) => ({
1004
+ commit: "sha",
1005
+ record: { version: 1, ...input },
1006
+ }));
1007
+ }
1008
+
1009
+ test("evaluates the policy for an agent's approval and records the decision next to it", async () => {
1010
+ seedPolicyGate("enforce");
1011
+ const evaluateGatePolicy = vi.fn().mockResolvedValue({ decision: "allow", determining: ["agent-low-risk"], errors: [] });
1012
+ loadGatePolicyEvaluatorMock.mockResolvedValue({ evaluateGatePolicy });
1013
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
1014
+
1015
+ expect(await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", actor: "release-bot", agent: true }))).toBe(0);
1016
+
1017
+ expect(loadGatePolicyEvaluatorMock).toHaveBeenCalledWith("cedar");
1018
+ expect(evaluateGatePolicy).toHaveBeenCalledWith(POLICY, {
1019
+ principal: { kind: "agent", name: "release-bot", roles: [] },
1020
+ action: "PassGate",
1021
+ resource: { op: "fountain-apply", gate: "rollout-gate" },
1022
+ context: { risk: "low", planDigest: PLAN_A },
1023
+ });
1024
+ expect(appendGateResolutionMock).toHaveBeenCalledWith(expect.objectContaining({
1025
+ approver: { kind: "agent" },
1026
+ policyDecision: {
1027
+ policy: "ship", version: "sha256:v1", mode: "enforce",
1028
+ decision: "allow", determining: ["agent-low-risk"], errors: [],
1029
+ },
1030
+ }));
1031
+ const out = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
1032
+ expect(out).toContain("In enforce mode this permit passes the gate on its own");
1033
+ expect(out).toContain("An agent's approval does not count toward the quorum");
1034
+ errSpy.mockRestore();
1035
+ });
1036
+
1037
+ test("records a human's roles, and reports quorum progress with a log-only decision", async () => {
1038
+ seedPolicyGate("log-only");
1039
+ loadGatePolicyEvaluatorMock.mockResolvedValue({
1040
+ evaluateGatePolicy: async () => ({ decision: "deny", determining: [], errors: [] }),
1041
+ });
1042
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
1043
+
1044
+ expect(await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", actor: "alex", roles: ["maintainer"] }))).toBe(0);
1045
+
1046
+ expect(appendGateResolutionMock).toHaveBeenCalledWith(expect.objectContaining({
1047
+ approver: { kind: "human", roles: ["maintainer"] },
1048
+ }));
1049
+ const out = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
1050
+ expect(out).toContain("log-only mode, so the decision is recorded and does not change the outcome");
1051
+ expect(out).toContain("Quorum: 1 of 2 human approval(s) with role maintainer for this plan (alex)");
1052
+ errSpy.mockRestore();
1053
+ });
1054
+
1055
+ test("refuses, and writes nothing, when the policy cannot be evaluated", async () => {
1056
+ seedPolicyGate("enforce");
1057
+ loadGatePolicyEvaluatorMock.mockRejectedValue(new Error("@intentius/chant-lexicon-cedar/gate-policy could not be loaded"));
1058
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
1059
+
1060
+ expect(await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", actor: "alex" }))).toBe(1);
1061
+
1062
+ expect(appendGateResolutionMock).not.toHaveBeenCalled();
1063
+ expect(errSpy.mock.calls.map((c) => String(c[0])).join("\n")).toContain('declares policy "ship", which could not be evaluated');
1064
+ errSpy.mockRestore();
1065
+ });
1066
+ });
@@ -38,6 +38,11 @@ import {
38
38
  resolveApprovalUrl, isApprovalUrl,
39
39
  } from "../../lifecycle/gate-ledger";
40
40
  import { isPlanDigest } from "../../lifecycle/plan-digest";
41
+ import {
42
+ gatePolicyRequest, loadGatePolicyEvaluator,
43
+ type GateApprover, type GatePolicyDecision, type GatePolicyEvaluator, type ResolvedGateApproval,
44
+ } from "../../op/gate-approval";
45
+ import { approverOf, tallyGateApprovals } from "../../op/gate";
41
46
  import { FAN_OUT_GATE_OP } from "../../op/gate-name";
42
47
  import { pushLifecycle, requireLifecycleLedger } from "../../lifecycle/git";
43
48
  import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
@@ -698,6 +703,8 @@ export async function runApprove(ctx: CommandContext): Promise<number> {
698
703
  url: ctx.args.url,
699
704
  plan: ctx.args.plan,
700
705
  allowSameOrigin: ctx.args.allowSameOrigin,
706
+ ...(ctx.args.roles ? { roles: ctx.args.roles } : {}),
707
+ ...(ctx.args.agent ? { agent: true } : {}),
701
708
  });
702
709
  if (!outcome.ok) return 1;
703
710
 
@@ -739,6 +746,15 @@ export interface GateApprovalOptions {
739
746
  * stays one command: run, read what it planned, approve it.
740
747
  */
741
748
  plan?: string;
749
+ /** `--role` (#2508) — roles the approver claims. */
750
+ roles?: string[];
751
+ /**
752
+ * `--agent` (#2508) — record this as an agent's approval. On a
753
+ * model-authored channel it is one whether or not this is set.
754
+ */
755
+ agent?: boolean;
756
+ /** Replaces the lexicon evaluator a gate policy is loaded from. For tests. */
757
+ evaluator?: GatePolicyEvaluator;
742
758
  }
743
759
 
744
760
  export type GateApprovalOutcome =
@@ -841,7 +857,8 @@ export async function recordGateApproval(
841
857
  // resolved over the same one has no second party in it. Refused by default,
842
858
  // the way #2300 refuses a plan nobody approved.
843
859
  const origin = opts.origin ?? currentGateOrigin();
844
- const standingForOrigin = latestPendingGate((await readGateLedger(opName)).pending, gate);
860
+ const ledger = await readGateLedger(opName);
861
+ const standingForOrigin = latestPendingGate(ledger.pending, gate);
845
862
  const refusal = sameOriginRefusal(standingForOrigin?.origin, origin);
846
863
  if (refusal && !opts.allowSameOrigin) {
847
864
  console.error(formatError({
@@ -862,11 +879,58 @@ export async function recordGateApproval(
862
879
  ? UNATTESTED_APPROVER
863
880
  : process.env.GITHUB_ACTOR ?? process.env.GITLAB_USER_LOGIN ?? process.env.USER ?? "unknown");
864
881
 
882
+ // #2508: who this is, as the gate's quorum and policy read it. A model on
883
+ // MCP or ACP is an agent whatever it claims, the same reasoning that gives
884
+ // it `UNATTESTED_APPROVER` for a name.
885
+ const approver: GateApprover = {
886
+ kind: opts.agent || isModelAuthored(origin) ? "agent" : "human",
887
+ ...(opts.roles && opts.roles.length > 0 ? { roles: [...new Set(opts.roles)] } : {}),
888
+ };
889
+
890
+ // #2508: a gate with a policy has it evaluated for this approval now, while
891
+ // the approver is known, against the context the gated run recorded. The
892
+ // decision is written next to the approval, so a pass can be traced to the
893
+ // rule that allowed it.
894
+ const approval = standingForOrigin?.approval;
895
+ let policyDecision: GatePolicyDecision | undefined;
896
+ if (approval?.policy) {
897
+ try {
898
+ const evaluator = opts.evaluator ?? (await loadGatePolicyEvaluator(approval.policy.lexicon));
899
+ const answer = await evaluator.evaluateGatePolicy(
900
+ approval.policy,
901
+ gatePolicyRequest({
902
+ op: opName,
903
+ gate,
904
+ resolvedBy,
905
+ approver,
906
+ ...(planDigest !== undefined ? { planDigest } : {}),
907
+ ...(approval.context ? { context: approval.context } : {}),
908
+ }),
909
+ );
910
+ policyDecision = {
911
+ policy: approval.policy.name,
912
+ version: approval.policy.version,
913
+ mode: approval.mode,
914
+ decision: answer.decision,
915
+ determining: answer.determining,
916
+ errors: answer.errors,
917
+ };
918
+ } catch (err) {
919
+ console.error(formatError({
920
+ message: `Gate "${gate}" on "${opName}" declares policy "${approval.policy.name}", which could not be evaluated: ${err instanceof Error ? err.message : String(err)}`,
921
+ hint: `Install the ${approval.policy.lexicon} lexicon in this project, or approve from a checkout that has it.`,
922
+ }));
923
+ return { ok: false };
924
+ }
925
+ }
926
+
865
927
  const { record } = await appendGateResolution({
866
928
  op: opName,
867
929
  gate,
868
930
  resolvedBy,
869
931
  timestamp: new Date().toISOString(),
932
+ approver,
933
+ ...(policyDecision ? { policyDecision } : {}),
870
934
  ...(opts.note ? { note: opts.note } : {}),
871
935
  ...(url ? { url } : {}),
872
936
  ...(planDigest !== undefined ? { planDigest } : {}),
@@ -889,5 +953,56 @@ export async function recordGateApproval(
889
953
  "differs refuses rather than applying it.",
890
954
  ));
891
955
  }
956
+ if (approval) {
957
+ for (const line of describeApprovalProgress(approval, record, ledger.resolutions, standingForOrigin)) {
958
+ console.error(formatInfo(line));
959
+ }
960
+ }
892
961
  return { ok: true, record };
893
962
  }
963
+
964
+ /**
965
+ * What a just-recorded approval did to a gate with an `approval` block
966
+ * (#2508): the policy's decision and whether it binds, then the quorum count
967
+ * for this plan. The run decides the gate from the ledger; these lines only
968
+ * say what it will find.
969
+ */
970
+ export function describeApprovalProgress(
971
+ approval: ResolvedGateApproval,
972
+ record: GateResolutionRecord,
973
+ prior: GateResolutionRecord[],
974
+ standing: PendingGateRecord | undefined,
975
+ ): string[] {
976
+ const lines: string[] = [];
977
+ const decision = record.policyDecision;
978
+ if (decision) {
979
+ const rules = decision.determining.length > 0 ? ` (${decision.determining.join(", ")})` : "";
980
+ const binds =
981
+ approval.mode === "enforce"
982
+ ? decision.decision === "allow"
983
+ ? "In enforce mode this permit passes the gate on its own."
984
+ : "In enforce mode only a permit passes the gate on its own, so this approval counts only toward the quorum."
985
+ : "The gate is in log-only mode, so the decision is recorded and does not change the outcome.";
986
+ lines.push(`Policy "${decision.policy}" ${decision.version}: ${decision.decision}${rules}. ${binds}`);
987
+ for (const error of decision.errors) lines.push(`Policy evaluation error: ${error}`);
988
+ }
989
+
990
+ const tally = tallyGateApprovals(
991
+ [...prior, record],
992
+ record.gate,
993
+ standing?.timestamp ?? new Date(0).toISOString(),
994
+ record.planDigest,
995
+ approval,
996
+ );
997
+ const roles = approval.quorum?.roles ? ` with role ${approval.quorum.roles.join(" or ")}` : "";
998
+ const who = tally.counted.map((r) => r.resolvedBy).join(", ") || "none yet";
999
+ lines.push(`Quorum: ${tally.counted.length} of ${tally.need} human approval(s)${roles} for this plan (${who}).`);
1000
+ if (!tally.counted.includes(record)) {
1001
+ lines.push(
1002
+ approverOf(record).kind === "agent"
1003
+ ? "An agent's approval does not count toward the quorum."
1004
+ : `This approval does not count toward the quorum: the approver claims none of its roles (${approval.quorum?.roles?.join(", ")}).`,
1005
+ );
1006
+ }
1007
+ return lines;
1008
+ }
package/src/cli/main.ts CHANGED
@@ -51,6 +51,7 @@ import type { LexiconPlugin } from "../lexicon";
51
51
  const BOOLEAN_FLAGS = new Set([
52
52
  "--help",
53
53
  "--agents",
54
+ "--agent",
54
55
  "--all-projects",
55
56
  "--force",
56
57
  "--fix",
@@ -419,6 +420,14 @@ export function parseArgs(args: string[]): ParsedArgs {
419
420
  result.note = args[++i];
420
421
  } else if (arg === "--expire") {
421
422
  result.expire = true;
423
+ } else if (arg === "--role") {
424
+ // #2508 — a role the approver holds, for a gate whose quorum names
425
+ // roles. Repeatable, and a comma list works too.
426
+ const value = args[++i] ?? "";
427
+ result.roles = [...(result.roles ?? []), ...value.split(",").map((r) => r.trim()).filter(Boolean)];
428
+ } else if (arg === "--agent") {
429
+ // #2508 — record the approval as an agent's rather than a person's.
430
+ result.agent = true;
422
431
  } else if (arg === "--allow-same-origin") {
423
432
  // chant#2384 — record a resolution the same-origin rule would refuse,
424
433
  // deliberately. Flagged on the record, not just accepted quietly.
@@ -364,6 +364,10 @@ export interface ParsedArgs {
364
364
  * on a model-authored channel.
365
365
  */
366
366
  allowSameOrigin?: boolean;
367
+ /** `chant approve --role <name>` (#2508) — roles the approver claims, for a gate whose quorum counts only some roles. Repeatable; a comma list also works. */
368
+ roles?: string[];
369
+ /** `chant approve --agent` (#2508) — record the approval as an agent's. An agent never counts toward a quorum; it passes a gate only through a policy permit in `enforce` mode. */
370
+ agent?: boolean;
367
371
  /** `chant approve <op> <gate> --plan <digest>` (#2300) — the plan this approval is for, as `sha256:<64 hex>`. Omitted, the digest is taken from the gate's standing pending fact, which is the plan the run that stopped at the gate actually produced; pass it to approve a plan explicitly, or to approve one before any run has recorded a pending fact. */
368
372
  plan?: string;
369
373
  /** `chant operator log --op <name>` (#2029) — restrict the tick history to one ConvergeOp by name. Omitted, every discovered ConvergeOp's ticks are merged into one timeline. */
@@ -43,6 +43,7 @@
43
43
  */
44
44
  import { sortedJsonReplacer } from "../utils";
45
45
  import { currentGateOrigin, type GateOrigin } from "./gate-origin";
46
+ import type { GateApprover, GatePolicyDecision, ResolvedGateApproval } from "../op/gate-approval";
46
47
  import { readBlobFromPath, readPathSha, readBlobBySha, writeBlobToPath, RefCASConflictError } from "./git";
47
48
 
48
49
  const DIR = "_gates";
@@ -160,6 +161,21 @@ export interface GateResolutionRecord {
160
161
  * later should see which approvals had a second party and which did not.
161
162
  */
162
163
  sameOriginOverride?: boolean;
164
+ /**
165
+ * Whether a person or an agent recorded this, and the roles they claimed
166
+ * (#2508). Only a human approval counts toward a gate's quorum; an agent
167
+ * passes a gate only through a policy permit in `enforce` mode.
168
+ *
169
+ * Absent on every resolution written before #2508, and read as a human with
170
+ * no roles. Roles are claimed, not verified, the same local trust boundary
171
+ * as `resolvedBy`.
172
+ */
173
+ approver?: GateApprover;
174
+ /**
175
+ * The gate policy's decision for this approval (#2508), evaluated when it
176
+ * was recorded. Present only when the gate declared a policy.
177
+ */
178
+ policyDecision?: GatePolicyDecision;
163
179
  }
164
180
 
165
181
  export type GateResolutionInput = Omit<GateResolutionRecord, "version" | "kind">;
@@ -212,6 +228,12 @@ export interface PendingGateRecord {
212
228
  * and resolved over MCP has one author, not two.
213
229
  */
214
230
  origin?: GateOrigin;
231
+ /**
232
+ * The gate's quorum and policy (#2508), with its context resolved against
233
+ * this run. `chant approve` reads it to evaluate the policy against the plan
234
+ * the run produced, and `chant operator status` to show quorum progress.
235
+ */
236
+ approval?: ResolvedGateApproval;
215
237
  }
216
238
 
217
239
  export type PendingGateInput = Omit<PendingGateRecord, "version" | "kind">;
@@ -12,12 +12,14 @@ import type { PostSynthCheck } from "../../post-synth";
12
12
  import { ops012 } from "./ops012-activity-contract";
13
13
  import { ops013 } from "./ops013-step-output-ref";
14
14
  import { ops014 } from "./ops014-converge-rule-refusals";
15
+ import { ops015 } from "./ops015-gate-approval";
15
16
 
16
17
  export { ops012 } from "./ops012-activity-contract";
17
18
  export { ops013 } from "./ops013-step-output-ref";
18
19
  export { ops014 } from "./ops014-converge-rule-refusals";
20
+ export { ops015 } from "./ops015-gate-approval";
19
21
 
20
- /** Core's own post-synth checks over the Op model — OPS012, OPS013, OPS014. */
22
+ /** Core's own post-synth checks over the Op model — OPS012, OPS013, OPS014, OPS015. */
21
23
  export function coreOpChecks(): PostSynthCheck[] {
22
- return [ops012, ops013, ops014];
24
+ return [ops012, ops013, ops014, ops015];
23
25
  }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * OPS015 (#2508) — a gate's approval block, checked over the build output.
3
+ */
4
+
5
+ import { describe, test, expect } from "vitest";
6
+ import type { PostSynthContext } from "../../post-synth";
7
+ import { DECLARABLE_MARKER } from "../../../declarable";
8
+ import { gatePolicyVersion } from "../../../op/gate-approval";
9
+ import { ops015 } from "./ops015-gate-approval";
10
+
11
+ const TEXT = "permit (principal, action, resource);\n";
12
+ const POLICY = { kind: "gate-policy", lexicon: "cedar", name: "ship", version: gatePolicyVersion(TEXT), text: TEXT };
13
+
14
+ function ctxWith(phases: unknown[], onFailure?: unknown[]): PostSynthContext {
15
+ const entities = new Map<string, unknown>([["release", {
16
+ [DECLARABLE_MARKER]: true,
17
+ entityType: "Chant::Op",
18
+ lexicon: "chant",
19
+ kind: "resource",
20
+ props: { name: "release", overview: "test", phases, ...(onFailure ? { onFailure } : {}) },
21
+ attributes: {},
22
+ }]]);
23
+ return {
24
+ outputs: new Map([["chant", ""]]),
25
+ entities: entities as Map<string, never>,
26
+ buildResult: { outputs: new Map([["chant", ""]]), entities: entities as Map<string, never>, warnings: [], errors: [], sourceFileCount: 1 },
27
+ };
28
+ }
29
+
30
+ const gateStep = (approval: unknown) => ({ kind: "gate", gate: "ship", approval });
31
+
32
+ describe("OPS015", () => {
33
+ test("a well-formed approval block passes", () => {
34
+ const ctx = ctxWith([{ name: "Apply", steps: [gateStep({ quorum: { count: 2 }, policy: POLICY, mode: "enforce" })] }]);
35
+ expect(ops015.check(ctx)).toEqual([]);
36
+ });
37
+
38
+ test("a gate with no approval block is not this rule's concern", () => {
39
+ expect(ops015.check(ctxWith([{ name: "Apply", steps: [{ kind: "gate", gate: "ship" }] }]))).toEqual([]);
40
+ });
41
+
42
+ test("a policy that is not a gate policy set is refused, naming the op and gate", () => {
43
+ const ctx = ctxWith([{ name: "Apply", steps: [gateStep({ policy: "dist/ship.cedar" })] }]);
44
+ const [diag] = ops015.check(ctx);
45
+ expect(diag?.checkId).toBe("OPS015");
46
+ expect(diag?.severity).toBe("error");
47
+ expect(diag?.message).toContain('Op "release", gate "ship"');
48
+ expect(diag?.message).toContain("does not resolve to a gate policy set");
49
+ });
50
+
51
+ test("a policy whose text was edited after it was stamped is refused", () => {
52
+ const ctx = ctxWith([{ name: "Apply", steps: [gateStep({ policy: { ...POLICY, text: "forbid (principal, action, resource);\n" } })] }]);
53
+ expect(ops015.check(ctx)[0]?.message).toContain("not the digest of its text");
54
+ });
55
+
56
+ test("reports every problem, including a gate nested in an effect step and one in onFailure", () => {
57
+ const ctx = ctxWith(
58
+ [{ name: "Apply", steps: [{ kind: "effect", receipt: {}, steps: [gateStep({ quorum: { count: 0 }, mode: "enforce" })] }] }],
59
+ [{ name: "Rollback", steps: [gateStep({ mode: "sometimes" })] }],
60
+ );
61
+ const messages = ops015.check(ctx).map((d) => d.message);
62
+ expect(messages).toHaveLength(3);
63
+ expect(messages.join("\n")).toContain("quorum.count");
64
+ expect(messages.join("\n")).toContain("needs an `approval.policy`");
65
+ expect(messages.join("\n")).toContain("approval.mode");
66
+ });
67
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * OPS015: a gate's `approval` block (#2508) must be well formed. The quorum
3
+ * count is an integer of at least 1, roles are named, the mode is `log-only`
4
+ * or `enforce`, `enforce` has a policy to enforce, and `policy` resolves to a
5
+ * gate policy set a lexicon rendered, with a version that is still the digest
6
+ * of its text.
7
+ *
8
+ * `gate()` already throws on the first of these. This check runs over the
9
+ * build output, so it also covers a gate step written without the builder and
10
+ * reports every problem rather than the first.
11
+ */
12
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "../../post-synth";
13
+ import type { OpConfig, PhaseDefinition, StepDefinition } from "../../../op/types";
14
+ import { gateApprovalProblems } from "../../../op/gate-approval";
15
+ import { gateName } from "../../../op/gate-name";
16
+ import { isOpEntity } from "./support";
17
+
18
+ function* gateSteps(phases: PhaseDefinition[] | undefined): Generator<StepDefinition & { kind: "gate" }> {
19
+ for (const phase of phases ?? []) {
20
+ for (const step of phase.steps ?? []) {
21
+ if (step.kind === "gate") yield step;
22
+ else if (step.kind === "effect") {
23
+ for (const nested of step.steps ?? []) if (nested.kind === "gate") yield nested;
24
+ }
25
+ }
26
+ }
27
+ }
28
+
29
+ export const ops015: PostSynthCheck = {
30
+ id: "OPS015",
31
+ description:
32
+ "A gate's approval block is well formed: quorum count at least 1, roles named, mode log-only or enforce, enforce has a policy, and policy resolves to a gate policy set whose version is the digest of its text",
33
+
34
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
35
+ const diagnostics: PostSynthDiagnostic[] = [];
36
+ for (const [entityKey, entity] of ctx.entities) {
37
+ if (!isOpEntity(entity)) continue;
38
+ const rec = entity as unknown as Record<string, unknown>;
39
+ const props = ((entity as { props?: Record<string, unknown> }).props ?? {}) as unknown as OpConfig;
40
+ if (!Array.isArray(props.phases)) continue;
41
+
42
+ for (const step of [...gateSteps(props.phases), ...gateSteps(props.onFailure)]) {
43
+ if (step.approval === undefined) continue;
44
+ for (const problem of gateApprovalProblems(step.approval)) {
45
+ diagnostics.push({
46
+ checkId: "OPS015",
47
+ severity: "error",
48
+ message: `Op "${props.name}", gate "${gateName(step)}": ${problem}`,
49
+ entity: entityKey,
50
+ lexicon: typeof rec.lexicon === "string" ? rec.lexicon : undefined,
51
+ });
52
+ }
53
+ }
54
+ }
55
+ return diagnostics;
56
+ },
57
+ };
@@ -2,6 +2,7 @@ import { OpResource } from "./resource";
2
2
  import type { OpConfig, PhaseDefinition, StepDefinition, ActivityStep, GateStep, EffectStep } from "./types";
3
3
  import { isEffectReceipt, type EffectReceiptDeclaration } from "../effect-receipt";
4
4
  import { receiptCheckInput } from "./receipt-store";
5
+ import { gateApprovalProblems } from "./gate-approval";
5
6
  import { makeOutProxy, type StepOutputRef, type WithStepRefs } from "./step-output-ref";
6
7
  import type { ChantBuildArgs } from "./activities/build";
7
8
  import type { ShellCmdArgs } from "./activities/shell";
@@ -112,17 +113,25 @@ export function activity(
112
113
  * Plan phase's own digest, `plan.out.planDigest`. Then a resolution counts
113
114
  * only for that plan, and a run whose fresh plan differs is refused by name
114
115
  * instead of applying a change nobody approved. See {@link GateStep.plan}.
116
+ *
117
+ * Pass `approval` to require a quorum of human approvers, or to evaluate a
118
+ * policy for each approval (#2508). See {@link GateStep.approval}.
115
119
  */
116
120
  export function gate(
117
121
  name: string,
118
- opts?: { timeout?: string; description?: string; plan?: GateStep["plan"] },
122
+ opts?: { timeout?: string; description?: string; plan?: GateStep["plan"]; approval?: GateStep["approval"] },
119
123
  ): GateStep {
124
+ if (opts?.approval !== undefined) {
125
+ const problems = gateApprovalProblems(opts.approval);
126
+ if (problems.length > 0) throw new Error(`gate(${JSON.stringify(name)}): ${problems[0]}`);
127
+ }
120
128
  return {
121
129
  kind: "gate",
122
130
  gate: name,
123
131
  ...(opts?.timeout ? { timeout: opts.timeout } : {}),
124
132
  ...(opts?.description ? { description: opts.description } : {}),
125
133
  ...(opts?.plan !== undefined ? { plan: opts.plan } : {}),
134
+ ...(opts?.approval !== undefined ? { approval: opts.approval } : {}),
126
135
  };
127
136
  }
128
137