@intentius/chant 0.77.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.
- package/dist/cli/handlers/operator.d.ts +17 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +4 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +22 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lint/rules/op/index.d.ts +2 -1
- package/dist/lint/rules/op/index.d.ts.map +1 -1
- package/dist/lint/rules/op/ops015-gate-approval.d.ts +14 -0
- package/dist/lint/rules/op/ops015-gate-approval.d.ts.map +1 -0
- package/dist/op/builders.d.ts +4 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/gate-approval.d.ts +142 -0
- package/dist/op/gate-approval.d.ts.map +1 -0
- package/dist/op/gate.d.ts +54 -0
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +4 -1
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +4 -0
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/op-ir.d.ts +2 -0
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/types.d.ts +7 -0
- package/dist/op/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/operator.test.ts +85 -0
- package/src/cli/handlers/operator.ts +116 -1
- package/src/cli/main.ts +9 -0
- package/src/cli/registry.ts +4 -0
- package/src/lifecycle/gate-ledger.ts +22 -0
- package/src/lint/rules/op/index.ts +4 -2
- package/src/lint/rules/op/ops015-gate-approval.test.ts +67 -0
- package/src/lint/rules/op/ops015-gate-approval.ts +57 -0
- package/src/op/builders.ts +10 -1
- package/src/op/gate-approval.test.ts +258 -0
- package/src/op/gate-approval.ts +258 -0
- package/src/op/gate.ts +148 -10
- package/src/op/index.ts +9 -1
- package/src/op/local-executor.test.ts +66 -0
- package/src/op/local-executor.ts +44 -1
- package/src/op/op-ir.ts +4 -0
- package/src/op/types.ts +7 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gate approval policy (#2508): quorum, roles, and a policy decision that is
|
|
3
|
+
* logged or enforced. The rules are decided by `evaluateGate`, driven here
|
|
4
|
+
* through `memoryGateLedgerPort`; the Cedar evaluation itself is the cedar
|
|
5
|
+
* lexicon's and is tested there.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, test, expect } from "vitest";
|
|
8
|
+
import { evaluateGate, memoryGateLedgerPort, tallyGateApprovals } from "./gate";
|
|
9
|
+
import { gate } from "./builders";
|
|
10
|
+
import { gateApprovalProblems, gatePolicyRequest, gatePolicyVersion, type GatePolicyRef, type ResolvedGateApproval } from "./gate-approval";
|
|
11
|
+
import type { GateResolutionRecord, PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
12
|
+
|
|
13
|
+
const PLAN_A = `sha256:${"a".repeat(64)}`;
|
|
14
|
+
const PLAN_B = `sha256:${"b".repeat(64)}`;
|
|
15
|
+
const TEXT = 'permit (principal is Chant::Agent, action, resource) when { context.risk == "low" };\n';
|
|
16
|
+
const POLICY: GatePolicyRef = { kind: "gate-policy", lexicon: "cedar", name: "ship", version: gatePolicyVersion(TEXT), text: TEXT };
|
|
17
|
+
|
|
18
|
+
const PENDING: PendingGateRecord = {
|
|
19
|
+
version: 1, kind: "pending", op: "release", gate: "ship",
|
|
20
|
+
timestamp: "2026-09-01T00:00:00.000Z", expiresAt: "2026-09-03T00:00:00.000Z", planDigest: PLAN_A,
|
|
21
|
+
};
|
|
22
|
+
const NOW = "2026-09-01T12:00:00.000Z";
|
|
23
|
+
|
|
24
|
+
function approval(overrides: Partial<GateResolutionRecord> & { resolvedBy: string; timestamp: string }): GateResolutionRecord {
|
|
25
|
+
return { version: 1, op: "release", gate: "ship", planDigest: PLAN_A, approver: { kind: "human" }, ...overrides };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function allow(mode: "log-only" | "enforce" = "enforce"): GateResolutionRecord["policyDecision"] {
|
|
29
|
+
return { policy: "ship", version: POLICY.version, mode, decision: "allow", determining: ["ship-0"], errors: [] };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function decide(resolutions: GateResolutionRecord[], approvalBlock: ResolvedGateApproval, planDigest = PLAN_A) {
|
|
33
|
+
const port = memoryGateLedgerPort({ resolutions, pending: [{ ...PENDING, approval: approvalBlock, planDigest }] });
|
|
34
|
+
const check = await evaluateGate(port, { op: "release", gate: "ship", planDigest, approval: approvalBlock, now: NOW });
|
|
35
|
+
return { check, port };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("gate approval — quorum (#2508)", () => {
|
|
39
|
+
const twoMaintainers: ResolvedGateApproval = { quorum: { count: 2, roles: ["maintainer"] }, mode: "log-only" };
|
|
40
|
+
|
|
41
|
+
test("quorum not met: one approval of two leaves the gate standing and reports progress", async () => {
|
|
42
|
+
const { check, port } = await decide(
|
|
43
|
+
[approval({ resolvedBy: "alex", timestamp: "2026-09-01T01:00:00.000Z", approver: { kind: "human", roles: ["maintainer"] } })],
|
|
44
|
+
twoMaintainers,
|
|
45
|
+
);
|
|
46
|
+
expect(check.satisfied).toBe(false);
|
|
47
|
+
if (check.satisfied) return;
|
|
48
|
+
expect(check.quorum).toEqual({ approvers: ["alex"], need: 2 });
|
|
49
|
+
// The standing fact still describes this gate, so nothing new is appended.
|
|
50
|
+
expect(check.recorded).toBe(false);
|
|
51
|
+
expect(port.appended).toHaveLength(0);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("quorum met: two distinct maintainers pass the gate, and both are named", async () => {
|
|
55
|
+
const { check } = await decide(
|
|
56
|
+
[
|
|
57
|
+
approval({ resolvedBy: "alex", timestamp: "2026-09-01T01:00:00.000Z", approver: { kind: "human", roles: ["maintainer"] } }),
|
|
58
|
+
approval({ resolvedBy: "sam", timestamp: "2026-09-01T02:00:00.000Z", approver: { kind: "human", roles: ["maintainer", "sre"] } }),
|
|
59
|
+
],
|
|
60
|
+
twoMaintainers,
|
|
61
|
+
);
|
|
62
|
+
expect(check.satisfied).toBe(true);
|
|
63
|
+
if (!check.satisfied) return;
|
|
64
|
+
expect(check.via).toBe("quorum");
|
|
65
|
+
expect(check.approvals?.map((r) => r.resolvedBy)).toEqual(["alex", "sam"]);
|
|
66
|
+
expect(check.resolution.resolvedBy).toBe("sam");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("the same person approving twice counts once", async () => {
|
|
70
|
+
const { check } = await decide(
|
|
71
|
+
[
|
|
72
|
+
approval({ resolvedBy: "alex", timestamp: "2026-09-01T01:00:00.000Z", approver: { kind: "human", roles: ["maintainer"] } }),
|
|
73
|
+
approval({ resolvedBy: "alex", timestamp: "2026-09-01T02:00:00.000Z", approver: { kind: "human", roles: ["maintainer"] } }),
|
|
74
|
+
],
|
|
75
|
+
twoMaintainers,
|
|
76
|
+
);
|
|
77
|
+
expect(check.satisfied).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("an approver without one of the quorum's roles does not count", async () => {
|
|
81
|
+
const { check } = await decide(
|
|
82
|
+
[
|
|
83
|
+
approval({ resolvedBy: "alex", timestamp: "2026-09-01T01:00:00.000Z", approver: { kind: "human", roles: ["maintainer"] } }),
|
|
84
|
+
approval({ resolvedBy: "pat", timestamp: "2026-09-01T02:00:00.000Z", approver: { kind: "human", roles: ["viewer"] } }),
|
|
85
|
+
],
|
|
86
|
+
twoMaintainers,
|
|
87
|
+
);
|
|
88
|
+
expect(check.satisfied).toBe(false);
|
|
89
|
+
if (check.satisfied) return;
|
|
90
|
+
expect(check.quorum?.approvers).toEqual(["alex"]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("a changed plan invalidates every collected approval", async () => {
|
|
94
|
+
const { check, port } = await decide(
|
|
95
|
+
[
|
|
96
|
+
approval({ resolvedBy: "alex", timestamp: "2026-09-01T01:00:00.000Z", approver: { kind: "human", roles: ["maintainer"] } }),
|
|
97
|
+
approval({ resolvedBy: "sam", timestamp: "2026-09-01T02:00:00.000Z", approver: { kind: "human", roles: ["maintainer"] } }),
|
|
98
|
+
],
|
|
99
|
+
twoMaintainers,
|
|
100
|
+
PLAN_B,
|
|
101
|
+
);
|
|
102
|
+
expect(check.satisfied).toBe(false);
|
|
103
|
+
if (check.satisfied) return;
|
|
104
|
+
expect(check.mismatch?.approved).toBe(PLAN_A);
|
|
105
|
+
expect(check.mismatch?.planned).toBe(PLAN_B);
|
|
106
|
+
expect(check.quorum).toEqual({ approvers: [], need: 2 });
|
|
107
|
+
expect(port.appended).toHaveLength(0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("an approval older than the standing pending fact does not count", async () => {
|
|
111
|
+
const { check } = await decide(
|
|
112
|
+
[approval({ resolvedBy: "alex", timestamp: "2026-08-31T00:00:00.000Z" })],
|
|
113
|
+
{ quorum: { count: 1 }, mode: "log-only" },
|
|
114
|
+
);
|
|
115
|
+
expect(check.satisfied).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("a resolution written before #2508 reads as a human, and one from a model channel as an agent", () => {
|
|
119
|
+
const legacy: GateResolutionRecord = { version: 1, op: "release", gate: "ship", resolvedBy: "alex", timestamp: "2026-09-01T01:00:00.000Z", planDigest: PLAN_A };
|
|
120
|
+
const fromMcp: GateResolutionRecord = { ...legacy, resolvedBy: "unattested", origin: "mcp", timestamp: "2026-09-01T02:00:00.000Z" };
|
|
121
|
+
const tally = tallyGateApprovals([legacy, fromMcp], "ship", PENDING.timestamp, PLAN_A, { quorum: { count: 2 }, mode: "log-only" });
|
|
122
|
+
expect(tally.counted.map((r) => r.resolvedBy)).toEqual(["alex"]);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("gate approval — policy decisions (#2508)", () => {
|
|
127
|
+
test("log-only: an agent's permit is recorded but does not pass the gate", async () => {
|
|
128
|
+
const block: ResolvedGateApproval = { policy: POLICY, mode: "log-only", context: { risk: "low" } };
|
|
129
|
+
const { check } = await decide(
|
|
130
|
+
[approval({ resolvedBy: "release-bot", timestamp: "2026-09-01T01:00:00.000Z", approver: { kind: "agent" }, policyDecision: allow("log-only") })],
|
|
131
|
+
block,
|
|
132
|
+
);
|
|
133
|
+
expect(check.satisfied).toBe(false);
|
|
134
|
+
if (check.satisfied) return;
|
|
135
|
+
expect(check.quorum).toEqual({ approvers: [], need: 1 });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("log-only: a human's approval passes even when the policy recorded a deny", async () => {
|
|
139
|
+
const block: ResolvedGateApproval = { policy: POLICY, mode: "log-only" };
|
|
140
|
+
const { check } = await decide(
|
|
141
|
+
[approval({
|
|
142
|
+
resolvedBy: "alex", timestamp: "2026-09-01T01:00:00.000Z",
|
|
143
|
+
policyDecision: { ...allow("log-only")!, decision: "deny", determining: [] },
|
|
144
|
+
})],
|
|
145
|
+
block,
|
|
146
|
+
);
|
|
147
|
+
expect(check.satisfied).toBe(true);
|
|
148
|
+
if (!check.satisfied) return;
|
|
149
|
+
expect(check.via).toBe("quorum");
|
|
150
|
+
expect(check.resolution.policyDecision?.decision).toBe("deny");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("enforce: an agent's permit passes the gate on its own", async () => {
|
|
154
|
+
const block: ResolvedGateApproval = { quorum: { count: 2 }, policy: POLICY, mode: "enforce" };
|
|
155
|
+
const { check } = await decide(
|
|
156
|
+
[approval({ resolvedBy: "release-bot", timestamp: "2026-09-01T01:00:00.000Z", approver: { kind: "agent" }, policyDecision: allow() })],
|
|
157
|
+
block,
|
|
158
|
+
);
|
|
159
|
+
expect(check.satisfied).toBe(true);
|
|
160
|
+
if (!check.satisfied) return;
|
|
161
|
+
expect(check.via).toBe("policy");
|
|
162
|
+
expect(check.resolution.resolvedBy).toBe("release-bot");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("enforce: a permit recorded under an older policy version does not count", async () => {
|
|
166
|
+
const block: ResolvedGateApproval = { policy: POLICY, mode: "enforce" };
|
|
167
|
+
const { check } = await decide(
|
|
168
|
+
[approval({
|
|
169
|
+
resolvedBy: "release-bot", timestamp: "2026-09-01T01:00:00.000Z", approver: { kind: "agent" },
|
|
170
|
+
policyDecision: { ...allow()!, version: gatePolicyVersion("permit (principal, action, resource);\n") },
|
|
171
|
+
})],
|
|
172
|
+
block,
|
|
173
|
+
);
|
|
174
|
+
expect(check.satisfied).toBe(false);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("enforce: a deny-by-default policy keeps the old behaviour, where only a human's approval counts", async () => {
|
|
178
|
+
const block: ResolvedGateApproval = { policy: POLICY, mode: "enforce" };
|
|
179
|
+
const deny = { ...allow()!, decision: "deny" as const, determining: ["floor"] };
|
|
180
|
+
const agentOnly = await decide(
|
|
181
|
+
[approval({ resolvedBy: "release-bot", timestamp: "2026-09-01T01:00:00.000Z", approver: { kind: "agent" }, policyDecision: deny })],
|
|
182
|
+
block,
|
|
183
|
+
);
|
|
184
|
+
expect(agentOnly.check.satisfied).toBe(false);
|
|
185
|
+
|
|
186
|
+
const withHuman = await decide(
|
|
187
|
+
[
|
|
188
|
+
approval({ resolvedBy: "release-bot", timestamp: "2026-09-01T01:00:00.000Z", approver: { kind: "agent" }, policyDecision: deny }),
|
|
189
|
+
approval({ resolvedBy: "alex", timestamp: "2026-09-01T02:00:00.000Z", policyDecision: deny }),
|
|
190
|
+
],
|
|
191
|
+
block,
|
|
192
|
+
);
|
|
193
|
+
expect(withHuman.check.satisfied).toBe(true);
|
|
194
|
+
if (!withHuman.check.satisfied) return;
|
|
195
|
+
expect(withHuman.check.via).toBe("quorum");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("a changed approval block re-records the pending fact, so approve evaluates the current context", async () => {
|
|
199
|
+
const port = memoryGateLedgerPort({
|
|
200
|
+
pending: [{ ...PENDING, approval: { policy: POLICY, mode: "log-only", context: { risk: "high" } } }],
|
|
201
|
+
});
|
|
202
|
+
const check = await evaluateGate(port, {
|
|
203
|
+
op: "release", gate: "ship", planDigest: PLAN_A, now: NOW,
|
|
204
|
+
approval: { policy: POLICY, mode: "log-only", context: { risk: "low" } },
|
|
205
|
+
});
|
|
206
|
+
expect(check.satisfied).toBe(false);
|
|
207
|
+
if (check.satisfied) return;
|
|
208
|
+
expect(check.recorded).toBe(true);
|
|
209
|
+
expect(port.appended[0]?.approval?.context).toEqual({ risk: "low" });
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("a gate with no approval block decides exactly as before", async () => {
|
|
213
|
+
const port = memoryGateLedgerPort({
|
|
214
|
+
resolutions: [approval({ resolvedBy: "release-bot", timestamp: "2026-09-01T01:00:00.000Z", approver: { kind: "agent" } })],
|
|
215
|
+
pending: [PENDING],
|
|
216
|
+
});
|
|
217
|
+
const check = await evaluateGate(port, { op: "release", gate: "ship", planDigest: PLAN_A, now: NOW });
|
|
218
|
+
expect(check.satisfied).toBe(true);
|
|
219
|
+
if (!check.satisfied) return;
|
|
220
|
+
expect(check.via).toBeUndefined();
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("gate approval — the request a policy is asked (#2508)", () => {
|
|
225
|
+
test("carries the approver, the gate and the context, with the plan digest", () => {
|
|
226
|
+
expect(gatePolicyRequest({
|
|
227
|
+
op: "release", gate: "ship", resolvedBy: "release-bot", approver: { kind: "agent", roles: ["deployer"] },
|
|
228
|
+
planDigest: PLAN_A, context: { risk: "low" },
|
|
229
|
+
})).toEqual({
|
|
230
|
+
principal: { kind: "agent", name: "release-bot", roles: ["deployer"] },
|
|
231
|
+
action: "PassGate",
|
|
232
|
+
resource: { op: "release", gate: "ship" },
|
|
233
|
+
context: { risk: "low", planDigest: PLAN_A },
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe("gate approval — the approval block is checked where it is written (#2508)", () => {
|
|
239
|
+
test("a well-formed block passes, and lands on the step", () => {
|
|
240
|
+
const step = gate("ship", { approval: { quorum: { count: 2, roles: ["maintainer"] }, policy: POLICY, mode: "enforce", context: { risk: "low" } } });
|
|
241
|
+
expect(step.approval?.quorum?.count).toBe(2);
|
|
242
|
+
expect(gateApprovalProblems(step.approval)).toEqual([]);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test.each([
|
|
246
|
+
[{ quorum: { count: 0 } }, "quorum.count"],
|
|
247
|
+
[{ quorum: { count: 2, roles: [] } }, "quorum.roles"],
|
|
248
|
+
[{ mode: "enforce" }, "needs an `approval.policy`"],
|
|
249
|
+
[{ mode: "strict" }, "approval.mode"],
|
|
250
|
+
[{ policy: { name: "ship" } }, "does not resolve to a gate policy set"],
|
|
251
|
+
[{ policy: { ...POLICY, version: "sha256:stale" } }, "not the digest of its text"],
|
|
252
|
+
[{ context: { risk: "low" } }, "declares none"],
|
|
253
|
+
[{ policy: POLICY, context: { risk: { level: 1 } } }, "approval.context.risk"],
|
|
254
|
+
])("rejects %j", (block, expected) => {
|
|
255
|
+
expect(gateApprovalProblems(block).join("\n")).toContain(expected);
|
|
256
|
+
expect(() => gate("ship", { approval: block as never })).toThrow(expected);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gate approval policy as data (#2508).
|
|
3
|
+
*
|
|
4
|
+
* A gate used to pass on one recorded approval. This module lets a gate say
|
|
5
|
+
* more than that, as data that survives into the build output:
|
|
6
|
+
*
|
|
7
|
+
* - `quorum`: how many distinct human approvers the gate needs, and
|
|
8
|
+
* optionally which roles count toward it.
|
|
9
|
+
* - `policy`: a policy set a lexicon rendered (the cedar lexicon's
|
|
10
|
+
* `gatePolicy()`), evaluated for each approval as a `PassGate` request.
|
|
11
|
+
* - `mode`: `"log-only"` records the policy's decision next to the approval
|
|
12
|
+
* and lets only the quorum bind. `"enforce"` lets a permit pass the gate on
|
|
13
|
+
* its own, which is how an agent principal passes a low-risk plan.
|
|
14
|
+
* - `context`: plan attributes handed to the policy (risk labels, changed
|
|
15
|
+
* paths), resolved at run time like a gate's `plan`.
|
|
16
|
+
*
|
|
17
|
+
* Core does not know Cedar. A {@link GatePolicyRef} names the lexicon that
|
|
18
|
+
* made it, and {@link loadGatePolicyEvaluator} imports that lexicon's
|
|
19
|
+
* `gate-policy` module by the same `@intentius/chant-lexicon-<name>/...`
|
|
20
|
+
* convention the activity registry uses (`./activity-registry.ts`).
|
|
21
|
+
*
|
|
22
|
+
* The policy is evaluated when an approval is recorded (`chant approve`),
|
|
23
|
+
* because that is the moment the approver is known. The decision is written
|
|
24
|
+
* on the resolution with the policy's version, and a run counts a recorded
|
|
25
|
+
* permit only while the gate still declares that same version. Editing the
|
|
26
|
+
* policy invalidates every permit recorded under the old one, the way a new
|
|
27
|
+
* plan invalidates every approval recorded for the old plan (#2300).
|
|
28
|
+
*
|
|
29
|
+
* The trust boundary is unchanged from `../lifecycle/gate-ledger.ts`: a
|
|
30
|
+
* resolution is a local fact, not a signed one, and roles on it are what the
|
|
31
|
+
* approver claimed.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { createHash } from "node:crypto";
|
|
35
|
+
import { isStepOutputRef, type StepOutputRef } from "./step-output-ref";
|
|
36
|
+
|
|
37
|
+
/** What a policy's decision does to the gate. */
|
|
38
|
+
export type GateApprovalMode = "log-only" | "enforce";
|
|
39
|
+
|
|
40
|
+
export const GATE_APPROVAL_MODES: readonly GateApprovalMode[] = ["log-only", "enforce"];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A policy set a lexicon rendered for a gate — plain data, so it lands in the
|
|
44
|
+
* Op's build output unchanged. Build one with the owning lexicon's helper
|
|
45
|
+
* (the cedar lexicon's `gatePolicy()`) rather than by hand.
|
|
46
|
+
*/
|
|
47
|
+
export interface GatePolicyRef {
|
|
48
|
+
kind: "gate-policy";
|
|
49
|
+
/** The lexicon whose `gate-policy` module evaluates {@link GatePolicyRef.text}. */
|
|
50
|
+
lexicon: string;
|
|
51
|
+
/** A name for the policy set, carried into every recorded decision. */
|
|
52
|
+
name: string;
|
|
53
|
+
/** Content digest of {@link GatePolicyRef.text}. A recorded permit counts only under the version it was evaluated against. */
|
|
54
|
+
version: string;
|
|
55
|
+
/** The policy set, in the lexicon's own language. */
|
|
56
|
+
text: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** How many distinct human approvers a gate needs, and which roles count. */
|
|
60
|
+
export interface GateQuorum {
|
|
61
|
+
/** At least 1. */
|
|
62
|
+
count: number;
|
|
63
|
+
/** When set, only an approver holding one of these roles counts toward {@link GateQuorum.count}. */
|
|
64
|
+
roles?: string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A value a gate hands its policy as context. */
|
|
68
|
+
export type GateContextValue = string | number | boolean | string[] | StepOutputRef;
|
|
69
|
+
|
|
70
|
+
/** The `approval` block on a `gate` step. */
|
|
71
|
+
export interface GateApproval {
|
|
72
|
+
quorum?: GateQuorum;
|
|
73
|
+
policy?: GatePolicyRef;
|
|
74
|
+
/** Default `"log-only"`, so a new policy is observed against real gate traffic before it binds. */
|
|
75
|
+
mode?: GateApprovalMode;
|
|
76
|
+
/** Plan attributes handed to the policy as Cedar `context`. A {@link StepOutputRef} is resolved at run time. */
|
|
77
|
+
context?: Record<string, GateContextValue>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* {@link GateApproval} as a run resolved it: context references replaced with
|
|
82
|
+
* the values they pointed at, mode defaulted. This is what a pending fact
|
|
83
|
+
* carries, so `chant approve` evaluates the policy against the plan the run
|
|
84
|
+
* actually produced.
|
|
85
|
+
*/
|
|
86
|
+
export interface ResolvedGateApproval {
|
|
87
|
+
quorum?: GateQuorum;
|
|
88
|
+
policy?: GatePolicyRef;
|
|
89
|
+
mode: GateApprovalMode;
|
|
90
|
+
context?: Record<string, unknown>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Who recorded an approval. Absent on every resolution written before #2508, which reads as a human with no roles. */
|
|
94
|
+
export interface GateApprover {
|
|
95
|
+
kind: "human" | "agent";
|
|
96
|
+
roles?: string[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** One `PassGate` question put to a policy. */
|
|
100
|
+
export interface GatePolicyRequest {
|
|
101
|
+
principal: { kind: GateApprover["kind"]; name: string; roles: string[] };
|
|
102
|
+
action: "PassGate";
|
|
103
|
+
resource: { op: string; gate: string };
|
|
104
|
+
/** The plan digest, when the gate binds one, plus the gate's declared context. */
|
|
105
|
+
context: Record<string, unknown>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** What a lexicon's evaluator answers. */
|
|
109
|
+
export interface GatePolicyAnswer {
|
|
110
|
+
decision: "allow" | "deny";
|
|
111
|
+
/** Ids of the policies that determined the decision. */
|
|
112
|
+
determining: string[];
|
|
113
|
+
/** Per-policy evaluation errors. A policy that errors does not apply. */
|
|
114
|
+
errors: string[];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A policy's decision, as recorded on a resolution. */
|
|
118
|
+
export interface GatePolicyDecision extends GatePolicyAnswer {
|
|
119
|
+
policy: string;
|
|
120
|
+
version: string;
|
|
121
|
+
/** The gate's mode when the decision was recorded. A run reads the gate's current mode, not this. */
|
|
122
|
+
mode: GateApprovalMode;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** What a lexicon's `gate-policy` module exports. */
|
|
126
|
+
export interface GatePolicyEvaluator {
|
|
127
|
+
evaluateGatePolicy(policy: GatePolicyRef, request: GatePolicyRequest): Promise<GatePolicyAnswer> | GatePolicyAnswer;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The version of a gate policy: `sha256:` and the hex digest of its text. A lexicon's helper stamps it, and OPS015 checks it still matches. */
|
|
131
|
+
export function gatePolicyVersion(text: string): string {
|
|
132
|
+
return `sha256:${createHash("sha256").update(text).digest("hex")}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function isGatePolicyRef(value: unknown): value is GatePolicyRef {
|
|
136
|
+
if (typeof value !== "object" || value === null) return false;
|
|
137
|
+
const v = value as Record<string, unknown>;
|
|
138
|
+
return (
|
|
139
|
+
v.kind === "gate-policy" &&
|
|
140
|
+
typeof v.lexicon === "string" && v.lexicon !== "" &&
|
|
141
|
+
typeof v.name === "string" && v.name !== "" &&
|
|
142
|
+
typeof v.version === "string" && v.version !== "" &&
|
|
143
|
+
typeof v.text === "string" && v.text.trim() !== ""
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Everything wrong with an `approval` block, one line each. The `gate()`
|
|
149
|
+
* builder throws on the first; OPS015 reports all of them over the build
|
|
150
|
+
* output, where a block could have been written without the builder.
|
|
151
|
+
*/
|
|
152
|
+
export function gateApprovalProblems(approval: unknown): string[] {
|
|
153
|
+
if (typeof approval !== "object" || approval === null || Array.isArray(approval)) {
|
|
154
|
+
return ["`approval` must be an object"];
|
|
155
|
+
}
|
|
156
|
+
const a = approval as Record<string, unknown>;
|
|
157
|
+
const problems: string[] = [];
|
|
158
|
+
|
|
159
|
+
if (a.quorum !== undefined) {
|
|
160
|
+
const q = a.quorum as Record<string, unknown> | null;
|
|
161
|
+
if (typeof q !== "object" || q === null) {
|
|
162
|
+
problems.push("`approval.quorum` must be an object");
|
|
163
|
+
} else {
|
|
164
|
+
if (typeof q.count !== "number" || !Number.isInteger(q.count) || q.count < 1) {
|
|
165
|
+
problems.push("`approval.quorum.count` must be an integer of at least 1");
|
|
166
|
+
}
|
|
167
|
+
if (q.roles !== undefined) {
|
|
168
|
+
if (!Array.isArray(q.roles) || q.roles.length === 0 || q.roles.some((r) => typeof r !== "string" || r === "")) {
|
|
169
|
+
problems.push("`approval.quorum.roles` must be a non-empty list of role names");
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (a.policy !== undefined) {
|
|
176
|
+
if (!isGatePolicyRef(a.policy)) {
|
|
177
|
+
problems.push(
|
|
178
|
+
"`approval.policy` does not resolve to a gate policy set. Build it with the lexicon's helper, " +
|
|
179
|
+
"for example `gatePolicy(\"ship\", policies)` from @intentius/chant-lexicon-cedar",
|
|
180
|
+
);
|
|
181
|
+
} else if (a.policy.version !== gatePolicyVersion(a.policy.text)) {
|
|
182
|
+
problems.push(
|
|
183
|
+
`\`approval.policy\` "${a.policy.name}" has a version that is not the digest of its text, ` +
|
|
184
|
+
"so a recorded decision could not be traced to the rule that made it",
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (a.mode !== undefined && !GATE_APPROVAL_MODES.includes(a.mode as GateApprovalMode)) {
|
|
190
|
+
problems.push(`\`approval.mode\` must be one of ${GATE_APPROVAL_MODES.map((m) => `"${m}"`).join(", ")}`);
|
|
191
|
+
}
|
|
192
|
+
if (a.mode === "enforce" && a.policy === undefined) {
|
|
193
|
+
problems.push("`approval.mode: \"enforce\"` needs an `approval.policy` to enforce");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (a.context !== undefined) {
|
|
197
|
+
if (typeof a.context !== "object" || a.context === null || Array.isArray(a.context)) {
|
|
198
|
+
problems.push("`approval.context` must be an object of plan attributes");
|
|
199
|
+
} else if (a.policy === undefined) {
|
|
200
|
+
problems.push("`approval.context` is only read by a policy, and this gate declares none");
|
|
201
|
+
} else {
|
|
202
|
+
for (const [key, value] of Object.entries(a.context)) {
|
|
203
|
+
if (!isContextValue(value)) {
|
|
204
|
+
problems.push(
|
|
205
|
+
`\`approval.context.${key}\` must be a string, number, boolean, list of strings, or a step output reference`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return problems;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function isContextValue(value: unknown): boolean {
|
|
216
|
+
if (typeof value === "string" || typeof value === "boolean") return true;
|
|
217
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
218
|
+
if (Array.isArray(value)) return value.every((v) => typeof v === "string");
|
|
219
|
+
return isStepOutputRef(value);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Import `lexicon`'s evaluator. Throws a message that names the package when it is not installed or exports none. */
|
|
223
|
+
export async function loadGatePolicyEvaluator(lexicon: string): Promise<GatePolicyEvaluator> {
|
|
224
|
+
const spec = `@intentius/chant-lexicon-${lexicon}/gate-policy`;
|
|
225
|
+
let mod: Partial<GatePolicyEvaluator>;
|
|
226
|
+
try {
|
|
227
|
+
mod = (await import(spec)) as Partial<GatePolicyEvaluator>;
|
|
228
|
+
} catch (err) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`the gate's policy is evaluated by ${spec}, which could not be loaded: ` +
|
|
231
|
+
(err instanceof Error ? err.message : String(err)),
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
if (typeof mod.evaluateGatePolicy !== "function") {
|
|
235
|
+
throw new Error(`${spec} exports no evaluateGatePolicy function`);
|
|
236
|
+
}
|
|
237
|
+
return mod as GatePolicyEvaluator;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The request a policy is asked about one approval. */
|
|
241
|
+
export function gatePolicyRequest(input: {
|
|
242
|
+
op: string;
|
|
243
|
+
gate: string;
|
|
244
|
+
resolvedBy: string;
|
|
245
|
+
approver: GateApprover;
|
|
246
|
+
planDigest?: string;
|
|
247
|
+
context?: Record<string, unknown>;
|
|
248
|
+
}): GatePolicyRequest {
|
|
249
|
+
return {
|
|
250
|
+
principal: { kind: input.approver.kind, name: input.resolvedBy, roles: input.approver.roles ?? [] },
|
|
251
|
+
action: "PassGate",
|
|
252
|
+
resource: { op: input.op, gate: input.gate },
|
|
253
|
+
context: {
|
|
254
|
+
...(input.context ?? {}),
|
|
255
|
+
...(input.planDigest !== undefined ? { planDigest: input.planDigest } : {}),
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
}
|