@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,65 @@
|
|
|
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
|
+
import { type SealedGateApproval } from "./trust/gate-seal.js";
|
|
17
|
+
import type { Signer } from "./trust/trust-files.js";
|
|
18
|
+
/** The core classes, in the order a principal's roles are tried; `human` holds no role and is the rest. */
|
|
19
|
+
export declare const CORE_PRINCIPAL_CLASSES: readonly ["human", "agent", "runner", "service"];
|
|
20
|
+
export type CorePrincipalClass = (typeof CORE_PRINCIPAL_CLASSES)[number];
|
|
21
|
+
export interface PrincipalClass {
|
|
22
|
+
name: string;
|
|
23
|
+
/** One line saying who is in the class. */
|
|
24
|
+
description: string;
|
|
25
|
+
/** The role whose grant at base puts a principal in the class, or null for `human`, the rest. */
|
|
26
|
+
role: string | null;
|
|
27
|
+
/** `core`, or the pin (package name or path) that supplies the class. */
|
|
28
|
+
source: string;
|
|
29
|
+
}
|
|
30
|
+
export declare const CORE_CLASSES: readonly PrincipalClass[];
|
|
31
|
+
/** A class as the rule reads it: its name, and the role whose grant puts a principal in it (null for `human`). */
|
|
32
|
+
export type ClassGrant = Pick<PrincipalClass, "name" | "role">;
|
|
33
|
+
/** What an `identity.gates` gate is judged by, all read at base. */
|
|
34
|
+
export interface GateAdmissionRule {
|
|
35
|
+
gate: string;
|
|
36
|
+
/** The class the approver must be in, or null for any signer. */
|
|
37
|
+
class: string | null;
|
|
38
|
+
/** The signers file at base: its path, whether it exists, and its signers. */
|
|
39
|
+
signers: {
|
|
40
|
+
signersPath: string;
|
|
41
|
+
active: boolean;
|
|
42
|
+
signers: readonly Signer[];
|
|
43
|
+
};
|
|
44
|
+
/** The role grants at base (`.chant/trust.json` `roles`). */
|
|
45
|
+
roles: Readonly<Record<string, readonly string[]>>;
|
|
46
|
+
/** Every class the rule knows, core first, then domain classes in pin order. */
|
|
47
|
+
classes: readonly ClassGrant[];
|
|
48
|
+
/**
|
|
49
|
+
* Why a class the rule does not know is unknown, for the refusal. Default
|
|
50
|
+
* "no pinned package supplies", which is what chant says once it has read
|
|
51
|
+
* every pin.
|
|
52
|
+
*/
|
|
53
|
+
unknownClass?: string;
|
|
54
|
+
}
|
|
55
|
+
/** Every class `principal`'s role grants put it in, in `classes` order: what `classesOf` in `./principal-classes.ts` answers. */
|
|
56
|
+
export declare function grantedClasses(classes: readonly ClassGrant[], roles: Readonly<Record<string, readonly string[]>>, principal: string | null): string[];
|
|
57
|
+
/**
|
|
58
|
+
* Why `a` does not count toward the gate, or null when it does. An approval
|
|
59
|
+
* counts only when its seal verifies for its approver against the signers at
|
|
60
|
+
* base and, when the rule names a class, the role grants at base put the
|
|
61
|
+
* approver in it (`human`: in no other class). A class the rule does not
|
|
62
|
+
* know lets nothing through.
|
|
63
|
+
*/
|
|
64
|
+
export declare function admitApprover(rule: GateAdmissionRule, a: SealedGateApproval): string | null;
|
|
65
|
+
//# sourceMappingURL=identity-gate-rule.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"identity-gate-rule.d.ts","sourceRoot":"","sources":["../../src/workspace/identity-gate-rule.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAkF,KAAK,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAC5I,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAElD,2GAA2G;AAC3G,eAAO,MAAM,sBAAsB,YAAI,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,SAAS,CAAU,CAAC;AACvF,MAAM,MAAM,kBAAkB,GAAG,CAAC,OAAO,sBAAsB,CAAC,CAAC,MAAM,CAAC,CAAC;AAEzE,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,2CAA2C;IAC3C,WAAW,EAAE,MAAM,CAAC;IACpB,iGAAiG;IACjG,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,yEAAyE;IACzE,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,eAAO,MAAM,YAAY,EAAE,SAAS,cAAc,EAKjD,CAAC;AAEF,kHAAkH;AAClH,MAAM,MAAM,UAAU,GAAG,IAAI,CAAC,cAAc,EAAE,MAAM,GAAG,MAAM,CAAC,CAAC;AAE/D,oEAAoE;AACpE,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,iEAAiE;IACjE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,8EAA8E;IAC9E,OAAO,EAAE;QAAE,WAAW,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,OAAO,CAAC;QAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAA;KAAE,CAAC;IAC9E,6DAA6D;IAC7D,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC,CAAC;IACnD,gFAAgF;IAChF,OAAO,EAAE,SAAS,UAAU,EAAE,CAAC;IAC/B;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAID,iIAAiI;AACjI,wBAAgB,cAAc,CAAC,OAAO,EAAE,SAAS,UAAU,EAAE,EAAE,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,EAAE,CAIrJ;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,iBAAiB,EAAE,CAAC,EAAE,kBAAkB,GAAG,MAAM,GAAG,IAAI,CAa3F"}
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
import type { Declaration, SignedGate } from "./declaration.js";
|
|
28
28
|
import { type ClassRegistry } from "./principal-classes.js";
|
|
29
29
|
import type { TrustPolicy } from "./trust/policy.js";
|
|
30
|
-
import {
|
|
30
|
+
import type { SealedGateApproval } from "./trust/gate-seal.js";
|
|
31
31
|
/** The forges a forge identity can name. */
|
|
32
32
|
export declare const FORGES: readonly ["github", "gitlab", "forgejo", "gitea"];
|
|
33
33
|
export type Forge = (typeof FORGES)[number];
|
|
@@ -98,7 +98,9 @@ export interface GateRuleSource extends IdentitySource {
|
|
|
98
98
|
* approval counts only when its seal verifies for its approver against the
|
|
99
99
|
* signers at base and, when the rule names a class, the approver's role
|
|
100
100
|
* grants at base put it in that class. A class no pinned package supplies
|
|
101
|
-
* lets nothing through.
|
|
101
|
+
* lets nothing through. The judge is {@link admitApprover}, which
|
|
102
|
+
* `@intentius/chant/gate-decision` also judges with (#3862). The policy is
|
|
103
|
+
* the one at base, never a returned work's copy.
|
|
102
104
|
*/
|
|
103
105
|
export declare function gateAdmissionFrom(source: GateRuleSource, gate: string): GateAdmission | null;
|
|
104
106
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"identity.d.ts","sourceRoot":"","sources":["../../src/workspace/identity.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"identity.d.ts","sourceRoot":"","sources":["../../src/workspace/identity.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAE7D,OAAO,EAA4C,KAAK,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAGnG,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAG5D,4CAA4C;AAC5C,eAAO,MAAM,MAAM,YAAI,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,OAAO,CAAU,CAAC;AACxE,MAAM,MAAM,KAAK,GAAG,CAAC,OAAO,MAAM,CAAC,CAAC,MAAM,CAAC,CAAC;AAK5C,qCAAqC;AACrC,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,KAAK,CAAC;IACb,2DAA2D;IAC3D,IAAI,EAAE,MAAM,CAAC;IACb,2DAA2D;IAC3D,KAAK,EAAE,MAAM,CAAC;CACf;AAKD;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,aAAa,GAAG,IAAI,CAO1E;AAED,8HAA8H;AAC9H,wBAAgB,cAAc,CAAC,EAAE,EAAE,aAAa,GAAG,MAAM,CAExD;AAED,MAAM,MAAM,YAAY,GAAG,QAAQ,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,CAAC;AAEhE,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,YAAY,CAAC;IACnB,+EAA+E;IAC/E,KAAK,EAAE,aAAa,GAAG,IAAI,CAAC;CAC7B;AAID;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,WAAW,GAAE,WAAW,GAAG,IAAW,GAAG,UAAU,CAQnH;AAED,uDAAuD;AACvD,eAAO,MAAM,cAAc,qCAAsE,CAAC;AAClG,MAAM,MAAM,YAAY,GAAG,CAAC,OAAO,cAAc,CAAC,CAAC,MAAM,CAAC,CAAC;AAE3D,qBAAa,aAAc,SAAQ,KAAK;IACtC,QAAQ,CAAC,IAAI,EAAE,YAAY,CAA4B;IACvD,YAAY,OAAO,EAAE,MAAM,EAG1B;CACF;AAED,8GAA8G;AAC9G,MAAM,WAAW,cAAc;IAC7B,WAAW,EAAE,WAAW,GAAG,IAAI,CAAC;IAChC,MAAM,EAAE,WAAW,CAAC;CACrB;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,cAAc,EAAE,KAAK,EAAE,SAAS,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAAO,GAAG,IAAI,CAS7I;AAED,+EAA+E;AAC/E,MAAM,MAAM,gBAAgB,GAAG,kBAAkB,CAAC;AAElD,yFAAyF;AACzF,MAAM,WAAW,aAAa;IAC5B,WAAW,EAAE,UAAU,CAAC;IACxB,0EAA0E;IAC1E,OAAO,CAAC,QAAQ,EAAE,gBAAgB,GAAG,MAAM,GAAG,IAAI,CAAC;CACpD;AAED,sHAAsH;AACtH,MAAM,WAAW,cAAe,SAAQ,cAAc;IACpD,OAAO,EAAE,aAAa,CAAC;CACxB;AAED;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,cAAc,EAAE,IAAI,EAAE,MAAM,GAAG,aAAa,GAAG,IAAI,CAM5F;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,aAAa,GAAG,IAAI,CAE5F"}
|
|
@@ -24,26 +24,15 @@
|
|
|
24
24
|
import { type KindLoadProblem, type PinLike } from "./kinds.js";
|
|
25
25
|
import type { TrustPolicy } from "./trust/policy.js";
|
|
26
26
|
import { type WorkspaceTree } from "./tree.js";
|
|
27
|
+
import { type PrincipalClass } from "./identity-gate-rule.js";
|
|
27
28
|
export declare const PRINCIPALS_SCHEMA_ID: string;
|
|
28
29
|
/** The subpath a package publishes its principal classes at. */
|
|
29
30
|
export declare const PRINCIPALS_SUBPATH = "./workspace-principals";
|
|
30
|
-
|
|
31
|
-
export declare const CORE_PRINCIPAL_CLASSES: readonly ["human", "agent", "runner", "service"];
|
|
32
|
-
export type CorePrincipalClass = (typeof CORE_PRINCIPAL_CLASSES)[number];
|
|
31
|
+
export { CORE_CLASSES, CORE_PRINCIPAL_CLASSES, type CorePrincipalClass, type PrincipalClass } from "./identity-gate-rule.js";
|
|
33
32
|
/** A class name: a core class, or a domain class a pinned package supplies. */
|
|
34
33
|
export type PrincipalClassName = string;
|
|
35
34
|
/** The grammar of a class name, as `workspace-principals.schema.json` and the declaration's writeScope keys say it. */
|
|
36
35
|
export declare const CLASS_NAME: RegExp;
|
|
37
|
-
export interface PrincipalClass {
|
|
38
|
-
name: string;
|
|
39
|
-
/** One line saying who is in the class. */
|
|
40
|
-
description: string;
|
|
41
|
-
/** The role whose grant at base puts a principal in the class, or null for `human`, the rest. */
|
|
42
|
-
role: string | null;
|
|
43
|
-
/** `core`, or the pin (package name or path) that supplies the class. */
|
|
44
|
-
source: string;
|
|
45
|
-
}
|
|
46
|
-
export declare const CORE_CLASSES: readonly PrincipalClass[];
|
|
47
36
|
export interface ClassRegistry {
|
|
48
37
|
get(name: string): PrincipalClass | undefined;
|
|
49
38
|
/** Every class name, core first, for "unknown class" messages. */
|
|
@@ -63,7 +52,7 @@ export declare function roleGrants(policy: TrustPolicy): Record<string, string[]
|
|
|
63
52
|
/**
|
|
64
53
|
* Every class `principal`'s role grants at base put it in, in registry order
|
|
65
54
|
* (core first). Empty for a principal in no class but `human`, and for null.
|
|
66
|
-
* Names compare as
|
|
55
|
+
* Names compare as `normalisePrincipal` (`./records.ts`) folds them.
|
|
67
56
|
*/
|
|
68
57
|
export declare function classesOf(classes: ClassRegistry, policy: TrustPolicy, principal: string | null): string[];
|
|
69
58
|
/** The class write scope judges `principal` as: the first class its grants put it in, or `human`. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"principal-classes.d.ts","sourceRoot":"","sources":["../../src/workspace/principal-classes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAKH,OAAO,EAAuC,KAAK,eAAe,EAAE,KAAK,OAAO,EAAE,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"principal-classes.d.ts","sourceRoot":"","sources":["../../src/workspace/principal-classes.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
package/src/cli/commands/lint.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { applyConfiguredPreset, applyConfiguredSeverity, resolvePresetIds } from
|
|
|
32
32
|
import { applyInlineSuppressions } from "../../lint/suppressions";
|
|
33
33
|
import { getProvenance } from "../../provenance";
|
|
34
34
|
import { runLexiconPostSynthChecks } from "./build";
|
|
35
|
+
import { armSandboxPolicyExecution, runProjectPolicies } from "../../lint/policy-sandbox";
|
|
35
36
|
|
|
36
37
|
// Import config loader
|
|
37
38
|
import { loadConfig, resolveRulesForFile, resolveConfiguredSeverity, findProjectRoot } from "../../lint/config";
|
|
@@ -665,9 +666,15 @@ async function runOpCheckDiagnostics(
|
|
|
665
666
|
* builds `infraPath`, the target the caller asked to lint, never the project
|
|
666
667
|
* root above it, so `chant lint <dir>` costs a build of `<dir>` and nothing
|
|
667
668
|
* more (the same scoping {@link runOpCheckDiagnostics} documents). It is
|
|
668
|
-
* skipped outright when no loaded lexicon ships post-synth checks
|
|
669
|
-
*
|
|
670
|
-
* all.
|
|
669
|
+
* skipped outright when no loaded lexicon ships post-synth checks and the
|
|
670
|
+
* project declares no `lint.policies`, which keeps a project with no lexicon,
|
|
671
|
+
* and every core-only fixture, from building at all.
|
|
672
|
+
*
|
|
673
|
+
* The project's `lint.policies` run over the same build after the lexicons'
|
|
674
|
+
* checks (#3870), with every lexicon's output and the env, as `chant build`
|
|
675
|
+
* runs them, and sandboxed when the build is. `lint.rules` applies to their
|
|
676
|
+
* ids; a policy that does not load is reported under
|
|
677
|
+
* {@link BUILD_FAILURE_RULE_ID}.
|
|
671
678
|
*
|
|
672
679
|
* A target that does not build reports each build error under
|
|
673
680
|
* {@link BUILD_FAILURE_RULE_ID} and skips the checks. Findings go through
|
|
@@ -686,10 +693,10 @@ async function runLexiconPostSynthDiagnostics(
|
|
|
686
693
|
const diagnostics: LintDiagnostic[] = [];
|
|
687
694
|
const suppressed: Array<LintDiagnostic & { reason?: string }> = [];
|
|
688
695
|
const withChecks = plugins.filter((plugin) => (plugin.postSynthChecks?.() ?? []).length > 0);
|
|
689
|
-
if (withChecks.length === 0) return { diagnostics, suppressed };
|
|
690
|
-
|
|
691
696
|
const projectRoot = findProjectRoot(infraPath);
|
|
692
697
|
const lintConfig = loadConfig(projectRoot);
|
|
698
|
+
if (withChecks.length === 0 && (lintConfig.policies ?? []).length === 0) return { diagnostics, suppressed };
|
|
699
|
+
|
|
693
700
|
// Where a finding that names no declared entity is reported: the config the
|
|
694
701
|
// build loads once it is known (past a lint-only fragment), else the nearest.
|
|
695
702
|
let fallbackFile = findProjectConfig(projectRoot).configPath ?? projectRoot;
|
|
@@ -707,6 +714,7 @@ async function runLexiconPostSynthDiagnostics(
|
|
|
707
714
|
let telemetry: Awaited<ReturnType<typeof resolveTelemetryAttribution>>;
|
|
708
715
|
let configDir = infraPath;
|
|
709
716
|
let loadedConfig: Record<string, unknown> | undefined;
|
|
717
|
+
let policies: readonly string[] = [];
|
|
710
718
|
try {
|
|
711
719
|
const loaded = await loadChantConfigUpward(infraPath);
|
|
712
720
|
const config = loaded.config;
|
|
@@ -720,6 +728,10 @@ async function runLexiconPostSynthDiagnostics(
|
|
|
720
728
|
// fragment with no `buildParams` (a `src/chant.config.json`).
|
|
721
729
|
const params = buildParams ?? resolveBuildParams(config.buildParams, { env: process.env }).provenance;
|
|
722
730
|
env = resolveOwnershipEnv(config, params);
|
|
731
|
+
policies = config.lint?.policies ?? [];
|
|
732
|
+
// #3870: a sandboxed build sandboxes the policies too, as `chant build` arms it.
|
|
733
|
+
const modes = resolveBuildModes(config, { sandbox });
|
|
734
|
+
if (modes.sandbox && policies.length > 0) armSandboxPolicyExecution();
|
|
723
735
|
telemetry = await resolveTelemetryAttribution(configDir, config as unknown as Record<string, unknown>, env);
|
|
724
736
|
// Every lexicon the lint loaded, as `chant build` serializes with every
|
|
725
737
|
// lexicon it loaded: a check may read another lexicon's entities, and the
|
|
@@ -732,7 +744,7 @@ async function runLexiconPostSynthDiagnostics(
|
|
|
732
744
|
config,
|
|
733
745
|
configDir,
|
|
734
746
|
plugins,
|
|
735
|
-
modes
|
|
747
|
+
modes,
|
|
736
748
|
ownership: resolveOwnershipMarker(config, params),
|
|
737
749
|
buildParams: params,
|
|
738
750
|
telemetry,
|
|
@@ -781,6 +793,21 @@ async function runLexiconPostSynthDiagnostics(
|
|
|
781
793
|
}
|
|
782
794
|
}
|
|
783
795
|
|
|
796
|
+
// #3870: the project's `lint.policies`, over the same build, as `chant
|
|
797
|
+
// build` runs them: every lexicon's output at once, with the env, and
|
|
798
|
+
// `lint.rules` applied to their ids.
|
|
799
|
+
if (policies.length > 0) {
|
|
800
|
+
let raw: Awaited<ReturnType<typeof runProjectPolicies>>;
|
|
801
|
+
try {
|
|
802
|
+
raw = await runProjectPolicies({ policies, configDir, buildResult: result, env });
|
|
803
|
+
} catch (err) {
|
|
804
|
+
diagnostics.push(buildFailure(err instanceof Error ? err.message : String(err)));
|
|
805
|
+
return { diagnostics, suppressed };
|
|
806
|
+
}
|
|
807
|
+
const configured = applyConfiguredSeverity(raw, lintConfig.rules);
|
|
808
|
+
for (const d of configured.diagnostics) diagnostics.push(toLint(d));
|
|
809
|
+
}
|
|
810
|
+
|
|
784
811
|
return { diagnostics, suppressed };
|
|
785
812
|
}
|
|
786
813
|
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* toolchain, and checked sealed approvals with its own copy of chant's seal
|
|
6
6
|
* rule because `@intentius/chant/op/gate` pulls in zod and the git ledger
|
|
7
7
|
* code. `@intentius/chant/gate-decision` holds the decision for a plan digest
|
|
8
|
-
* under `ledger`, `pr-review` and `sealed`,
|
|
9
|
-
* node:crypto. This bundles it the way terragucci would and reads esbuild's
|
|
8
|
+
* under `ledger`, `pr-review` and `sealed`, the seal check over
|
|
9
|
+
* node:crypto, and the `identity.gates` rule with its class check (#3862). This bundles it the way terragucci would and reads esbuild's
|
|
10
10
|
* own list of what went in.
|
|
11
11
|
*/
|
|
12
12
|
|
|
@@ -46,6 +46,7 @@ describe("the gate-decision bundle", () => {
|
|
|
46
46
|
"packages/core/src/notify.ts",
|
|
47
47
|
"packages/core/src/op/gate-point.ts",
|
|
48
48
|
"packages/core/src/op/op-waves.ts",
|
|
49
|
+
"packages/core/src/workspace/identity-gate-rule.ts",
|
|
49
50
|
"packages/core/src/workspace/jsonc.ts",
|
|
50
51
|
"packages/core/src/workspace/trust/gate-seal.ts",
|
|
51
52
|
"packages/core/src/workspace/trust/trust-files.ts",
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3862: `identityGateAdmissionAt` judges an `identity.gates` approval from a
|
|
3
|
+
* bare clone at a base commit as chant's `gateAdmission(cwd, gate, base)`
|
|
4
|
+
* does: the seal against the signers at base, and the class against the role
|
|
5
|
+
* grants at base. Each fixture is judged both ways and the answers compared,
|
|
6
|
+
* reasons included.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "vitest";
|
|
10
|
+
import {
|
|
11
|
+
admitApprover,
|
|
12
|
+
CORE_CLASSES,
|
|
13
|
+
decideGateApproval,
|
|
14
|
+
GATE_SEAL_NAMESPACE,
|
|
15
|
+
gateSealPayload,
|
|
16
|
+
identityGateAdmissionAt,
|
|
17
|
+
type GateResolutionRecord,
|
|
18
|
+
} from "./gate-decision";
|
|
19
|
+
import { gateAdmission } from "./workspace/identity";
|
|
20
|
+
import { hasSshKeygen, TestRepo, type Key } from "./workspace/trust/test-repo";
|
|
21
|
+
import { evaluateGate, memoryGateLedgerPort } from "./op/gate";
|
|
22
|
+
|
|
23
|
+
const D1 = "sha256:" + "a".repeat(64);
|
|
24
|
+
const GATES = { ship: {}, deploy: { class: "human" }, release: { class: "runner" }, ops: { class: "operator" } };
|
|
25
|
+
const NAMES = [...Object.keys(GATES), "undeclared"];
|
|
26
|
+
|
|
27
|
+
describe.skipIf(!hasSshKeygen)("identity.gates read at base, as chant reads it", () => {
|
|
28
|
+
const repos: TestRepo[] = [];
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
while (repos.length) repos.pop()!.cleanup();
|
|
31
|
+
});
|
|
32
|
+
// Keys and seals are made once, by ssh-keygen, and shared by every fixture.
|
|
33
|
+
let signer: TestRepo;
|
|
34
|
+
let k: Record<"alice" | "bob" | "carol" | "mallory", Key>;
|
|
35
|
+
const sigs = new Map<string, string>();
|
|
36
|
+
beforeAll(() => {
|
|
37
|
+
signer = new TestRepo("3862-keys");
|
|
38
|
+
k = { alice: signer.key("alice"), bob: signer.key("bob"), carol: signer.key("carol"), mallory: signer.key("mallory") };
|
|
39
|
+
});
|
|
40
|
+
afterAll(() => signer.cleanup());
|
|
41
|
+
const sign = (key: Key, payload: Buffer): string => {
|
|
42
|
+
const id = `${key.name}\n${payload.toString("utf-8")}`;
|
|
43
|
+
if (!sigs.has(id)) sigs.set(id, signer.sshSign(key, payload, GATE_SEAL_NAMESPACE));
|
|
44
|
+
return sigs.get(id)!;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function setup(files: (k: Record<string, Key>) => Record<string, string>, declaration: Record<string, unknown> = {}) {
|
|
48
|
+
const r = new TestRepo("3862-identity");
|
|
49
|
+
repos.push(r);
|
|
50
|
+
r.write("chant.workspace.json", JSON.stringify({ name: "acme", schema: 1, members: [], identity: { gates: GATES }, ...declaration }));
|
|
51
|
+
for (const [path, text] of Object.entries(files(k))) r.write(path, text);
|
|
52
|
+
const base = r.commit("base");
|
|
53
|
+
// The change being applied lists mallory and makes alice a runner; neither counts at base.
|
|
54
|
+
r.write(".chant/allowed_signers", `alice ${k.alice.pub}\nbob ${k.bob.pub}\nmallory ${k.mallory.pub}\n`);
|
|
55
|
+
r.write(".chant/trust.json", JSON.stringify({ schema: 1, roles: { runner: ["alice", "bob"] } }));
|
|
56
|
+
r.commit("widens the policy");
|
|
57
|
+
const approval = (gate: string, resolvedBy: string, extra: Partial<GateResolutionRecord> = {}): GateResolutionRecord => ({
|
|
58
|
+
version: 1, op: "web", gate, resolvedBy, timestamp: "2026-10-11T11:00:00.000Z", planDigest: D1, ...extra,
|
|
59
|
+
});
|
|
60
|
+
const seal = (a: GateResolutionRecord, key: Key, signer = a.resolvedBy): GateResolutionRecord => ({
|
|
61
|
+
...a,
|
|
62
|
+
seal: { signer, key: "k", signature: sign(key, gateSealPayload(a)) },
|
|
63
|
+
});
|
|
64
|
+
const made = new Map<string, GateResolutionRecord[]>();
|
|
65
|
+
const approvals = (gate: string): GateResolutionRecord[] => made.get(gate) ?? (made.set(gate, approvalsOf(gate)), made.get(gate)!);
|
|
66
|
+
const approvalsOf = (gate: string): GateResolutionRecord[] => [
|
|
67
|
+
approval(gate, "alice"),
|
|
68
|
+
seal(approval(gate, "alice"), k.alice),
|
|
69
|
+
seal(approval(gate, "BOB "), k.bob, "bob"),
|
|
70
|
+
seal(approval(gate, "mallory"), k.mallory),
|
|
71
|
+
seal(approval(gate, "carol"), k.carol),
|
|
72
|
+
seal(approval(gate, "alice"), k.bob),
|
|
73
|
+
seal(approval(gate, "alice"), k.alice, "bob"),
|
|
74
|
+
{ ...approval(gate, "alice"), seal: { signer: "alice" } as unknown as GateResolutionRecord["seal"] },
|
|
75
|
+
{ ...seal(approval(gate, "alice"), k.alice), planDigest: "sha256:" + "b".repeat(64) },
|
|
76
|
+
];
|
|
77
|
+
return { r, base, approvals };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Every approval of every gate, judged by both readers: the pairs, and the reasons gate-decision gave. */
|
|
81
|
+
function judgeBoth(r: TestRepo, base: string, approvals: (gate: string) => GateResolutionRecord[]) {
|
|
82
|
+
const ours: Record<string, (string | null)[] | null> = {};
|
|
83
|
+
for (const gate of NAMES) {
|
|
84
|
+
const here = identityGateAdmissionAt(r.dir, base, gate);
|
|
85
|
+
const chant = gateAdmission(r.dir, gate, base);
|
|
86
|
+
expect(here === null, gate).toBe(chant === null);
|
|
87
|
+
if (here === null || chant === null) {
|
|
88
|
+
ours[gate] = null;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
expect(here.requirement).toEqual({ gate, class: chant.requirement.class });
|
|
92
|
+
const reasons = approvals(gate).map((a) => here.refuses(a));
|
|
93
|
+
expect(reasons, gate).toEqual(approvals(gate).map((a) => chant.refuses(a)));
|
|
94
|
+
ours[gate] = reasons;
|
|
95
|
+
}
|
|
96
|
+
return ours;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
test("signers, role grants and classes at base, approval by approval", () => {
|
|
100
|
+
const { r, base, approvals } = setup((k) => ({
|
|
101
|
+
".chant/allowed_signers": `alice ${k.alice.pub}\nbob ${k.bob.pub}\ncarol namespaces="git" ${k.carol.pub}\n`,
|
|
102
|
+
".chant/trust.json": JSON.stringify({ schema: 1, roles: { runner: ["Bob"] } }),
|
|
103
|
+
}));
|
|
104
|
+
const ours = judgeBoth(r, base, approvals);
|
|
105
|
+
expect(ours.undeclared).toBeNull();
|
|
106
|
+
const passed = (gate: string) => ours[gate]!.flatMap((reason, i) => (reason === null ? [i] : []));
|
|
107
|
+
// Any signer: alice and bob, each with their own key. Not mallory (listed after base), not carol (no chant-gate namespace).
|
|
108
|
+
expect(passed("ship")).toEqual([1, 2]);
|
|
109
|
+
// human: alice; bob holds the runner role at base.
|
|
110
|
+
expect(passed("deploy")).toEqual([1]);
|
|
111
|
+
expect(ours.deploy![2]).toMatch(/^BOB is not in the class human that gate deploy needs/);
|
|
112
|
+
// runner: bob, by the grant at base; alice's grant comes after base.
|
|
113
|
+
expect(passed("release")).toEqual([2]);
|
|
114
|
+
expect(ours.release![1]).toBe("alice is not in the class runner that gate release needs: no role grant at base puts them there");
|
|
115
|
+
// operator: no pinned package supplies it.
|
|
116
|
+
expect(passed("ops")).toEqual([]);
|
|
117
|
+
expect(ours.ops![1]).toMatch(/names the class operator, which no pinned package supplies, .*known classes: human, agent, runner, service/);
|
|
118
|
+
expect(ours.ship![0]).toBe("the approval by alice is not signed; gate ship needs chant approve --sign");
|
|
119
|
+
expect(ours.ship![3]).toBe("mallory has no key in .chant/allowed_signers at base, so the seal can't count");
|
|
120
|
+
expect(ours.ship![4]).toMatch(/^the seal does not verify for carol against \.chant\/allowed_signers at base/);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("a signers path trust.json names", () => {
|
|
124
|
+
const named = setup((k) => ({
|
|
125
|
+
".chant/trust.json": JSON.stringify({ schema: 1, signers: "ops/signers", roles: { runner: ["alice"] } }),
|
|
126
|
+
"ops/signers": `alice ${k.alice.pub}\n`,
|
|
127
|
+
}));
|
|
128
|
+
expect(judgeBoth(named.r, named.base, named.approvals).release![1]).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("no signers file at base, and a trust.json chant refuses", () => {
|
|
132
|
+
const none = setup(() => ({ "README.md": "x\n" }));
|
|
133
|
+
expect(judgeBoth(none.r, none.base, none.approvals).ship![1]).toBe("gate ship needs a signed approval, and there is no signers file (.chant/allowed_signers) at base to check one against");
|
|
134
|
+
|
|
135
|
+
const invalid = setup((k) => ({
|
|
136
|
+
".chant/trust.json": JSON.stringify({ schema: 1, roles: { Admin: ["alice"] } }),
|
|
137
|
+
".chant/allowed_signers": `alice ${k.alice.pub}\n`,
|
|
138
|
+
}));
|
|
139
|
+
expect(judgeBoth(invalid.r, invalid.base, invalid.approvals).ship![1]).toMatch(/no signers file/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("a domain class the caller read from a pin, and a pin this reader does not load", () => {
|
|
143
|
+
const { r, base, approvals } = setup(
|
|
144
|
+
(k) => ({ ".chant/allowed_signers": `alice ${k.alice.pub}\n`, ".chant/trust.json": JSON.stringify({ schema: 1, roles: { operator: ["alice"] } }) }),
|
|
145
|
+
{ pins: [{ package: "@acme/ops-classes", version: "1.0.0" }] },
|
|
146
|
+
);
|
|
147
|
+
const unread = identityGateAdmissionAt(r.dir, base, "ops")!;
|
|
148
|
+
expect(unread.refuses(approvals("ops")[1])).toMatch(/is not a core class, and this reader does not load the classes the pinned packages in chant\.workspace\.json at base supply/);
|
|
149
|
+
const read = identityGateAdmissionAt(r.dir, base, "ops", { classes: [{ name: "operator", role: "operator" }] })!;
|
|
150
|
+
expect(read.refuses(approvals("ops")[1])).toBeNull();
|
|
151
|
+
expect(read.refuses(approvals("ops")[0])).toMatch(/not signed/);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("passed to decideGateApproval as refuses, it counts what chant's wave gate counts", async () => {
|
|
155
|
+
const { r, base, approvals } = setup((k) => ({
|
|
156
|
+
".chant/allowed_signers": `alice ${k.alice.pub}\nbob ${k.bob.pub}\n`,
|
|
157
|
+
".chant/trust.json": JSON.stringify({ schema: 1, roles: { runner: ["bob"] } }),
|
|
158
|
+
}));
|
|
159
|
+
const here = identityGateAdmissionAt(r.dir, base, "release")!;
|
|
160
|
+
const chant = gateAdmission(r.dir, "release", base)!;
|
|
161
|
+
for (const resolutions of [approvals("release").slice(0, 2), approvals("release")]) {
|
|
162
|
+
const decided = decideGateApproval({ ledger: { pending: [], resolutions }, gate: "release", planDigest: D1, refuses: here.refuses, now: "2026-10-11T12:00:00.000Z" });
|
|
163
|
+
const port = { ...memoryGateLedgerPort({ pending: [], resolutions }), approvalRule: async () => chant };
|
|
164
|
+
const check = await evaluateGate(port, { op: "web", gate: "release", planDigest: D1, now: "2026-10-11T12:00:00.000Z" });
|
|
165
|
+
expect(decided.status === "approved" && decided.via === "ledger" ? decided.resolution : null).toEqual(check.satisfied ? check.resolution : null);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("admitApprover", () => {
|
|
171
|
+
test("is the rule gateAdmission judges by: a class it does not know refuses before the seal is read", () => {
|
|
172
|
+
const rule = { gate: "ship", class: "operator", signers: { signersPath: ".chant/allowed_signers", active: false, signers: [] }, roles: {}, classes: CORE_CLASSES };
|
|
173
|
+
expect(admitApprover(rule, { op: "web", gate: "ship", resolvedBy: "alice", timestamp: "t" })).toMatch(/no pinned package supplies/);
|
|
174
|
+
expect(admitApprover({ ...rule, class: null }, { op: "web", gate: "ship", resolvedBy: "alice", timestamp: "t" })).toMatch(/no signers file/);
|
|
175
|
+
});
|
|
176
|
+
});
|
package/src/gate-decision.ts
CHANGED
|
@@ -16,6 +16,12 @@
|
|
|
16
16
|
* approving review, when its head planned this digest
|
|
17
17
|
* ({@link reviewApproval}).
|
|
18
18
|
*
|
|
19
|
+
* A gate the declaration at base names under `identity.gates` counts only a
|
|
20
|
+
* sealed approval from its class: {@link identityGateAdmissionAt} reads that
|
|
21
|
+
* rule from a bare clone at base and judges by `admitApprover`, the function
|
|
22
|
+
* chant's `gateAdmission` judges by (#3862); pass its `refuses` to
|
|
23
|
+
* {@link decideGateApproval}.
|
|
24
|
+
*
|
|
19
25
|
* With `spend` (#3784), an approval a wave already applied under is spent and
|
|
20
26
|
* counts for nothing, as terragucci's wave gate has it: the caller hands in
|
|
21
27
|
* the lines of `_gates/<op>/applied.jsonl` ({@link parseAppliedGateLines}).
|
|
@@ -36,7 +42,7 @@ import {
|
|
|
36
42
|
} from "./lifecycle/gate-ledger-select";
|
|
37
43
|
import { samePlanDigest } from "./lifecycle/plan-digest";
|
|
38
44
|
import { isApprovalSpent, type AppliedGateRecord } from "./lifecycle/gate-applied";
|
|
39
|
-
import { gateSealRefusal, type GateSigners } from "./workspace/trust/gate-seal";
|
|
45
|
+
import { gateSealRefusal, type GateSigners, type SealedGateApproval } from "./workspace/trust/gate-seal";
|
|
40
46
|
import {
|
|
41
47
|
DEFAULT_SIGNERS_PATH,
|
|
42
48
|
TRUST_CONFIG_PATH,
|
|
@@ -44,6 +50,7 @@ import {
|
|
|
44
50
|
trustConfigProblems,
|
|
45
51
|
} from "./workspace/trust/trust-files";
|
|
46
52
|
import { parseJsonText } from "./workspace/jsonc";
|
|
53
|
+
import { CORE_CLASSES, admitApprover, type ClassGrant, type GateAdmissionRule } from "./workspace/identity-gate-rule";
|
|
47
54
|
import type { GateResolutionRecord, PendingGateRecord } from "./lifecycle/gate-ledger";
|
|
48
55
|
import type { MergedReview } from "./op/gate-review";
|
|
49
56
|
import type { GateApprovalSource } from "./op/op-waves";
|
|
@@ -75,6 +82,14 @@ export {
|
|
|
75
82
|
type SignerSet,
|
|
76
83
|
} from "./workspace/trust/trust-files";
|
|
77
84
|
export type { GateResolutionRecord, PendingGateRecord } from "./lifecycle/gate-ledger";
|
|
85
|
+
export {
|
|
86
|
+
CORE_CLASSES,
|
|
87
|
+
admitApprover,
|
|
88
|
+
grantedClasses,
|
|
89
|
+
type ClassGrant,
|
|
90
|
+
type GateAdmissionRule,
|
|
91
|
+
type PrincipalClass,
|
|
92
|
+
} from "./workspace/identity-gate-rule";
|
|
78
93
|
export {
|
|
79
94
|
appliedGateApplies,
|
|
80
95
|
appliedGatePath,
|
|
@@ -117,17 +132,12 @@ export function baseCommit(repo: string): string {
|
|
|
117
132
|
return parent;
|
|
118
133
|
}
|
|
119
134
|
|
|
120
|
-
/**
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
* trust.json that is not valid (`trustConfigProblems`) turns attestation
|
|
124
|
-
* off, as it does in chant, so a sealed gate passes nothing. `base` is a
|
|
125
|
-
* commit the repository holds, such as {@link baseCommit}'s answer.
|
|
126
|
-
*/
|
|
127
|
-
export function gateSignersAt(repo: string, base: string): GateSigners {
|
|
128
|
-
const off = (problems: string[]): GateSigners => ({ base, signersPath: DEFAULT_SIGNERS_PATH, active: false, signers: [], problems });
|
|
135
|
+
/** The signers and role grants at `base`, as chant's trust policy reads them. */
|
|
136
|
+
function trustAt(repo: string, base: string): { signers: GateSigners; roles: Record<string, string[]> } {
|
|
137
|
+
const off = (problems: string[]) => ({ signers: { base, signersPath: DEFAULT_SIGNERS_PATH, active: false, signers: [], problems }, roles: {} });
|
|
129
138
|
const configText = showAt(repo, base, TRUST_CONFIG_PATH);
|
|
130
139
|
let signersPath = DEFAULT_SIGNERS_PATH;
|
|
140
|
+
let roles: Record<string, string[]> = {};
|
|
131
141
|
if (configText !== undefined) {
|
|
132
142
|
let raw: unknown;
|
|
133
143
|
try {
|
|
@@ -138,9 +148,21 @@ export function gateSignersAt(repo: string, base: string): GateSigners {
|
|
|
138
148
|
const problems = trustConfigProblems(raw);
|
|
139
149
|
if (problems.length > 0) return off([`${TRUST_CONFIG_PATH} is invalid: ${problems.join("; ")}`]);
|
|
140
150
|
signersPath = (raw as { signers?: string }).signers ?? DEFAULT_SIGNERS_PATH;
|
|
151
|
+
roles = (raw as { roles?: Record<string, string[]> }).roles ?? {};
|
|
141
152
|
}
|
|
142
153
|
const text = showAt(repo, base, signersPath);
|
|
143
|
-
return { base, signersPath, active: text !== undefined, signers: text === undefined ? [] : parseAllowedSigners(text).signers, problems: [] };
|
|
154
|
+
return { signers: { base, signersPath, active: text !== undefined, signers: text === undefined ? [] : parseAllowedSigners(text).signers, problems: [] }, roles };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The signers at `base`: the file `.chant/trust.json` names there, else
|
|
159
|
+
* `.chant/allowed_signers`, read as chant's trust policy reads it. A
|
|
160
|
+
* trust.json that is not valid (`trustConfigProblems`) turns attestation
|
|
161
|
+
* off, as it does in chant, so a sealed gate passes nothing. `base` is a
|
|
162
|
+
* commit the repository holds, such as {@link baseCommit}'s answer.
|
|
163
|
+
*/
|
|
164
|
+
export function gateSignersAt(repo: string, base: string): GateSigners {
|
|
165
|
+
return trustAt(repo, base).signers;
|
|
144
166
|
}
|
|
145
167
|
|
|
146
168
|
/** A gate the declaration at base names under `identity.gates`, and the principal class its signer must be in, if any. */
|
|
@@ -149,23 +171,79 @@ export interface IdentityGate {
|
|
|
149
171
|
class: string | null;
|
|
150
172
|
}
|
|
151
173
|
|
|
152
|
-
/**
|
|
153
|
-
|
|
154
|
-
* `identity.gates`: each counts only a sealed approval, from its class when
|
|
155
|
-
* it names one. chant checks the class against the role grants at base; this
|
|
156
|
-
* reads the names. Empty when there is no declaration at base. Throws when
|
|
157
|
-
* the declaration there cannot be parsed, since the gate cannot be decided.
|
|
158
|
-
*/
|
|
159
|
-
export function identityGatesAt(repo: string, base: string): IdentityGate[] {
|
|
174
|
+
/** The declaration at `base`, parsed, or null when there is none. Throws when it cannot be parsed. */
|
|
175
|
+
function declarationAt(repo: string, base: string): { file: string; value: unknown } | null {
|
|
160
176
|
for (const file of ["chant.workspace.json", "chant.workspace.jsonc"]) {
|
|
161
177
|
const text = showAt(repo, base, file);
|
|
162
178
|
if (text === undefined) continue;
|
|
163
179
|
const parsed = parseJsonText(text, { jsonc: file.endsWith(".jsonc") });
|
|
164
180
|
if (!parsed.ok) throw new Error(`${file} at base is not valid JSON (${parsed.message}), so the gate cannot be decided`);
|
|
165
|
-
|
|
166
|
-
return Object.entries(gates).map(([gate, entry]) => ({ gate, class: typeof entry?.class === "string" ? entry.class : null }));
|
|
181
|
+
return { file, value: parsed.value };
|
|
167
182
|
}
|
|
168
|
-
return
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function identityGatesOf(declaration: unknown): IdentityGate[] {
|
|
187
|
+
const gates = (declaration as { identity?: { gates?: Record<string, { class?: unknown } | null> } } | null)?.identity?.gates ?? {};
|
|
188
|
+
return Object.entries(gates).map(([gate, entry]) => ({ gate, class: typeof entry?.class === "string" ? entry.class : null }));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The gates `chant.workspace.json` (or `.jsonc`) at `base` names under
|
|
193
|
+
* `identity.gates`: each counts only a sealed approval, from its class when
|
|
194
|
+
* it names one. {@link identityGateAdmissionAt} judges an approval by one.
|
|
195
|
+
* Empty when there is no declaration at base. Throws when the declaration
|
|
196
|
+
* there cannot be parsed, since the gate cannot be decided.
|
|
197
|
+
*/
|
|
198
|
+
export function identityGatesAt(repo: string, base: string): IdentityGate[] {
|
|
199
|
+
const declaration = declarationAt(repo, base);
|
|
200
|
+
return declaration === null ? [] : identityGatesOf(declaration.value);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** An `identity.gates` gate at base, and the judge for its approvals. */
|
|
204
|
+
export interface IdentityGateAdmission {
|
|
205
|
+
requirement: IdentityGate;
|
|
206
|
+
/** Why `approval` does not count toward the gate, or null when it does. Pass it to {@link decideGateApproval} as `refuses`. */
|
|
207
|
+
refuses(approval: SealedGateApproval): string | null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The `identity.gates` rule for `gate` at `base`, or null when the
|
|
212
|
+
* declaration there names none: what chant's `gateAdmission(cwd, gate, base)`
|
|
213
|
+
* answers, read from a bare clone. An approval counts when its seal verifies
|
|
214
|
+
* for its approver against the signers at base and, when the gate names a
|
|
215
|
+
* class, the role grants in `.chant/trust.json` at base put the approver in
|
|
216
|
+
* it. Both judge with the same function ({@link admitApprover}).
|
|
217
|
+
*
|
|
218
|
+
* The classes are the four core ones, then `classes`, the domain classes the
|
|
219
|
+
* caller read from the pinned packages (`./workspace-principals`), in pin
|
|
220
|
+
* order. This reader loads no package. A gate whose class is in neither
|
|
221
|
+
* refuses every approval, and when the declaration at base pins anything the
|
|
222
|
+
* reason says the pins were not read, since one of them may supply it.
|
|
223
|
+
* Throws when the declaration at base cannot be parsed.
|
|
224
|
+
*/
|
|
225
|
+
export function identityGateAdmissionAt(
|
|
226
|
+
repo: string,
|
|
227
|
+
base: string,
|
|
228
|
+
gate: string,
|
|
229
|
+
opts: { classes?: readonly ClassGrant[] } = {},
|
|
230
|
+
): IdentityGateAdmission | null {
|
|
231
|
+
const declaration = declarationAt(repo, base);
|
|
232
|
+
const requirement = declaration === null ? undefined : identityGatesOf(declaration.value).find((g) => g.gate === gate);
|
|
233
|
+
if (!requirement) return null;
|
|
234
|
+
const { signers, roles } = trustAt(repo, base);
|
|
235
|
+
const pins = (declaration!.value as { pins?: unknown }).pins;
|
|
236
|
+
const rule: GateAdmissionRule = {
|
|
237
|
+
gate,
|
|
238
|
+
class: requirement.class,
|
|
239
|
+
signers,
|
|
240
|
+
roles,
|
|
241
|
+
classes: [...CORE_CLASSES, ...(opts.classes ?? [])],
|
|
242
|
+
...(Array.isArray(pins) && pins.length > 0
|
|
243
|
+
? { unknownClass: `is not a core class, and this reader does not load the classes the pinned packages in ${declaration!.file} at base supply` }
|
|
244
|
+
: {}),
|
|
245
|
+
};
|
|
246
|
+
return { requirement, refuses: (a) => admitApprover(rule, a) };
|
|
169
247
|
}
|
|
170
248
|
|
|
171
249
|
// ── pr-review ────────────────────────────────────────────────────────────
|