@intentius/chant 0.122.0 → 0.123.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/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/gate-resume.d.ts +1 -1
- package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +1 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/override-approve.d.ts +17 -0
- package/dist/cli/handlers/override-approve.d.ts.map +1 -0
- package/dist/cli/handlers/run-wave.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/gate-decision.d.ts +157 -0
- package/dist/gate-decision.d.ts.map +1 -0
- package/dist/lifecycle/gate-applied.d.ts +75 -0
- package/dist/lifecycle/gate-applied.d.ts.map +1 -0
- package/dist/lifecycle/gate-audit.d.ts +145 -0
- package/dist/lifecycle/gate-audit.d.ts.map +1 -0
- package/dist/lifecycle/gate-ledger-select.d.ts +54 -0
- package/dist/lifecycle/gate-ledger-select.d.ts.map +1 -0
- package/dist/lifecycle/gate-ledger.d.ts +1 -45
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/override-ledger.d.ts +34 -0
- package/dist/lifecycle/override-ledger.d.ts.map +1 -0
- package/dist/lifecycle/rollback.d.ts.map +1 -1
- package/dist/lifecycle/work-item-id.d.ts +22 -0
- package/dist/lifecycle/work-item-id.d.ts.map +1 -0
- package/dist/lifecycle/work-lease.d.ts +2 -2
- package/dist/lifecycle/work-lease.d.ts.map +1 -1
- package/dist/lint/post-synth.d.ts +20 -1
- package/dist/lint/post-synth.d.ts.map +1 -1
- package/dist/notify-verify.d.ts +62 -0
- package/dist/notify-verify.d.ts.map +1 -0
- package/dist/notify.d.ts +202 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/op/activities/activity-contracts.d.ts +4 -4
- package/dist/op/activities/gh-host.d.ts +37 -0
- package/dist/op/activities/gh-host.d.ts.map +1 -0
- package/dist/op/activities/lexicon-upgrade.d.ts +11 -0
- package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
- package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts +74 -76
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/gate-approval.d.ts +17 -1
- package/dist/op/gate-approval.d.ts.map +1 -1
- package/dist/op/gate-name.d.ts +8 -1
- package/dist/op/gate-name.d.ts.map +1 -1
- package/dist/op/gate-resume.d.ts +4 -2
- package/dist/op/gate-resume.d.ts.map +1 -1
- package/dist/op/gate-review.d.ts +2 -1
- package/dist/op/gate-review.d.ts.map +1 -1
- package/dist/op/gate.d.ts +43 -6
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +8 -6
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/op-waves-notify.d.ts +31 -0
- package/dist/op/op-waves-notify.d.ts.map +1 -0
- package/dist/op/op-waves-run.d.ts +91 -16
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves.d.ts +99 -2
- package/dist/op/op-waves.d.ts.map +1 -1
- package/dist/op/operator.d.ts +15 -0
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/work-lease-decl.d.ts.map +1 -1
- package/dist/op/work-lease-run.d.ts.map +1 -1
- package/dist/override-gate.d.ts +224 -0
- package/dist/override-gate.d.ts.map +1 -0
- package/dist/pr-forge.d.ts +198 -19
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/workspace/status-gates.d.ts +1 -0
- package/dist/workspace/status-gates.d.ts.map +1 -1
- package/dist/workspace/trust/gate-seal.d.ts +96 -0
- package/dist/workspace/trust/gate-seal.d.ts.map +1 -0
- package/dist/workspace/trust/policy.d.ts +2 -41
- package/dist/workspace/trust/policy.d.ts.map +1 -1
- package/dist/workspace/trust/seal.d.ts +2 -27
- package/dist/workspace/trust/seal.d.ts.map +1 -1
- package/dist/workspace/trust/trust-files.d.ts +57 -0
- package/dist/workspace/trust/trust-files.d.ts.map +1 -0
- package/dist/workspace/work-cli.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/cli/commands/build.ts +1 -1
- package/src/cli/commands/lint.ts +5 -2
- package/src/cli/handlers/__snapshots__/build-member.test.ts.snap +2 -2
- package/src/cli/handlers/gate-resume.ts +3 -2
- package/src/cli/handlers/operator.test.ts +12 -1
- package/src/cli/handlers/operator.ts +25 -13
- package/src/cli/handlers/override-approve.test.ts +87 -0
- package/src/cli/handlers/override-approve.ts +103 -0
- package/src/cli/handlers/run-wave.ts +10 -2
- package/src/cli/main.test.ts +8 -0
- package/src/cli/main.ts +15 -0
- package/src/cli/registry.ts +4 -0
- package/src/gate-decision-bundle.test.ts +56 -0
- package/src/gate-decision.test.ts +237 -0
- package/src/gate-decision.ts +321 -0
- package/src/lifecycle/gate-applied.ts +115 -0
- package/src/lifecycle/gate-audit-bundle.test.ts +47 -0
- package/src/lifecycle/gate-audit.test.ts +134 -0
- package/src/lifecycle/gate-audit.ts +438 -0
- package/src/lifecycle/gate-ledger-select.ts +113 -0
- package/src/lifecycle/gate-ledger.test.ts +22 -0
- package/src/lifecycle/gate-ledger.ts +9 -104
- package/src/lifecycle/override-ledger.test.ts +63 -0
- package/src/lifecycle/override-ledger.ts +69 -0
- package/src/lifecycle/rollback.ts +5 -1
- package/src/lifecycle/work-item-id.test.ts +71 -0
- package/src/lifecycle/work-item-id.ts +35 -0
- package/src/lifecycle/work-lease.ts +4 -5
- package/src/lint/post-synth.test.ts +29 -0
- package/src/lint/post-synth.ts +21 -2
- package/src/notify-bundle.test.ts +50 -0
- package/src/notify-verify.test.ts +59 -0
- package/src/notify-verify.ts +93 -0
- package/src/notify.schema.json +72 -0
- package/src/notify.test.ts +279 -0
- package/src/notify.ts +398 -0
- package/src/op/activities/gh-host.test.ts +130 -0
- package/src/op/activities/gh-host.ts +79 -0
- package/src/op/activities/lexicon-upgrade.ts +22 -1
- package/src/op/activities/propose-upgrade.ts +5 -1
- package/src/op/activities/reconcile.test.ts +284 -280
- package/src/op/activities/reconcile.ts +128 -131
- package/src/op/gate-approval.test.ts +175 -7
- package/src/op/gate-approval.ts +27 -1
- package/src/op/gate-name.ts +10 -2
- package/src/op/gate-resume.ts +6 -3
- package/src/op/gate-review.ts +4 -7
- package/src/op/gate.ts +117 -16
- package/src/op/index.ts +20 -5
- package/src/op/op-waves-notify.test.ts +127 -0
- package/src/op/op-waves-notify.ts +101 -0
- package/src/op/op-waves-run.ts +527 -104
- package/src/op/op-waves.test.ts +383 -3
- package/src/op/op-waves.ts +187 -6
- package/src/op/operator.test.ts +29 -0
- package/src/op/operator.ts +27 -2
- package/src/op/run-context.test.ts +2 -2
- package/src/op/work-lease-decl.ts +4 -4
- package/src/op/work-lease-run.test.ts +5 -0
- package/src/op/work-lease-run.ts +4 -3
- package/src/override-gate-bundle.test.ts +47 -0
- package/src/override-gate.test.ts +239 -0
- package/src/override-gate.ts +395 -0
- package/src/pr-forge-bundle.test.ts +40 -0
- package/src/pr-forge.test.ts +303 -0
- package/src/pr-forge.ts +502 -47
- package/src/workspace/records-write-contract.test.ts +3 -2
- package/src/workspace/status-gates.ts +7 -4
- package/src/workspace/trust/gate-seal.test.ts +156 -0
- package/src/workspace/trust/gate-seal.ts +254 -0
- package/src/workspace/trust/policy.ts +10 -165
- package/src/workspace/trust/seal.ts +19 -34
- package/src/workspace/trust/trust-files.test.ts +50 -0
- package/src/workspace/trust/trust-files.ts +245 -0
- package/src/workspace/work-cli.ts +5 -3
- package/src/workspace/work-history.test.ts +7 -0
|
@@ -28,7 +28,11 @@ import { homedir, tmpdir } from "node:os";
|
|
|
28
28
|
import { join, resolve } from "node:path";
|
|
29
29
|
import type { SealCode } from "../records";
|
|
30
30
|
import { type TrustPolicy } from "./policy";
|
|
31
|
+
import type { Signer } from "./trust-files";
|
|
31
32
|
import { sshKeygen, verifySshSignature } from "./ssh-commit";
|
|
33
|
+
import { GATE_SEAL_NAMESPACE, gateSealPayload, namespaceAllowed, sshSignatureKey, verifySshSignatureWith, type SealedGateApproval } from "./gate-seal";
|
|
34
|
+
|
|
35
|
+
export { GATE_SEAL_NAMESPACE, gateSealPayload, type SealedGateApproval } from "./gate-seal";
|
|
32
36
|
|
|
33
37
|
/** The ssh signature namespace of a verdict seal. */
|
|
34
38
|
export const REVIEW_SEAL_NAMESPACE = "chant-review";
|
|
@@ -36,12 +40,6 @@ export const REVIEW_SEAL_NAMESPACE = "chant-review";
|
|
|
36
40
|
/** The ssh signature namespace of a record's author seal (#2688). */
|
|
37
41
|
export const RECORD_SEAL_NAMESPACE = "chant-record";
|
|
38
42
|
|
|
39
|
-
/**
|
|
40
|
-
* The ssh signature namespace of a gate approval's seal (#3163, ws-080), so a
|
|
41
|
-
* verdict, record or commit signature can't stand in for one.
|
|
42
|
-
*/
|
|
43
|
-
export const GATE_SEAL_NAMESPACE = "chant-gate";
|
|
44
|
-
|
|
45
43
|
/** A seal as a review entry holds it. */
|
|
46
44
|
export interface VerdictSeal {
|
|
47
45
|
/** The principal who signed: the reviewer, as the signers file names them. */
|
|
@@ -87,33 +85,6 @@ export function recordSealPayload(record: string, digest: string, author: string
|
|
|
87
85
|
return Buffer.from(`${record}\n${digest}\n${author}\n${state ?? ""}`, "utf-8");
|
|
88
86
|
}
|
|
89
87
|
|
|
90
|
-
/** The gate approval a seal covers (#3163): the fields of a gate resolution line that say what was approved, by whom and when. */
|
|
91
|
-
export interface SealedGateApproval {
|
|
92
|
-
op: string;
|
|
93
|
-
gate: string;
|
|
94
|
-
environment?: string;
|
|
95
|
-
planDigest?: string;
|
|
96
|
-
resolvedBy: string;
|
|
97
|
-
/** Who relayed the approval for `resolvedBy` (#3402), when someone did. */
|
|
98
|
-
relayedBy?: string;
|
|
99
|
-
timestamp: string;
|
|
100
|
-
seal?: unknown;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* The bytes a gate approval's seal signs (#3163): the op (or component), the
|
|
105
|
-
* gate, the environment, the plan digest, the approver and the time, joined
|
|
106
|
-
* by LF with no final newline. An approval that binds no environment or plan
|
|
107
|
-
* signs an empty line for it. A relayed approval (#3402) signs a seventh line,
|
|
108
|
-
* `relayed-by <principal>`, so the relay can't be added, changed or removed
|
|
109
|
-
* after sealing; an approval nobody relayed signs the six lines it always did,
|
|
110
|
-
* so seals made before #3402 still verify.
|
|
111
|
-
*/
|
|
112
|
-
export function gateSealPayload(a: SealedGateApproval): Buffer {
|
|
113
|
-
const relay = a.relayedBy !== undefined ? `\nrelayed-by ${a.relayedBy}` : "";
|
|
114
|
-
return Buffer.from(`${a.op}\n${a.gate}\n${a.environment ?? ""}\n${a.planDigest ?? ""}\n${a.resolvedBy}\n${a.timestamp}${relay}`, "utf-8");
|
|
115
|
-
}
|
|
116
|
-
|
|
117
88
|
/** The record an author seal covers (#2688). */
|
|
118
89
|
export interface SealedRecord {
|
|
119
90
|
record: string | null;
|
|
@@ -239,14 +210,28 @@ function checkSeal(policy: TrustPolicy, s: SealSubject): SealCheck {
|
|
|
239
210
|
if (listed.length === 0) {
|
|
240
211
|
return { attested: false, code: "seal-signer-unlisted", message: `${s.principal} has no key in ${where}, so the seal can't count` };
|
|
241
212
|
}
|
|
242
|
-
const r = verifySshSignature(listed, s.payload, seal.signature, s.namespace);
|
|
213
|
+
const r = s.namespace === GATE_SEAL_NAMESPACE ? verifyGate(listed, s.payload, seal.signature) : verifySshSignature(listed, s.payload, seal.signature, s.namespace);
|
|
243
214
|
if (r.ok) return { attested: true, message: `sealed by ${r.principal}${r.key ? ` with ${r.key}` : ""}, a signer ${where} lists`, ...(r.key ? { key: r.key } : {}) };
|
|
244
215
|
if (r.missing) return { attested: null, code: "seal-unverifiable", message: `the seal by ${s.principal} can't be checked here: ${r.reason}` };
|
|
245
216
|
return { attested: false, code: "seal-signature-invalid", message: `the seal does not verify for ${s.principal} against ${where}: ${r.reason}` };
|
|
246
217
|
}
|
|
247
218
|
|
|
219
|
+
/** A gate seal checked with node:crypto (#3785) against the keys `listed` for the approver in the gate namespace. */
|
|
220
|
+
function verifyGate(listed: Signer[], payload: Buffer, signature: string): { ok: true; principal: string; key: string; missing?: undefined } | { ok: false; missing?: false; reason: string } {
|
|
221
|
+
const keys = listed.filter((x) => namespaceAllowed(x.namespaces, GATE_SEAL_NAMESPACE)).map((x) => Buffer.from(x.key.split(" ")[1] ?? "", "base64"));
|
|
222
|
+
const r = verifySshSignatureWith(signature, payload, GATE_SEAL_NAMESPACE, keys);
|
|
223
|
+
return r.ok ? { ok: true, principal: listed[0]!.principal, key: r.key } : { ok: false, reason: r.reason };
|
|
224
|
+
}
|
|
225
|
+
|
|
248
226
|
/** A seal checked with no signers file: is the signature over these bytes intact? */
|
|
249
227
|
function checkIntegrity(principal: string, payload: Buffer, signature: string, namespace: string): SealCheck {
|
|
228
|
+
if (namespace === GATE_SEAL_NAMESPACE) {
|
|
229
|
+
// A gate seal is checked with node:crypto (#3785), against the key it carries.
|
|
230
|
+
const pk = sshSignatureKey(signature);
|
|
231
|
+
const r = pk ? verifySshSignatureWith(signature, payload, namespace, [pk]) : ({ ok: false, reason: "it is not an SSHSIG" } as const);
|
|
232
|
+
if (!r.ok) return { attested: false, code: "seal-signature-invalid", message: `the seal by ${principal} does not verify over this approval, even without a signers file` };
|
|
233
|
+
return { attested: null, code: "seal-unverifiable", message: `the signature is intact (${r.key}), and there is no signers file at base to say whose key it is`, key: r.key };
|
|
234
|
+
}
|
|
250
235
|
const dir = mkdtempSync(join(tmpdir(), "chant-seal-"));
|
|
251
236
|
try {
|
|
252
237
|
const sigFile = join(dir, "signature");
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3763: the zod-free check of `.chant/trust.json` accepts exactly what the
|
|
3
|
+
* trust policy's zod schema accepts, so a bundled reader turns attestation
|
|
4
|
+
* off for the same files chant does.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "vitest";
|
|
8
|
+
import { trustConfigSchema } from "./policy";
|
|
9
|
+
import { trustConfigProblems } from "./trust-files";
|
|
10
|
+
|
|
11
|
+
const commit = "a".repeat(40);
|
|
12
|
+
const ed = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOv1";
|
|
13
|
+
|
|
14
|
+
const cases: Array<[string, unknown]> = [
|
|
15
|
+
["the smallest", { schema: 1 }],
|
|
16
|
+
["everything", {
|
|
17
|
+
schema: 1,
|
|
18
|
+
signers: "ops/allowed_signers",
|
|
19
|
+
roles: { admin: ["alice"], "release-1": ["bob", "carol"] },
|
|
20
|
+
adopted: [{ to: commit }, { from: commit, to: "b".repeat(64), note: "import" }],
|
|
21
|
+
runners: [{ principal: "ci", class: "runner", key: ed }],
|
|
22
|
+
admitted: [{ return: "ret-0123456789ab", signers: [{ principal: "dana", key: "ssh-rsa AAAAB3Nza==" }], note: "n" }],
|
|
23
|
+
}],
|
|
24
|
+
["schema 2", { schema: 2 }],
|
|
25
|
+
["no schema", {}],
|
|
26
|
+
["not an object", [1]],
|
|
27
|
+
["null", null],
|
|
28
|
+
["an unknown key", { schema: 1, extra: true }],
|
|
29
|
+
["an absolute signers path", { schema: 1, signers: "/etc/signers" }],
|
|
30
|
+
["a signers path with ..", { schema: 1, signers: "a/../b" }],
|
|
31
|
+
["a signers path that is not normal", { schema: 1, signers: "./a" }],
|
|
32
|
+
["a signers path with a backslash", { schema: 1, signers: "a\\b" }],
|
|
33
|
+
["an empty signers path", { schema: 1, signers: "" }],
|
|
34
|
+
["a role name with capitals", { schema: 1, roles: { Admin: ["a"] } }],
|
|
35
|
+
["a role with an empty principal", { schema: 1, roles: { admin: [""] } }],
|
|
36
|
+
["roles as a list", { schema: 1, roles: [] }],
|
|
37
|
+
["a short adopted commit", { schema: 1, adopted: [{ to: "abc" }] }],
|
|
38
|
+
["an adopted range with an extra key", { schema: 1, adopted: [{ to: commit, by: "x" }] }],
|
|
39
|
+
["a runner with an rsa key", { schema: 1, runners: [{ principal: "ci", class: "runner", key: "ssh-rsa AAAA" }] }],
|
|
40
|
+
["a runner of another class", { schema: 1, runners: [{ principal: "ci", class: "human", key: ed }] }],
|
|
41
|
+
["an admission with no signers", { schema: 1, admitted: [{ return: "ret-0123456789ab", signers: [] }] }],
|
|
42
|
+
["an admission with a bad id", { schema: 1, admitted: [{ return: "ret-1", signers: [{ principal: "d", key: ed }] }] }],
|
|
43
|
+
["an admitted key with a comment", { schema: 1, admitted: [{ return: "ret-0123456789ab", signers: [{ principal: "d", key: `${ed} dana@laptop` }] }] }],
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
describe("trustConfigProblems", () => {
|
|
47
|
+
test.each(cases)("agrees with the zod schema: %s", (_name, raw) => {
|
|
48
|
+
expect(trustConfigProblems(raw).length === 0).toBe(trustConfigSchema.safeParse(raw).success);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The trust files' plain readers (#3763): the signers file's parser, and a
|
|
3
|
+
* check of `.chant/trust.json` that says what `./policy.ts`'s zod schema
|
|
4
|
+
* says, so a bundle can read both without zod. `./policy.ts` re-exports the
|
|
5
|
+
* parser; `trust-files.test.ts` holds the check to the schema.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { posix } from "node:path";
|
|
9
|
+
|
|
10
|
+
/** The trust config, relative to the repository root. Fixed: it is how the signers path is overridden. */
|
|
11
|
+
export const TRUST_CONFIG_PATH = ".chant/trust.json";
|
|
12
|
+
|
|
13
|
+
/** Where the signers are when `trust.json` names no other path. */
|
|
14
|
+
export const DEFAULT_SIGNERS_PATH = ".chant/allowed_signers";
|
|
15
|
+
|
|
16
|
+
// ── allowed_signers ──────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/** Key types ssh-keygen accepts in an allowed_signers line. */
|
|
19
|
+
const KEY_TYPES = new Set([
|
|
20
|
+
"ssh-ed25519",
|
|
21
|
+
"ssh-rsa",
|
|
22
|
+
"ecdsa-sha2-nistp256",
|
|
23
|
+
"ecdsa-sha2-nistp384",
|
|
24
|
+
"ecdsa-sha2-nistp521",
|
|
25
|
+
"sk-ssh-ed25519@openssh.com",
|
|
26
|
+
"sk-ecdsa-sha2-nistp256@openssh.com",
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
/** One usable signer: a principal and the public key it signs with. */
|
|
30
|
+
export interface Signer {
|
|
31
|
+
principal: string;
|
|
32
|
+
/** `<type> <base64>`, with no comment. */
|
|
33
|
+
key: string;
|
|
34
|
+
/** The `namespaces="..."` option as written, or undefined when the key may sign any namespace. */
|
|
35
|
+
namespaces?: string;
|
|
36
|
+
/** 1-based line in the signers file. */
|
|
37
|
+
line: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A line of the signers file that is not used, and why. */
|
|
41
|
+
export interface ExcludedSigner {
|
|
42
|
+
line: number;
|
|
43
|
+
reason: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SignerSet {
|
|
47
|
+
signers: Signer[];
|
|
48
|
+
excluded: ExcludedSigner[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Split one line into fields. Double quotes group, so an option such as
|
|
53
|
+
* `namespaces="git,file"` stays one field.
|
|
54
|
+
*/
|
|
55
|
+
function fields(line: string): string[] {
|
|
56
|
+
const out: string[] = [];
|
|
57
|
+
let cur = "";
|
|
58
|
+
let quoted = false;
|
|
59
|
+
let any = false;
|
|
60
|
+
for (const ch of line) {
|
|
61
|
+
if (ch === '"') {
|
|
62
|
+
quoted = !quoted;
|
|
63
|
+
cur += ch;
|
|
64
|
+
any = true;
|
|
65
|
+
} else if (!quoted && (ch === " " || ch === "\t")) {
|
|
66
|
+
if (any) out.push(cur);
|
|
67
|
+
cur = "";
|
|
68
|
+
any = false;
|
|
69
|
+
} else {
|
|
70
|
+
cur += ch;
|
|
71
|
+
any = true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (any) out.push(cur);
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** `a="x,y",b` as `{ a: "x,y", b: "" }`. */
|
|
79
|
+
function options(field: string): Map<string, string> {
|
|
80
|
+
const opts = new Map<string, string>();
|
|
81
|
+
let cur = "";
|
|
82
|
+
let quoted = false;
|
|
83
|
+
const flush = () => {
|
|
84
|
+
if (!cur) return;
|
|
85
|
+
const eq = cur.indexOf("=");
|
|
86
|
+
const name = (eq < 0 ? cur : cur.slice(0, eq)).toLowerCase();
|
|
87
|
+
const value = eq < 0 ? "" : cur.slice(eq + 1).replace(/^"|"$/g, "");
|
|
88
|
+
opts.set(name, value);
|
|
89
|
+
cur = "";
|
|
90
|
+
};
|
|
91
|
+
for (const ch of field) {
|
|
92
|
+
if (ch === '"') quoted = !quoted;
|
|
93
|
+
if (ch === "," && !quoted) flush();
|
|
94
|
+
else cur += ch;
|
|
95
|
+
}
|
|
96
|
+
flush();
|
|
97
|
+
return opts;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Parse an allowed_signers file, keeping only lines whose meaning does not
|
|
102
|
+
* depend on anything the signer controls.
|
|
103
|
+
*
|
|
104
|
+
* Refused, each with a reason, rather than passed to ssh-keygen:
|
|
105
|
+
*
|
|
106
|
+
* - `valid-after` and `valid-before`. git checks them against the commit's own
|
|
107
|
+
* date, which whoever makes the commit sets. A backdated commit would pass a
|
|
108
|
+
* window its key has left. Revocation is by position in history (#2553),
|
|
109
|
+
* never by date.
|
|
110
|
+
* - `cert-authority`. A certificate's validity is also a date, and a CA key
|
|
111
|
+
* vouches for keys this file never names.
|
|
112
|
+
* - Principal patterns (`*`, `?`, `!`). A signer is named by one exact
|
|
113
|
+
* principal, so role grants can name it.
|
|
114
|
+
*/
|
|
115
|
+
export function parseAllowedSigners(text: string): SignerSet {
|
|
116
|
+
const signers: Signer[] = [];
|
|
117
|
+
const excluded: ExcludedSigner[] = [];
|
|
118
|
+
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
|
119
|
+
for (let i = 0; i < lines.length; i++) {
|
|
120
|
+
const line = i + 1;
|
|
121
|
+
const raw = lines[i].trim();
|
|
122
|
+
if (!raw || raw.startsWith("#")) continue;
|
|
123
|
+
const f = fields(raw);
|
|
124
|
+
if (f.length < 3) {
|
|
125
|
+
excluded.push({ line, reason: "expected principals, optional options, a key type and a key" });
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const [principals, second] = f;
|
|
129
|
+
const hasOptions = !KEY_TYPES.has(second);
|
|
130
|
+
const type = hasOptions ? f[2] : second;
|
|
131
|
+
const blob = hasOptions ? f[3] : f[2];
|
|
132
|
+
if (!KEY_TYPES.has(type) || !blob || !/^[A-Za-z0-9+/]+={0,2}$/.test(blob)) {
|
|
133
|
+
excluded.push({ line, reason: `no supported public key (${type ?? "none"})` });
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const opts = hasOptions ? options(second) : new Map<string, string>();
|
|
137
|
+
const timed = ["valid-after", "valid-before"].filter((o) => opts.has(o));
|
|
138
|
+
if (timed.length > 0) {
|
|
139
|
+
excluded.push({
|
|
140
|
+
line,
|
|
141
|
+
reason: `${timed.join(" and ")} is checked against the commit's own date, which its author sets; revoke a key by removing it`,
|
|
142
|
+
});
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (opts.has("cert-authority")) {
|
|
146
|
+
excluded.push({ line, reason: "cert-authority keys are not supported: a certificate's validity is a date the signer controls" });
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const unknown = [...opts.keys()].filter((o) => o !== "namespaces");
|
|
150
|
+
if (unknown.length > 0) {
|
|
151
|
+
excluded.push({ line, reason: `unknown option ${unknown.join(", ")}` });
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const names = principals.replace(/^"|"$/g, "").split(",").filter(Boolean);
|
|
155
|
+
const pattern = names.find((p) => /[*?!]/.test(p));
|
|
156
|
+
if (pattern !== undefined || names.length === 0) {
|
|
157
|
+
excluded.push({ line, reason: `principal ${JSON.stringify(pattern ?? principals)} is a pattern; name one signer exactly` });
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
for (const principal of names) {
|
|
161
|
+
signers.push({ principal, key: `${type} ${blob}`, namespaces: opts.get("namespaces"), line });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return { signers, excluded };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The signers as an allowed_signers file ssh-keygen reads, holding only the usable lines. */
|
|
168
|
+
export function renderAllowedSigners(signers: Signer[]): string {
|
|
169
|
+
return (
|
|
170
|
+
signers
|
|
171
|
+
.map((s) => `${JSON.stringify(s.principal)}${s.namespaces !== undefined ? ` namespaces=${JSON.stringify(s.namespaces)}` : ""} ${s.key}`)
|
|
172
|
+
.join("\n") + "\n"
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── trust.json, without zod ──────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
179
|
+
const FULL_COMMIT = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
|
|
180
|
+
const SSH_ED25519 = /^ssh-ed25519 [A-Za-z0-9+/]+={0,2}$/;
|
|
181
|
+
const SSH_KEY = /^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp(256|384|521)|sk-ssh-ed25519@openssh\.com|sk-ecdsa-sha2-nistp256@openssh\.com) [A-Za-z0-9+/]+={0,2}$/;
|
|
182
|
+
|
|
183
|
+
/** A repository-relative path: no `..`, not absolute, `/` separators. */
|
|
184
|
+
export function isRepoPath(p: unknown): p is string {
|
|
185
|
+
return typeof p === "string" && p.length > 0 && !p.startsWith("/") && !p.includes("\\") && posix.normalize(p) === p && !p.split("/").includes("..");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Whether `o` holds only `keys`, as zod's `.strict()` requires. */
|
|
189
|
+
const only = (o: Record<string, unknown>, keys: string[]): boolean => Object.keys(o).every((k) => keys.includes(k));
|
|
190
|
+
const optional = (v: unknown, ok: (v: unknown) => boolean): boolean => v === undefined || ok(v);
|
|
191
|
+
const nonEmpty = (v: unknown): boolean => typeof v === "string" && v.length > 0;
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* What is wrong with a parsed `.chant/trust.json`, one line per field, or an
|
|
195
|
+
* empty list when it is valid. It accepts exactly what `trustConfigSchema`
|
|
196
|
+
* in `./policy.ts` accepts, so a reader without zod treats a file as chant
|
|
197
|
+
* does: an invalid one turns attestation off.
|
|
198
|
+
*/
|
|
199
|
+
export function trustConfigProblems(raw: unknown): string[] {
|
|
200
|
+
if (!isObject(raw)) return ["/: expected an object"];
|
|
201
|
+
const problems: string[] = [];
|
|
202
|
+
const check = (field: string, ok: boolean, message: string) => {
|
|
203
|
+
if (!ok) problems.push(`${field}: ${message}`);
|
|
204
|
+
};
|
|
205
|
+
check("/", only(raw, ["schema", "signers", "roles", "adopted", "runners", "admitted"]), "unknown key");
|
|
206
|
+
check("schema", raw.schema === 1, "expected 1");
|
|
207
|
+
check("signers", optional(raw.signers, isRepoPath), "a path inside the repository, with / separators and no ..");
|
|
208
|
+
check(
|
|
209
|
+
"roles",
|
|
210
|
+
optional(raw.roles, (r) => isObject(r) && Object.entries(r).every(([k, v]) => /^[a-z][a-z0-9-]*$/.test(k) && Array.isArray(v) && v.every(nonEmpty))),
|
|
211
|
+
"role names to lists of principals",
|
|
212
|
+
);
|
|
213
|
+
check(
|
|
214
|
+
"adopted",
|
|
215
|
+
optional(raw.adopted, (a) =>
|
|
216
|
+
Array.isArray(a) &&
|
|
217
|
+
a.every((e) => isObject(e) && only(e, ["from", "to", "note"]) && optional(e.from, (f) => typeof f === "string" && FULL_COMMIT.test(f)) && typeof e.to === "string" && FULL_COMMIT.test(e.to) && optional(e.note, (n) => typeof n === "string")),
|
|
218
|
+
),
|
|
219
|
+
"commit ranges of full commit ids",
|
|
220
|
+
);
|
|
221
|
+
check(
|
|
222
|
+
"runners",
|
|
223
|
+
optional(raw.runners, (a) =>
|
|
224
|
+
Array.isArray(a) &&
|
|
225
|
+
a.every((e) => isObject(e) && only(e, ["principal", "class", "key"]) && nonEmpty(e.principal) && (e.class === "runner" || e.class === "service") && typeof e.key === "string" && SSH_ED25519.test(e.key)),
|
|
226
|
+
),
|
|
227
|
+
"runner keys: a principal, a class and an ssh-ed25519 key",
|
|
228
|
+
);
|
|
229
|
+
check(
|
|
230
|
+
"admitted",
|
|
231
|
+
optional(raw.admitted, (a) =>
|
|
232
|
+
Array.isArray(a) &&
|
|
233
|
+
a.every(
|
|
234
|
+
(e) =>
|
|
235
|
+
isObject(e) && only(e, ["return", "signers", "note"]) &&
|
|
236
|
+
typeof e.return === "string" && /^ret-[0-9a-f]{12}$/.test(e.return) &&
|
|
237
|
+
optional(e.note, (n) => typeof n === "string") &&
|
|
238
|
+
Array.isArray(e.signers) && e.signers.length > 0 &&
|
|
239
|
+
e.signers.every((s) => isObject(s) && only(s, ["principal", "key"]) && nonEmpty(s.principal) && typeof s.key === "string" && SSH_KEY.test(s.key)),
|
|
240
|
+
),
|
|
241
|
+
),
|
|
242
|
+
"admitted signers, by return id",
|
|
243
|
+
);
|
|
244
|
+
return problems;
|
|
245
|
+
}
|
|
@@ -39,7 +39,8 @@ import {
|
|
|
39
39
|
releaseWorkLease,
|
|
40
40
|
renewWorkLease,
|
|
41
41
|
workLeaseRef,
|
|
42
|
-
|
|
42
|
+
isWorkItemId,
|
|
43
|
+
workItemIdProblem,
|
|
43
44
|
WORK_LEASE_OUTCOMES,
|
|
44
45
|
type KeptAttempt,
|
|
45
46
|
type WorkLease,
|
|
@@ -203,7 +204,8 @@ export interface WorkLeaseRequest {
|
|
|
203
204
|
export async function workLease(req: WorkLeaseRequest): Promise<WorkLeaseDocument> {
|
|
204
205
|
const doc = { $schema: WORK_LEASE_OUTPUT_SCHEMA_ID, contract: WORK_LEASE_CONTRACT_VERSION };
|
|
205
206
|
try {
|
|
206
|
-
|
|
207
|
+
const idProblem = workItemIdProblem(req.id);
|
|
208
|
+
if (idProblem) throw new WorkError("write-usage-invalid", idProblem);
|
|
207
209
|
if (req.outcome !== undefined && !isWorkLeaseOutcome(req.outcome)) {
|
|
208
210
|
throw new WorkError("write-usage-invalid", `${JSON.stringify(req.outcome)} is not a lease outcome: use one of ${WORK_LEASE_OUTCOMES.join(", ")}`);
|
|
209
211
|
}
|
|
@@ -381,7 +383,7 @@ export function attemptsOf(claims: readonly LeaseClaim[], limit: number | null):
|
|
|
381
383
|
export async function workHistory(req: { id: string; cwd: string; kind?: string }): Promise<WorkHistoryDocument> {
|
|
382
384
|
const head = { $schema: WORK_HISTORY_OUTPUT_SCHEMA_ID, contract: WORK_HISTORY_CONTRACT_VERSION, chant: readerVersion() };
|
|
383
385
|
try {
|
|
384
|
-
if (!
|
|
386
|
+
if (!isWorkItemId(req.id)) throw new WorkError("work-item-unknown", `${JSON.stringify(req.id)} is not a work item id`);
|
|
385
387
|
if (!gitRoot(req.cwd)) throw new WorkError("not-a-git-repository", "a work lease's history is on the chant/lifecycle branch, and this directory is not in a git repository");
|
|
386
388
|
const { loaded, root, record } = await findWorkItem(req.id, req.cwd, req.kind);
|
|
387
389
|
const leaseCwd = dirname(loaded.file);
|
|
@@ -138,6 +138,13 @@ describe("workspace work history (#2785)", () => {
|
|
|
138
138
|
expect(formatWorkHistory(doc)).toContain(`kept: ${kept}`);
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
+
test("an id git rejects as a ref name is refused before any git call (#2815)", async () => {
|
|
142
|
+
for (const id of ["upgrade-.", "x.lock", "a..b"]) {
|
|
143
|
+
const doc = await workLease({ verb: "claim", id, holder: "worker-b", cwd: root });
|
|
144
|
+
expect(doc).toMatchObject({ error: { code: "write-usage-invalid" } });
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
141
148
|
test("a release outside the closed list of outcomes is refused (#3147)", async () => {
|
|
142
149
|
await expect(releaseWorkLease("W-002", "worker-b", { cwd: kindDir, outcome: "skipped" })).rejects.toThrow(/not a work lease outcome/);
|
|
143
150
|
const doc = await workLease({ verb: "release", id: "W-002", holder: "worker-b", cwd: root, outcome: "skipped" });
|