@intentius/chant 0.124.11 → 0.124.13
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/commands/lint.d.ts.map +1 -1
- package/dist/gate-decision.d.ts +36 -4
- package/dist/gate-decision.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +8 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/git.d.ts +30 -14
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/ledger-port.d.ts +18 -0
- package/dist/lifecycle/ledger-port.d.ts.map +1 -0
- package/dist/lifecycle/release-ledger.d.ts +10 -9
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lifecycle/release-read.d.ts +23 -0
- package/dist/lifecycle/release-read.d.ts.map +1 -0
- package/dist/lifecycle/wave-plans.d.ts +51 -0
- package/dist/lifecycle/wave-plans.d.ts.map +1 -0
- package/dist/lint/typescript.d.ts +18 -0
- package/dist/lint/typescript.d.ts.map +1 -0
- package/dist/op/activities/activity-contracts.d.ts +4 -4
- package/dist/op/gate-review.d.ts +1 -20
- package/dist/op/gate-review.d.ts.map +1 -1
- package/dist/op/op-waves-run.d.ts +1 -1
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/workspace/identity-gate-rule.d.ts +65 -0
- package/dist/workspace/identity-gate-rule.d.ts.map +1 -0
- package/dist/workspace/identity.d.ts +4 -2
- package/dist/workspace/identity.d.ts.map +1 -1
- package/dist/workspace/principal-classes.d.ts +3 -14
- package/dist/workspace/principal-classes.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/lint.ts +33 -6
- package/src/gate-decision-bundle.test.ts +3 -2
- package/src/gate-decision-identity.test.ts +176 -0
- package/src/gate-decision.ts +100 -22
- package/src/lifecycle/gate-ledger.ts +8 -0
- package/src/lifecycle/git.ts +61 -18
- package/src/lifecycle/ledger-port.ts +17 -0
- package/src/lifecycle/ledger-readers-bundle.test.ts +47 -0
- package/src/lifecycle/ledger-readers.test.ts +139 -0
- package/src/lifecycle/release-ledger.ts +15 -42
- package/src/lifecycle/release-read.ts +69 -0
- package/src/lifecycle/wave-plans.ts +93 -0
- package/src/lint/typescript.ts +18 -0
- package/src/meta/lexicon-typescript-import.test.ts +42 -0
- package/src/op/gate-review.ts +2 -16
- package/src/op/op-waves-point.test.ts +1 -0
- package/src/op/op-waves-run.ts +8 -14
- package/src/op/op-waves.test.ts +14 -0
- package/src/workspace/identity-gate-rule.ts +109 -0
- package/src/workspace/identity.ts +8 -18
- package/src/workspace/principal-classes.ts +4 -29
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { join, relative } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Lexicon source takes the TypeScript compiler API from
|
|
8
|
+
* `@intentius/chant/lint/typescript`, never from `typescript` (chant #3864).
|
|
9
|
+
*
|
|
10
|
+
* A bare `typescript` import in a published lexicon resolves to the project's
|
|
11
|
+
* copy. On TypeScript 7 that copy has no JS compiler API, so every sql lint
|
|
12
|
+
* rule threw `ts.isImportDeclaration is not a function` and `chant graph`
|
|
13
|
+
* refused the project. Core parses with its own 5.x, and a rule has to test
|
|
14
|
+
* nodes with that same module. Tests are exempt: they run in this repo, where
|
|
15
|
+
* `typescript` is 5.x.
|
|
16
|
+
*/
|
|
17
|
+
const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url));
|
|
18
|
+
const lexiconsDir = join(repoRoot, "lexicons");
|
|
19
|
+
|
|
20
|
+
function sourceFiles(dir: string): string[] {
|
|
21
|
+
const out: string[] = [];
|
|
22
|
+
for (const name of readdirSync(dir)) {
|
|
23
|
+
const path = join(dir, name);
|
|
24
|
+
if (statSync(path).isDirectory()) out.push(...sourceFiles(path));
|
|
25
|
+
else if (/\.(ts|mts|js|mjs)$/.test(name) && !/\.test\.ts$/.test(name)) out.push(path);
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const BARE_TYPESCRIPT = /^\s*(import|export)\b[^;]*?\bfrom\s+["']typescript["']|\b(require|import)\(\s*["']typescript["']\s*\)/m;
|
|
31
|
+
|
|
32
|
+
describe("lexicon source imports TypeScript through chant (#3864)", () => {
|
|
33
|
+
test("no lexicon src file imports `typescript` directly", () => {
|
|
34
|
+
const offenders = readdirSync(lexiconsDir)
|
|
35
|
+
.map((name) => join(lexiconsDir, name, "src"))
|
|
36
|
+
.filter((dir) => statSync(dir, { throwIfNoEntry: false })?.isDirectory())
|
|
37
|
+
.flatMap(sourceFiles)
|
|
38
|
+
.filter((file) => BARE_TYPESCRIPT.test(readFileSync(file, "utf-8")))
|
|
39
|
+
.map((file) => relative(repoRoot, file));
|
|
40
|
+
expect(offenders, 'import { ts } from "@intentius/chant/lint/typescript" instead').toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
});
|
package/src/op/gate-review.ts
CHANGED
|
@@ -70,23 +70,9 @@ export function bothRules(a: GateApprovalRule | null, b: GateApprovalRule | null
|
|
|
70
70
|
|
|
71
71
|
// ── pr-review ─────────────────────────────────────────────────────────────
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
export
|
|
75
|
-
version: 1;
|
|
76
|
-
/** The Op waves spec's name. */
|
|
77
|
-
name: string;
|
|
78
|
-
/** The commit the waves were planned at: a pull request's head. */
|
|
79
|
-
head: string;
|
|
80
|
-
timestamp: string;
|
|
81
|
-
waves: Array<{ wave: number; name: string; digest: string }>;
|
|
82
|
-
}
|
|
73
|
+
// The record and its reader live apart, so a bundle can import them without git (#3865).
|
|
74
|
+
export { HEAD_PLANS_DIR, headPlansPath, type HeadPlans } from "../lifecycle/wave-plans";
|
|
83
75
|
|
|
84
|
-
/** Where {@link HeadPlans} for `head` live on `chant/lifecycle`: `_wave-plans/<name>/<head>.json`. */
|
|
85
|
-
export const HEAD_PLANS_DIR = "_wave-plans";
|
|
86
|
-
|
|
87
|
-
export function headPlansPath(name: string, head: string): { dir: string; file: string } {
|
|
88
|
-
return { dir: `${HEAD_PLANS_DIR}/${name}`, file: `${head}.json` };
|
|
89
|
-
}
|
|
90
76
|
|
|
91
77
|
/** The merged pull request's review, as a `pr-review` gate reads it. */
|
|
92
78
|
export interface MergedReview {
|
|
@@ -254,6 +254,7 @@ describe("a wave gate that asks a decision point (#3836)", () => {
|
|
|
254
254
|
members,
|
|
255
255
|
description: "wave 1 of 1: vpc, nat",
|
|
256
256
|
neverOverMcp: true,
|
|
257
|
+
approvalMode: "ledger",
|
|
257
258
|
answer: { point: "prod-apply" },
|
|
258
259
|
});
|
|
259
260
|
expect(waiting.decision?.pendingFact).toMatchObject({ commit: "c".repeat(40), neverOverMcp: true });
|
package/src/op/op-waves-run.ts
CHANGED
|
@@ -130,7 +130,7 @@ export interface OpWavePlansFile {
|
|
|
130
130
|
/** Pending-fact keys chant writes itself, which a plans file's `pending` may not set (#3821). */
|
|
131
131
|
export const OP_WAVE_PENDING_RESERVED = [
|
|
132
132
|
"version", "kind", "op", "gate", "timestamp", "expiresAt", "planDigest", "runId", "url", "resume",
|
|
133
|
-
"environment", "origin", "neverOverMcp", "approval", "answer",
|
|
133
|
+
"environment", "origin", "neverOverMcp", "approval", "approvalMode", "answer",
|
|
134
134
|
] as const;
|
|
135
135
|
|
|
136
136
|
/** What chant writes to `{runs}` for a wave-level command or a hook (#3762). */
|
|
@@ -1068,7 +1068,7 @@ async function settleOtherGate(ctx: WaveContext, hold: OtherGateHold): Promise<O
|
|
|
1068
1068
|
description: hold.request.description ?? `wave ${k} (${wave.name}) of ${spec.name} is held on it by ${hold.label}`,
|
|
1069
1069
|
now,
|
|
1070
1070
|
// #3839: the wave a hold belongs to, as a field, whatever the hook's own description says.
|
|
1071
|
-
fields: { ...hold.request.pending, wave: { number: k, name: wave.name, op: spec.name } },
|
|
1071
|
+
fields: { ...hold.request.pending, approvalMode: rules.approval, wave: { number: k, name: wave.name, op: spec.name } },
|
|
1072
1072
|
...(neverOverMcp ? { neverOverMcp: true } : {}),
|
|
1073
1073
|
});
|
|
1074
1074
|
const base = { op, gate: hold.request.gate, digest: hold.request.digest, hook: hold.label };
|
|
@@ -1633,7 +1633,8 @@ async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision:
|
|
|
1633
1633
|
description: (typeof decision.pending?.description === "string" ? decision.pending.description : undefined) ?? `wave ${k} (${wave.name}): ${members.length} run${members.length === 1 ? "" : "s"}, gate ${policy}, approval ${approval}${held}`,
|
|
1634
1634
|
now,
|
|
1635
1635
|
...(spent ? { spent } : {}),
|
|
1636
|
-
|
|
1636
|
+
// #3861: the approval mode the wave asked under, for readers deciding which approvals count.
|
|
1637
|
+
fields: { ...decision.pending, approvalMode: approval },
|
|
1637
1638
|
...(decision.neverOverMcp ? { neverOverMcp: true } : {}),
|
|
1638
1639
|
});
|
|
1639
1640
|
if (check.satisfied) {
|
|
@@ -1697,7 +1698,7 @@ async function settlePointGate(options: RunOpWaveOptions, ctx: WaveContext, deci
|
|
|
1697
1698
|
planDigest: digest,
|
|
1698
1699
|
...(options.now ? { now: options.now } : {}),
|
|
1699
1700
|
// #3863: the plans file's pending fields and the spec's neverOverMcp, as settleGate passes them.
|
|
1700
|
-
...
|
|
1701
|
+
fields: { ...decision.pending, approvalMode: decision.approval },
|
|
1701
1702
|
...(decision.neverOverMcp ? { neverOverMcp: true } : {}),
|
|
1702
1703
|
});
|
|
1703
1704
|
if (check.satisfied) {
|
|
@@ -2088,17 +2089,10 @@ async function approveByReview(
|
|
|
2088
2089
|
}
|
|
2089
2090
|
|
|
2090
2091
|
async function readHeadPlans(name: string, head: string, cwd: string): Promise<HeadPlans | null> {
|
|
2091
|
-
const {
|
|
2092
|
-
const {
|
|
2092
|
+
const { gitLedgerReadPort, requireLifecycleLedger } = await import("../lifecycle/git");
|
|
2093
|
+
const { readHeadPlans: read } = await import("../lifecycle/wave-plans");
|
|
2093
2094
|
await requireLifecycleLedger({ cwd });
|
|
2094
|
-
|
|
2095
|
-
const text = await readBlobFromPath(dir, file, { cwd });
|
|
2096
|
-
if (!text) return null;
|
|
2097
|
-
try {
|
|
2098
|
-
return JSON.parse(text) as HeadPlans;
|
|
2099
|
-
} catch {
|
|
2100
|
-
return null;
|
|
2101
|
-
}
|
|
2095
|
+
return read(gitLedgerReadPort({ cwd }), name, head);
|
|
2102
2096
|
}
|
|
2103
2097
|
|
|
2104
2098
|
export interface RecordHeadPlansOptions {
|
package/src/op/op-waves.test.ts
CHANGED
|
@@ -739,8 +739,22 @@ describe("a product's wave gate moved onto the waves (#3821)", () => {
|
|
|
739
739
|
const result = await runOpWave({ spec: tg, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: () => ({ sha: "b".repeat(40), text: JSON.stringify({ ...tg, neverOverMcp: true }) }), gates });
|
|
740
740
|
expect(result.exitCode).toBe(3);
|
|
741
741
|
expect(gates.appended[0]).toMatchObject({ commit: "c".repeat(40), members, description: "wave 1 of 3: vpc, dns, nat", neverOverMcp: true, gate: "wave-1", op: "tf-apply" });
|
|
742
|
+
// #3861: the approval mode the wave asked under, and a plans file may not set it.
|
|
743
|
+
expect(gates.appended[0]).toMatchObject({ approvalMode: "ledger" });
|
|
742
744
|
h.done();
|
|
743
745
|
|
|
746
|
+
const mode = harness(plans, { pending: { approvalMode: "sealed" } });
|
|
747
|
+
await expect(runOpWave({ spec: tg, specFile: "waves.json", wave: 1, cwd: mode.cwd, exec: mode.exec, show: mode.show, gates: memoryGateLedgerPort() })).rejects.toThrow(/sets approvalMode, which chant writes itself/);
|
|
748
|
+
mode.done();
|
|
749
|
+
|
|
750
|
+
// A sealed spec at base records "sealed".
|
|
751
|
+
const sealedSpec: OpWavesSpec = { ...tg, waves: [{ ...tg.waves[0]!, approval: "sealed" }] };
|
|
752
|
+
const sh = harness(plans, {}, sealedSpec);
|
|
753
|
+
const sealedGates = memoryGateLedgerPort();
|
|
754
|
+
await runOpWave({ spec: sealedSpec, specFile: "waves.json", wave: 1, cwd: sh.cwd, exec: sh.exec, show: () => ({ sha: "b".repeat(40), text: JSON.stringify(sealedSpec) }), gates: sealedGates });
|
|
755
|
+
expect(sealedGates.appended[0]).toMatchObject({ approvalMode: "sealed" });
|
|
756
|
+
sh.done();
|
|
757
|
+
|
|
744
758
|
const bad = harness(plans, { pending: { gate: "other" } });
|
|
745
759
|
await expect(runOpWave({ spec: tg, specFile: "waves.json", wave: 1, cwd: bad.cwd, exec: bad.exec, show: bad.show, gates: memoryGateLedgerPort() })).rejects.toThrow(/sets gate, which chant writes itself/);
|
|
746
760
|
bad.done();
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `identity.gates` rule as data and one pure function (#3862).
|
|
3
|
+
*
|
|
4
|
+
* A gate the declaration names under `identity.gates` passes only on an
|
|
5
|
+
* approval sealed by a signer at base, from the class the gate names when it
|
|
6
|
+
* names one (#3163, ws-080). chant's `gateAdmissionFrom` (`./identity.ts`)
|
|
7
|
+
* builds a {@link GateAdmissionRule} from what `scopeSource` reads and judges
|
|
8
|
+
* every approval with {@link admitApprover}. `@intentius/chant/gate-decision`
|
|
9
|
+
* builds the same rule from a bare clone at a base commit
|
|
10
|
+
* (`identityGateAdmissionAt`) and judges with the same function, so a tool
|
|
11
|
+
* counts approvals as chant's wave gate does.
|
|
12
|
+
*
|
|
13
|
+
* The module imports node:crypto through `./trust/gate-seal.ts` and nothing
|
|
14
|
+
* else: no zod, no ajv, no package loading, so it bundles small.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { GATE_SEAL_NAMESPACE, gateSealPayload, namespaceAllowed, verifySshSignatureWith, type SealedGateApproval } from "./trust/gate-seal";
|
|
18
|
+
import type { Signer } from "./trust/trust-files";
|
|
19
|
+
|
|
20
|
+
/** The core classes, in the order a principal's roles are tried; `human` holds no role and is the rest. */
|
|
21
|
+
export const CORE_PRINCIPAL_CLASSES = ["human", "agent", "runner", "service"] as const;
|
|
22
|
+
export type CorePrincipalClass = (typeof CORE_PRINCIPAL_CLASSES)[number];
|
|
23
|
+
|
|
24
|
+
export interface PrincipalClass {
|
|
25
|
+
name: string;
|
|
26
|
+
/** One line saying who is in the class. */
|
|
27
|
+
description: string;
|
|
28
|
+
/** The role whose grant at base puts a principal in the class, or null for `human`, the rest. */
|
|
29
|
+
role: string | null;
|
|
30
|
+
/** `core`, or the pin (package name or path) that supplies the class. */
|
|
31
|
+
source: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const CORE_CLASSES: readonly PrincipalClass[] = [
|
|
35
|
+
{ name: "human", description: "a principal holding none of the roles of another class", role: null, source: "core" },
|
|
36
|
+
{ name: "agent", description: "a principal holding the agent role, or a write naming an agent session", role: "agent", source: "core" },
|
|
37
|
+
{ name: "runner", description: "a principal holding the runner role, such as a CI job's signing identity", role: "runner", source: "core" },
|
|
38
|
+
{ name: "service", description: "a principal holding the service role", role: "service", source: "core" },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/** A class as the rule reads it: its name, and the role whose grant puts a principal in it (null for `human`). */
|
|
42
|
+
export type ClassGrant = Pick<PrincipalClass, "name" | "role">;
|
|
43
|
+
|
|
44
|
+
/** What an `identity.gates` gate is judged by, all read at base. */
|
|
45
|
+
export interface GateAdmissionRule {
|
|
46
|
+
gate: string;
|
|
47
|
+
/** The class the approver must be in, or null for any signer. */
|
|
48
|
+
class: string | null;
|
|
49
|
+
/** The signers file at base: its path, whether it exists, and its signers. */
|
|
50
|
+
signers: { signersPath: string; active: boolean; signers: readonly Signer[] };
|
|
51
|
+
/** The role grants at base (`.chant/trust.json` `roles`). */
|
|
52
|
+
roles: Readonly<Record<string, readonly string[]>>;
|
|
53
|
+
/** Every class the rule knows, core first, then domain classes in pin order. */
|
|
54
|
+
classes: readonly ClassGrant[];
|
|
55
|
+
/**
|
|
56
|
+
* Why a class the rule does not know is unknown, for the refusal. Default
|
|
57
|
+
* "no pinned package supplies", which is what chant says once it has read
|
|
58
|
+
* every pin.
|
|
59
|
+
*/
|
|
60
|
+
unknownClass?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const normalise = (name: string): string => name.normalize("NFKC").trim().toLowerCase();
|
|
64
|
+
|
|
65
|
+
/** Every class `principal`'s role grants put it in, in `classes` order: what `classesOf` in `./principal-classes.ts` answers. */
|
|
66
|
+
export function grantedClasses(classes: readonly ClassGrant[], roles: Readonly<Record<string, readonly string[]>>, principal: string | null): string[] {
|
|
67
|
+
if (principal === null) return [];
|
|
68
|
+
const name = normalise(principal);
|
|
69
|
+
return classes.filter((c) => c.role !== null && (roles[c.role] ?? []).some((p) => normalise(p) === name)).map((c) => c.name);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Why `a` does not count toward the gate, or null when it does. An approval
|
|
74
|
+
* counts only when its seal verifies for its approver against the signers at
|
|
75
|
+
* base and, when the rule names a class, the role grants at base put the
|
|
76
|
+
* approver in it (`human`: in no other class). A class the rule does not
|
|
77
|
+
* know lets nothing through.
|
|
78
|
+
*/
|
|
79
|
+
export function admitApprover(rule: GateAdmissionRule, a: SealedGateApproval): string | null {
|
|
80
|
+
const { gate, signers } = rule;
|
|
81
|
+
const want = rule.class;
|
|
82
|
+
if (want !== null && !rule.classes.some((c) => c.name === want)) {
|
|
83
|
+
return `identity.gates.${gate} names the class ${want}, which ${rule.unknownClass ?? "no pinned package supplies"}, so no approval can show it is in that class (known classes: ${rule.classes.map((c) => c.name).join(", ")})`;
|
|
84
|
+
}
|
|
85
|
+
if (!signers.active) return `gate ${gate} needs a signed approval, and there is no signers file (${signers.signersPath}) at base to check one against`;
|
|
86
|
+
const seal = sealRefusal(gate, signers, a);
|
|
87
|
+
if (seal !== null) return seal;
|
|
88
|
+
if (want === null) return null;
|
|
89
|
+
const granted = grantedClasses(rule.classes, rule.roles, a.resolvedBy);
|
|
90
|
+
const inClass = want === "human" ? granted.length === 0 : granted.includes(want);
|
|
91
|
+
return inClass ? null : `${a.resolvedBy} is not in the class ${want} that gate ${gate} needs: no role grant at base puts them there`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The seal step, worded as `checkGateSeal` (`./trust/seal.ts`) words it against an active signers file at base. */
|
|
95
|
+
function sealRefusal(gate: string, signers: GateAdmissionRule["signers"], a: SealedGateApproval): string | null {
|
|
96
|
+
const what = `the approval of gate ${a.gate} by ${a.resolvedBy}`;
|
|
97
|
+
if (a.seal === undefined || a.seal === null) return `the approval by ${a.resolvedBy} is not signed; gate ${gate} needs chant approve --sign`;
|
|
98
|
+
const seal = a.seal as { signer?: unknown; signature?: unknown };
|
|
99
|
+
if (typeof seal !== "object" || Array.isArray(seal) || typeof seal.signer !== "string" || typeof seal.signature !== "string") {
|
|
100
|
+
return `the seal on ${what} is malformed: it needs signer and signature`;
|
|
101
|
+
}
|
|
102
|
+
if (normalise(seal.signer) !== normalise(a.resolvedBy)) return `the seal on ${what} is by ${seal.signer}, and its approver is ${a.resolvedBy || "empty"}`;
|
|
103
|
+
const where = `${signers.signersPath} at base`;
|
|
104
|
+
const listed = signers.signers.filter((s) => normalise(s.principal) === normalise(a.resolvedBy));
|
|
105
|
+
if (listed.length === 0) return `${a.resolvedBy} has no key in ${where}, so the seal can't count`;
|
|
106
|
+
const keys = listed.filter((s) => namespaceAllowed(s.namespaces, GATE_SEAL_NAMESPACE)).map((s) => Buffer.from(s.key.split(" ")[1] ?? "", "base64"));
|
|
107
|
+
const r = verifySshSignatureWith(seal.signature, gateSealPayload(a), GATE_SEAL_NAMESPACE, keys);
|
|
108
|
+
return r.ok ? null : `the seal does not verify for ${a.resolvedBy} against ${where}: ${r.reason}`;
|
|
109
|
+
}
|
|
@@ -26,11 +26,12 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import type { Declaration, SignedGate } from "./declaration";
|
|
29
|
-
import {
|
|
29
|
+
import { admitApprover } from "./identity-gate-rule";
|
|
30
|
+
import { classesOf, coreClassRegistry, roleGrants, type ClassRegistry } from "./principal-classes";
|
|
30
31
|
import type { ReasonCode } from "./reason-codes";
|
|
31
32
|
import { normalisePrincipal } from "./records";
|
|
32
33
|
import type { TrustPolicy } from "./trust/policy";
|
|
33
|
-
import {
|
|
34
|
+
import type { SealedGateApproval } from "./trust/gate-seal";
|
|
34
35
|
import { scopeSource } from "./write-scope";
|
|
35
36
|
|
|
36
37
|
/** The forges a forge identity can name. */
|
|
@@ -152,27 +153,16 @@ export interface GateRuleSource extends IdentitySource {
|
|
|
152
153
|
* approval counts only when its seal verifies for its approver against the
|
|
153
154
|
* signers at base and, when the rule names a class, the approver's role
|
|
154
155
|
* grants at base put it in that class. A class no pinned package supplies
|
|
155
|
-
* lets nothing through.
|
|
156
|
+
* lets nothing through. The judge is {@link admitApprover}, which
|
|
157
|
+
* `@intentius/chant/gate-decision` also judges with (#3862). The policy is
|
|
158
|
+
* the one at base, never a returned work's copy.
|
|
156
159
|
*/
|
|
157
160
|
export function gateAdmissionFrom(source: GateRuleSource, gate: string): GateAdmission | null {
|
|
158
161
|
const requirement = source.declaration?.identity?.gates[gate];
|
|
159
162
|
if (!requirement) return null;
|
|
160
163
|
const { policy, classes } = source;
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
refuses(a) {
|
|
164
|
-
const want = requirement.class;
|
|
165
|
-
if (want !== null && classes.get(want) === undefined) {
|
|
166
|
-
return `identity.gates.${gate} names the class ${want}, which no pinned package supplies, so no approval can show it is in that class (known classes: ${classes.names().join(", ")})`;
|
|
167
|
-
}
|
|
168
|
-
if (!policy.active) return `gate ${gate} needs a signed approval, and there is no signers file (${policy.signersPath}) at base to check one against`;
|
|
169
|
-
const seal = checkGateSeal(policy, a);
|
|
170
|
-
if (seal.attested !== true) return a.seal === undefined || a.seal === null ? `the approval by ${a.resolvedBy} is not signed; gate ${gate} needs chant approve --sign` : seal.message;
|
|
171
|
-
if (want === null) return null;
|
|
172
|
-
const inClass = want === "human" ? principalClass(policy, a.resolvedBy, classes) === "human" : classesOf(classes, policy, a.resolvedBy).includes(want);
|
|
173
|
-
return inClass ? null : `${a.resolvedBy} is not in the class ${want} that gate ${gate} needs: no role grant at base puts them there`;
|
|
174
|
-
},
|
|
175
|
-
};
|
|
164
|
+
const rule = { gate, class: requirement.class, signers: policy, roles: roleGrants(policy), classes: classes.all() };
|
|
165
|
+
return { requirement, refuses: (a) => admitApprover(rule, a) };
|
|
176
166
|
}
|
|
177
167
|
|
|
178
168
|
/**
|
|
@@ -26,19 +26,17 @@ import { readFileSync } from "node:fs";
|
|
|
26
26
|
import { createRequire } from "node:module";
|
|
27
27
|
import { join, posix } from "node:path";
|
|
28
28
|
import { kindsExportTarget, pinnedPackageDir, type KindLoadProblem, type PinLike } from "./kinds";
|
|
29
|
-
import { normalisePrincipal } from "./records";
|
|
30
29
|
import schema from "./workspace-principals.schema.json";
|
|
31
30
|
import type { TrustPolicy } from "./trust/policy";
|
|
32
31
|
import { joinPath, type WorkspaceTree } from "./tree";
|
|
32
|
+
import { CORE_CLASSES, CORE_PRINCIPAL_CLASSES, grantedClasses, type PrincipalClass } from "./identity-gate-rule";
|
|
33
33
|
|
|
34
34
|
export const PRINCIPALS_SCHEMA_ID = schema.$id;
|
|
35
35
|
|
|
36
36
|
/** The subpath a package publishes its principal classes at. */
|
|
37
37
|
export const PRINCIPALS_SUBPATH = "./workspace-principals";
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
export const CORE_PRINCIPAL_CLASSES = ["human", "agent", "runner", "service"] as const;
|
|
41
|
-
export type CorePrincipalClass = (typeof CORE_PRINCIPAL_CLASSES)[number];
|
|
39
|
+
export { CORE_CLASSES, CORE_PRINCIPAL_CLASSES, type CorePrincipalClass, type PrincipalClass } from "./identity-gate-rule";
|
|
42
40
|
|
|
43
41
|
/** A class name: a core class, or a domain class a pinned package supplies. */
|
|
44
42
|
export type PrincipalClassName = string;
|
|
@@ -46,23 +44,6 @@ export type PrincipalClassName = string;
|
|
|
46
44
|
/** The grammar of a class name, as `workspace-principals.schema.json` and the declaration's writeScope keys say it. */
|
|
47
45
|
export const CLASS_NAME = /^(?!x-)[a-z][a-z0-9-]{0,39}$/;
|
|
48
46
|
|
|
49
|
-
export interface PrincipalClass {
|
|
50
|
-
name: string;
|
|
51
|
-
/** One line saying who is in the class. */
|
|
52
|
-
description: string;
|
|
53
|
-
/** The role whose grant at base puts a principal in the class, or null for `human`, the rest. */
|
|
54
|
-
role: string | null;
|
|
55
|
-
/** `core`, or the pin (package name or path) that supplies the class. */
|
|
56
|
-
source: string;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export const CORE_CLASSES: readonly PrincipalClass[] = [
|
|
60
|
-
{ name: "human", description: "a principal holding none of the roles of another class", role: null, source: "core" },
|
|
61
|
-
{ name: "agent", description: "a principal holding the agent role, or a write naming an agent session", role: "agent", source: "core" },
|
|
62
|
-
{ name: "runner", description: "a principal holding the runner role, such as a CI job's signing identity", role: "runner", source: "core" },
|
|
63
|
-
{ name: "service", description: "a principal holding the service role", role: "service", source: "core" },
|
|
64
|
-
];
|
|
65
|
-
|
|
66
47
|
const CORE_ROLES = new Set(CORE_CLASSES.map((c) => c.role).filter((r): r is string => r !== null));
|
|
67
48
|
|
|
68
49
|
export interface ClassRegistry {
|
|
@@ -100,16 +81,10 @@ export function roleGrants(policy: TrustPolicy): Record<string, string[]> {
|
|
|
100
81
|
/**
|
|
101
82
|
* Every class `principal`'s role grants at base put it in, in registry order
|
|
102
83
|
* (core first). Empty for a principal in no class but `human`, and for null.
|
|
103
|
-
* Names compare as
|
|
84
|
+
* Names compare as `normalisePrincipal` (`./records.ts`) folds them.
|
|
104
85
|
*/
|
|
105
86
|
export function classesOf(classes: ClassRegistry, policy: TrustPolicy, principal: string | null): string[] {
|
|
106
|
-
|
|
107
|
-
const name = normalisePrincipal(principal);
|
|
108
|
-
const grants = roleGrants(policy);
|
|
109
|
-
return classes
|
|
110
|
-
.all()
|
|
111
|
-
.filter((c) => c.role !== null && (grants[c.role] ?? []).some((p) => normalisePrincipal(p) === name))
|
|
112
|
-
.map((c) => c.name);
|
|
87
|
+
return grantedClasses(classes.all(), roleGrants(policy), principal);
|
|
113
88
|
}
|
|
114
89
|
|
|
115
90
|
/** The class write scope judges `principal` as: the first class its grants put it in, or `human`. */
|