@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.
- 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
package/dist/op/types.d.ts
CHANGED
|
@@ -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`;
|
package/dist/op/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/op/types.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACxD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAC/D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/op/types.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -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
|
|
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.
|
package/src/cli/registry.ts
CHANGED
|
@@ -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
|
+
};
|
package/src/op/builders.ts
CHANGED
|
@@ -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
|
|