@intentius/chant 0.85.0 → 0.87.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/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +13 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +13 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/workspace/__fixtures__/sessions.d.ts +23 -0
- package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -0
- package/dist/workspace/checks/records.d.ts +1 -0
- package/dist/workspace/checks/records.d.ts.map +1 -1
- package/dist/workspace/checks.d.ts +4 -0
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/composites.d.ts +14 -1
- package/dist/workspace/composites.d.ts.map +1 -1
- package/dist/workspace/conformance/index.d.ts +211 -0
- package/dist/workspace/conformance/index.d.ts.map +1 -0
- package/dist/workspace/conformance/vitest.d.ts +11 -0
- package/dist/workspace/conformance/vitest.d.ts.map +1 -0
- package/dist/workspace/declaration.d.ts +28 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +40 -0
- package/dist/workspace/declared-kinds.d.ts +43 -0
- package/dist/workspace/declared-kinds.d.ts.map +1 -0
- package/dist/workspace/graph-cli.d.ts +11 -0
- package/dist/workspace/graph-cli.d.ts.map +1 -1
- package/dist/workspace/intent-cli.d.ts +2 -1
- package/dist/workspace/intent-cli.d.ts.map +1 -1
- package/dist/workspace/intent-joins.d.ts +45 -8
- package/dist/workspace/intent-joins.d.ts.map +1 -1
- package/dist/workspace/intent.d.ts +71 -7
- package/dist/workspace/intent.d.ts.map +1 -1
- package/dist/workspace/ls.d.ts +31 -1
- package/dist/workspace/ls.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +47 -4
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/record-sessions.d.ts +51 -0
- package/dist/workspace/record-sessions.d.ts.map +1 -0
- package/dist/workspace/record-source.d.ts +2 -0
- package/dist/workspace/record-source.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +71 -4
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-since.d.ts +90 -0
- package/dist/workspace/records-since.d.ts.map +1 -0
- package/dist/workspace/records-write.d.ts +171 -0
- package/dist/workspace/records-write.d.ts.map +1 -0
- package/dist/workspace/records.d.ts +244 -15
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/runtimes.d.ts +60 -0
- package/dist/workspace/runtimes.d.ts.map +1 -0
- package/dist/workspace/status-gates.d.ts +90 -0
- package/dist/workspace/status-gates.d.ts.map +1 -0
- package/dist/workspace/status.d.ts +17 -0
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/trust/seal.d.ts +85 -0
- package/dist/workspace/trust/seal.d.ts.map +1 -0
- package/dist/workspace/trust/ssh-commit.d.ts +7 -0
- package/dist/workspace/trust/ssh-commit.d.ts.map +1 -1
- package/dist/workspace/work.d.ts +56 -0
- package/dist/workspace/work.d.ts.map +1 -0
- package/package.json +19 -1
- package/src/cli/main.ts +55 -3
- package/src/cli/registry.ts +13 -1
- package/src/lifecycle/gate-ledger.ts +14 -0
- package/src/workspace/__fixtures__/sessions.ts +66 -0
- package/src/workspace/checks/records.ts +19 -0
- package/src/workspace/checks.test.ts +2 -0
- package/src/workspace/checks.ts +7 -1
- package/src/workspace/composites.schema.json +65 -3
- package/src/workspace/composites.test.ts +95 -5
- package/src/workspace/composites.ts +28 -7
- package/src/workspace/conformance/__fixture__/app/package.json +7 -0
- package/src/workspace/conformance/__fixture__/app/src/server.mjs +29 -0
- package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +32 -0
- package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +376 -0
- package/src/workspace/conformance/__fixture__/decisions/fix-001-how-the-app-is-deployed.md +40 -0
- package/src/workspace/conformance/__fixture__/delivery/chant.config.ts +7 -0
- package/src/workspace/conformance/__fixture__/delivery/lexicon/index.ts +26 -0
- package/src/workspace/conformance/__fixture__/delivery/package.json +7 -0
- package/src/workspace/conformance/__fixture__/delivery/src/app.component.ts +14 -0
- package/src/workspace/conformance/__fixture__/delivery/src/app.ts +4 -0
- package/src/workspace/conformance/conformance.test.ts +149 -0
- package/src/workspace/conformance/index.mjs +31 -0
- package/src/workspace/conformance/index.ts +453 -0
- package/src/workspace/conformance/vitest.ts +62 -0
- package/src/workspace/declaration.schema.json +40 -0
- package/src/workspace/declaration.ts +62 -0
- package/src/workspace/declared-kinds.test.ts +321 -0
- package/src/workspace/declared-kinds.ts +76 -0
- package/src/workspace/graph-cli.ts +8 -0
- package/src/workspace/intent-cli.ts +29 -6
- package/src/workspace/intent-gaps.test.ts +217 -0
- package/src/workspace/intent-joins.test.ts +60 -0
- package/src/workspace/intent-joins.ts +71 -19
- package/src/workspace/intent.schema.json +304 -7
- package/src/workspace/intent.test.ts +99 -0
- package/src/workspace/intent.ts +365 -46
- package/src/workspace/ls.schema.json +34 -0
- package/src/workspace/ls.ts +69 -4
- package/src/workspace/read-contract.test.ts +30 -9
- package/src/workspace/reason-codes.test.ts +16 -4
- package/src/workspace/reason-codes.ts +55 -4
- package/src/workspace/record-assets.test.ts +3 -1
- package/src/workspace/record-sessions.ts +105 -0
- package/src/workspace/record-source.ts +14 -5
- package/src/workspace/records-amend.schema.json +167 -0
- package/src/workspace/records-cli.ts +308 -19
- package/src/workspace/records-contract.test.ts +57 -2
- package/src/workspace/records-formats.test.ts +640 -0
- package/src/workspace/records-new.schema.json +158 -0
- package/src/workspace/records-quorum.test.ts +196 -0
- package/src/workspace/records-review.schema.json +227 -0
- package/src/workspace/records-sessions.test.ts +108 -0
- package/src/workspace/records-since.schema.json +193 -0
- package/src/workspace/records-since.test.ts +174 -0
- package/src/workspace/records-since.ts +259 -0
- package/src/workspace/records-write-contract.test.ts +125 -0
- package/src/workspace/records-write.test.ts +373 -0
- package/src/workspace/records-write.ts +765 -0
- package/src/workspace/records.schema.json +202 -9
- package/src/workspace/records.ts +700 -41
- package/src/workspace/runtimes.ts +107 -0
- package/src/workspace/status-contract.test.ts +163 -0
- package/src/workspace/status-gates.ts +215 -0
- package/src/workspace/status.schema.json +69 -3
- package/src/workspace/status.ts +35 -2
- package/src/workspace/trust/seal.test.ts +232 -0
- package/src/workspace/trust/seal.ts +195 -0
- package/src/workspace/trust/ssh-commit.ts +2 -2
- package/src/workspace/work.test.ts +390 -0
- package/src/workspace/work.ts +163 -0
package/src/workspace/status.ts
CHANGED
|
@@ -20,6 +20,11 @@
|
|
|
20
20
|
* declaration that can't be read, or an environment name that can't be one,
|
|
21
21
|
* exits 1. The `--json` output is part of the read contract and is described
|
|
22
22
|
* by `status.schema.json` beside this file.
|
|
23
|
+
*
|
|
24
|
+
* The JSON also lists each member's gates, read from its gate ledger on the
|
|
25
|
+
* same branch (#2674, `status-gates.ts`): the state of each, the approvals
|
|
26
|
+
* that count, and the `chant approve` line that answers it. The text view
|
|
27
|
+
* doesn't show them.
|
|
23
28
|
*/
|
|
24
29
|
|
|
25
30
|
import { execFileSync } from "node:child_process";
|
|
@@ -27,11 +32,13 @@ import { existsSync, realpathSync } from "node:fs";
|
|
|
27
32
|
import { relative, resolve } from "node:path";
|
|
28
33
|
import { formatError } from "../cli/format";
|
|
29
34
|
import type { CommandContext } from "../cli/registry";
|
|
35
|
+
import { GATES_DIR } from "../lifecycle/gate-ledger";
|
|
30
36
|
import { readPathSha } from "../lifecycle/git";
|
|
31
37
|
import { latestPerComponent, readReleaseLedger, type ReleaseRecord } from "../lifecycle/release-ledger";
|
|
32
38
|
import { findWorkspaceRoot } from "../project-root";
|
|
33
39
|
import { readDeclaration, readerVersion, WorkspaceReadError, type ErrorLocation, type Member } from "./declaration";
|
|
34
40
|
import type { ReasonCode } from "./reason-codes";
|
|
41
|
+
import { GATE_REASON_CODES, readMemberGates, type GateLedgerReader, type StatusGate, type StatusGateLedger } from "./status-gates";
|
|
35
42
|
import { gitTop, workingTree } from "./tree";
|
|
36
43
|
import { handToRootChant } from "./which-chant";
|
|
37
44
|
|
|
@@ -61,6 +68,9 @@ export const STATUS_REASON_CODES = [
|
|
|
61
68
|
] as const satisfies readonly ReasonCode[];
|
|
62
69
|
export type StatusReasonCode = (typeof STATUS_REASON_CODES)[number];
|
|
63
70
|
|
|
71
|
+
/** Why a member's gates can't be listed (#2674). Closed, like {@link STATUS_REASON_CODES}. */
|
|
72
|
+
export const STATUS_GATE_REASON_CODES = GATE_REASON_CODES;
|
|
73
|
+
|
|
64
74
|
/**
|
|
65
75
|
* Why the status couldn't be read at all. The declaration's own codes, except
|
|
66
76
|
* the two that only `--at` returns, and one for the environment name.
|
|
@@ -129,7 +139,12 @@ export interface StatusMember {
|
|
|
129
139
|
environments: StatusEnvironment[];
|
|
130
140
|
/** Null without `--compare-to`. */
|
|
131
141
|
compare: StatusCompare | null;
|
|
142
|
+
/** True when no environment's release ledger has a reason. Gate reasons don't change it. */
|
|
132
143
|
readable: boolean;
|
|
144
|
+
/** Where the member's gates were read from, and why none are listed when none can be (#2674). */
|
|
145
|
+
gateLedger: StatusGateLedger;
|
|
146
|
+
/** Each gate in the member's gate ledger, one per environment asked for, sorted by component then gate. */
|
|
147
|
+
gates: StatusGate[];
|
|
133
148
|
}
|
|
134
149
|
|
|
135
150
|
export type StatusDocument =
|
|
@@ -160,6 +175,10 @@ export interface StatusQuery {
|
|
|
160
175
|
compareTo?: string;
|
|
161
176
|
/** Reads one ledger; the lifecycle reader unless a test swaps it. */
|
|
162
177
|
readLedger?: LedgerReader;
|
|
178
|
+
/** Reads one member's gate ledger directory; git unless a test swaps it. */
|
|
179
|
+
readGates?: GateLedgerReader;
|
|
180
|
+
/** The instant a gate's expiry is measured against; now by default. */
|
|
181
|
+
now?: string;
|
|
163
182
|
}
|
|
164
183
|
|
|
165
184
|
class StatusError extends Error {
|
|
@@ -192,9 +211,14 @@ function lifecycleTip(cwd: string): string | null {
|
|
|
192
211
|
}
|
|
193
212
|
}
|
|
194
213
|
|
|
214
|
+
/** Whether the member writes under `_members/<member>/` (#2538). The root member never does. */
|
|
215
|
+
async function hasMemberLedger(member: Member, cwd: string): Promise<boolean> {
|
|
216
|
+
return member.dir !== "." && (await readPathSha(MEMBERS_DIR, member.name, { cwd })) !== null;
|
|
217
|
+
}
|
|
218
|
+
|
|
195
219
|
/** Which ledger a member's releases for `env` are in (#2524 D7). */
|
|
196
220
|
async function ledgerFor(member: Member, env: string, cwd: string): Promise<Omit<StatusLedger, "shared">> {
|
|
197
|
-
if (
|
|
221
|
+
if (await hasMemberLedger(member, cwd)) {
|
|
198
222
|
return { layout: "members", path: `${MEMBERS_DIR}/${member.name}/${env}/releases.jsonl` };
|
|
199
223
|
}
|
|
200
224
|
return { layout: "flat", path: `${env}/releases.jsonl` };
|
|
@@ -276,10 +300,15 @@ export async function workspaceStatus(query: StatusQuery): Promise<StatusDocumen
|
|
|
276
300
|
const read = query.readLedger ?? defaultReader;
|
|
277
301
|
const envs = query.compareTo !== undefined && query.compareTo !== query.env ? [query.env, query.compareTo] : [query.env];
|
|
278
302
|
|
|
303
|
+
const commit = lifecycleTip(found.dir);
|
|
304
|
+
const now = query.now ?? new Date().toISOString();
|
|
305
|
+
|
|
279
306
|
const members: StatusMember[] = [];
|
|
280
307
|
for (const m of declaration.members) {
|
|
281
308
|
const environments: StatusEnvironment[] = [];
|
|
282
309
|
for (const env of envs) environments.push(await readEnvironment(m, env, found.dir, read));
|
|
310
|
+
const own = await hasMemberLedger(m, found.dir);
|
|
311
|
+
const gates = await readMemberGates(own ? `${MEMBERS_DIR}/${m.name}/${GATES_DIR}` : GATES_DIR, own ? "members" : "flat", commit, envs, found.dir, now, query.readGates);
|
|
283
312
|
members.push({
|
|
284
313
|
name: m.name,
|
|
285
314
|
dir: m.dir,
|
|
@@ -287,18 +316,22 @@ export async function workspaceStatus(query: StatusQuery): Promise<StatusDocumen
|
|
|
287
316
|
environments,
|
|
288
317
|
compare: query.compareTo === undefined ? null : compareEnvironments(environments[0], environments[environments.length - 1]),
|
|
289
318
|
readable: environments.every((e) => e.reason === null),
|
|
319
|
+
gateLedger: gates.ledger,
|
|
320
|
+
gates: gates.gates,
|
|
290
321
|
});
|
|
291
322
|
}
|
|
292
323
|
// Several members read from one flat ledger see the same records; say so.
|
|
293
324
|
const readers = new Map<string, number>();
|
|
294
325
|
for (const m of members) for (const e of m.environments) if (e.ledger.layout === "flat") readers.set(e.ledger.path, (readers.get(e.ledger.path) ?? 0) + 1);
|
|
295
326
|
for (const m of members) for (const e of m.environments) e.ledger.shared = e.ledger.layout === "flat" && (readers.get(e.ledger.path) ?? 0) > 1;
|
|
327
|
+
const flatGates = members.filter((m) => m.gateLedger.layout === "flat").length;
|
|
328
|
+
for (const m of members) m.gateLedger.shared = m.gateLedger.layout === "flat" && flatGates > 1;
|
|
296
329
|
|
|
297
330
|
return {
|
|
298
331
|
...head,
|
|
299
332
|
env: query.env,
|
|
300
333
|
compareTo: query.compareTo ?? null,
|
|
301
|
-
lifecycle: { ref: LIFECYCLE_REF, commit
|
|
334
|
+
lifecycle: { ref: LIFECYCLE_REF, commit },
|
|
302
335
|
workspace: { name: declaration.name, root: rootDir === "" ? "." : rootDir, file: declaration.file },
|
|
303
336
|
members,
|
|
304
337
|
summary: {
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2687: sealed verdicts. A review counts under a signers file at base only
|
|
3
|
+
* when its reviewer signed it.
|
|
4
|
+
*
|
|
5
|
+
* Each case writes verdicts through `records review` and reads them back
|
|
6
|
+
* through the real `records` query, in a git repository whose base (`main`)
|
|
7
|
+
* holds the signers file, or doesn't. Every document is checked against its
|
|
8
|
+
* published schema.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import Ajv2020 from "ajv/dist/2020";
|
|
14
|
+
import { afterEach, describe, expect, test } from "vitest";
|
|
15
|
+
import { parseArgs } from "../../cli/main";
|
|
16
|
+
import recordsSchema from "../records.schema.json";
|
|
17
|
+
import reviewSchema from "../records-review.schema.json";
|
|
18
|
+
import { queryRecords, type RecordView } from "../records-cli";
|
|
19
|
+
import { amendRecord, reviewRecord, runRecordsWrite, type ReviewDocument } from "../records-write";
|
|
20
|
+
import type { QuorumVerdict } from "../records";
|
|
21
|
+
import { REVIEW_SEAL_NAMESPACE, reviewSealPayload } from "./seal";
|
|
22
|
+
import { hasSshKeygen, TestRepo, type Key } from "./test-repo";
|
|
23
|
+
|
|
24
|
+
const REPO = join(import.meta.dirname, "..", "..", "..", "..", "..");
|
|
25
|
+
const DECISIONS = join(REPO, "docs", "design", "decisions");
|
|
26
|
+
const KIND = "decisions/decision.kind.mjs";
|
|
27
|
+
const FILE = "decisions/ws-003-seal-scope.md";
|
|
28
|
+
|
|
29
|
+
const ajv = new Ajv2020({ strict: true, allErrors: true });
|
|
30
|
+
const validateRecords = ajv.compile(recordsSchema);
|
|
31
|
+
const validateReview = ajv.compile(reviewSchema);
|
|
32
|
+
|
|
33
|
+
const repos: TestRepo[] = [];
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
while (repos.length) repos.pop()!.cleanup();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A repository whose main holds ws-003 (decided by lex00) and, unless
|
|
40
|
+
* `signers` is false, a signers file listing alice and bob. The work happens
|
|
41
|
+
* on a branch, so main stays the base.
|
|
42
|
+
*/
|
|
43
|
+
function workspace(label: string, opts: { signers?: boolean } = {}) {
|
|
44
|
+
const r = new TestRepo(`seal-${label}`);
|
|
45
|
+
repos.push(r);
|
|
46
|
+
const alice = r.key("alice");
|
|
47
|
+
const bob = r.key("bob");
|
|
48
|
+
const mallory = r.key("mallory");
|
|
49
|
+
r.write(KIND, readFileSync(join(DECISIONS, "decision.kind.mjs"), "utf-8"));
|
|
50
|
+
r.write("decisions/decision.schema.json", readFileSync(join(DECISIONS, "decision.schema.json"), "utf-8"));
|
|
51
|
+
r.write(FILE, readFileSync(join(DECISIONS, "ws-003-seal-scope.md"), "utf-8"));
|
|
52
|
+
if (opts.signers !== false) r.write(".chant/allowed_signers", `alice@example.test ${alice.pub}\nbob@example.test ${bob.pub}\n`);
|
|
53
|
+
r.commit("base", alice);
|
|
54
|
+
r.git(["checkout", "-q", "-b", "change"]);
|
|
55
|
+
return { r, alice, bob, mallory };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function review(r: TestRepo, by: string, sign?: Key | string | true, verdict = "agree"): Promise<ReviewDocument> {
|
|
59
|
+
const doc = await reviewRecord({ kind: KIND, id: "ws-003", verdict, by, cwd: r.dir, on: "2026-09-24", ...(sign !== undefined ? { sign: typeof sign === "object" ? sign.file : sign } : {}) });
|
|
60
|
+
expect(validateReview(doc), JSON.stringify(validateReview.errors)).toBe(true);
|
|
61
|
+
return doc;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function record(r: TestRepo): Promise<RecordView> {
|
|
65
|
+
const doc = await queryRecords({ kind: KIND, cwd: r.dir, base: "main" });
|
|
66
|
+
expect(validateRecords(doc), JSON.stringify(validateRecords.errors)).toBe(true);
|
|
67
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
68
|
+
const rec = doc.records.find((x) => x.id === "ws-003")!;
|
|
69
|
+
expect(rec.reasons).toEqual([]);
|
|
70
|
+
return rec;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Every verdict, counted or not, in list order, as `reviewer:reason code:seal code`. */
|
|
74
|
+
function verdicts(rec: RecordView): string[] {
|
|
75
|
+
const q = rec.quorum!;
|
|
76
|
+
return [...q.counted, ...q.notCounted]
|
|
77
|
+
.sort((a, b) => a.index - b.index)
|
|
78
|
+
.map((v: QuorumVerdict) => `${v.reviewer}:${v.reason?.code ?? "counted"}:${v.attestation.code ?? "attested"}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function verdict(rec: RecordView, index: number): QuorumVerdict {
|
|
82
|
+
const q = rec.quorum!;
|
|
83
|
+
return [...q.counted, ...q.notCounted].find((v) => v.index === index)!;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe.skipIf(!hasSshKeygen)("sealed verdicts under a signers file at base", () => {
|
|
87
|
+
test("a sealed agree counts; an unsealed one, one by an unlisted principal and one signed with another's key do not", async () => {
|
|
88
|
+
const { r, alice, bob, mallory } = workspace("active");
|
|
89
|
+
const sealed = await review(r, "alice@example.test", alice);
|
|
90
|
+
if ("error" in sealed) throw new Error(sealed.error.message);
|
|
91
|
+
expect(sealed.review.seal).toMatchObject({ signer: "alice@example.test", key: expect.stringMatching(/^SHA256:/) });
|
|
92
|
+
await review(r, "bob@example.test");
|
|
93
|
+
await review(r, "mallory@example.test", mallory);
|
|
94
|
+
// alice's key, claiming to be bob.
|
|
95
|
+
await review(r, "Bob@Example.test ", alice);
|
|
96
|
+
|
|
97
|
+
const rec = await record(r);
|
|
98
|
+
expect(verdicts(rec)).toEqual([
|
|
99
|
+
"alice@example.test:counted:attested",
|
|
100
|
+
"bob@example.test:review-unattested:seal-missing",
|
|
101
|
+
"mallory@example.test:review-unattested:seal-signer-unlisted",
|
|
102
|
+
"Bob@Example.test :review-unattested:seal-signature-invalid",
|
|
103
|
+
]);
|
|
104
|
+
const a = verdict(rec, 0);
|
|
105
|
+
expect(a.attested).toBe(true);
|
|
106
|
+
expect(a.attestation.key).toBe((sealed.review.seal as { key: string }).key);
|
|
107
|
+
expect(verdict(rec, 1)).toMatchObject({ attested: false, reason: { message: expect.stringMatching(/carries no seal/) } });
|
|
108
|
+
expect(verdict(rec, 2).reason!.message).toMatch(/mallory@example\.test has no key in \.chant\/allowed_signers at base/);
|
|
109
|
+
expect(verdict(rec, 3).reason!.message).toMatch(/does not verify for Bob@Example\.test/);
|
|
110
|
+
expect(rec.quorum).toMatchObject({ agreed: 1, met: false });
|
|
111
|
+
expect(bob).toBeDefined();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("two sealed agrees meet the quorum", async () => {
|
|
115
|
+
const { r, alice, bob } = workspace("met");
|
|
116
|
+
await review(r, "alice@example.test", alice);
|
|
117
|
+
await review(r, "bob@example.test", bob);
|
|
118
|
+
const rec = await record(r);
|
|
119
|
+
expect(rec.quorum).toMatchObject({ agreed: 2, met: true, notCounted: [] });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("an amendment leaves a sealed verdict on the older digest, and rewriting its digest breaks the seal", async () => {
|
|
123
|
+
const { r, alice } = workspace("amend");
|
|
124
|
+
await review(r, "alice@example.test", alice);
|
|
125
|
+
const before = await record(r);
|
|
126
|
+
const amended = await amendRecord({ kind: KIND, id: "ws-003", fields: JSON.stringify({ evidence: [] }), cwd: r.dir });
|
|
127
|
+
expect("error" in amended ? amended.error : null).toBeNull();
|
|
128
|
+
|
|
129
|
+
const after = await record(r);
|
|
130
|
+
expect(after.digest).not.toBe(before.digest);
|
|
131
|
+
// The seal still verifies over the text it judged; the verdict stops counting because the text moved.
|
|
132
|
+
expect(verdicts(after)).toEqual(["alice@example.test:review-older-digest:attested"]);
|
|
133
|
+
|
|
134
|
+
// Bringing the verdict up to date by hand, without a new signature.
|
|
135
|
+
const path = join(r.dir, FILE);
|
|
136
|
+
writeFileSync(path, readFileSync(path, "utf-8").replace(before.digest, after.digest));
|
|
137
|
+
const forged = await record(r);
|
|
138
|
+
expect(forged.digest).toBe(after.digest);
|
|
139
|
+
expect(verdicts(forged)).toEqual(["alice@example.test:review-unattested:seal-signature-invalid"]);
|
|
140
|
+
expect(verdict(forged, 0).attested).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("attack: a seal lifted from another verdict, or made in the commit namespace, does not verify", async () => {
|
|
144
|
+
const { r, alice } = workspace("lift");
|
|
145
|
+
// An abstain, turned into an agree by hand: the seal covers the verdict.
|
|
146
|
+
await review(r, "alice@example.test", alice, "abstain");
|
|
147
|
+
const path = join(r.dir, FILE);
|
|
148
|
+
writeFileSync(path, readFileSync(path, "utf-8").replace('verdict: "abstain"', 'verdict: "agree"'));
|
|
149
|
+
let rec = await record(r);
|
|
150
|
+
expect(verdicts(rec)).toEqual(["alice@example.test:review-unattested:seal-signature-invalid"]);
|
|
151
|
+
|
|
152
|
+
// A good signature by alice over the right bytes, in the git namespace.
|
|
153
|
+
const text = readFileSync(path, "utf-8");
|
|
154
|
+
const payload = reviewSealPayload("ws-003", rec.digest, "agree", "alice@example.test", "2026-09-24");
|
|
155
|
+
const gitSig = r.sshSign(alice, payload, "git");
|
|
156
|
+
const current = /signature: ("[^"]*")/.exec(text)![1];
|
|
157
|
+
writeFileSync(path, text.replace(current, JSON.stringify(gitSig)));
|
|
158
|
+
rec = await record(r);
|
|
159
|
+
expect(verdicts(rec)).toEqual(["alice@example.test:review-unattested:seal-signature-invalid"]);
|
|
160
|
+
|
|
161
|
+
// The same bytes in the review namespace do verify, which shows it was the namespace.
|
|
162
|
+
writeFileSync(path, text.replace(current, JSON.stringify(r.sshSign(alice, payload, REVIEW_SEAL_NAMESPACE))));
|
|
163
|
+
rec = await record(r);
|
|
164
|
+
expect(verdicts(rec)).toEqual(["alice@example.test:counted:attested"]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("--sign with no file uses git's ssh user.signingkey", async () => {
|
|
168
|
+
const { r, bob } = workspace("gitkey");
|
|
169
|
+
r.git(["config", "gpg.format", "ssh"]);
|
|
170
|
+
r.git(["config", "user.signingkey", bob.file]);
|
|
171
|
+
const doc = await review(r, "bob@example.test", true);
|
|
172
|
+
expect("error" in doc ? doc.error : null).toBeNull();
|
|
173
|
+
expect(verdicts(await record(r))).toEqual(["bob@example.test:counted:attested"]);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("a key that can't sign is refused with review-sign-failed, and nothing is written", async () => {
|
|
177
|
+
const { r } = workspace("nokey");
|
|
178
|
+
const before = readFileSync(join(r.dir, FILE), "utf-8");
|
|
179
|
+
const doc = await review(r, "alice@example.test", join(r.dir, "no-such-key"));
|
|
180
|
+
expect("error" in doc && doc.error.code).toBe("review-sign-failed");
|
|
181
|
+
r.git(["config", "gpg.format", "openpgp"]);
|
|
182
|
+
const git = await review(r, "alice@example.test", true);
|
|
183
|
+
expect("error" in git && git.error.message).toMatch(/gpg\.format openpgp/);
|
|
184
|
+
expect(readFileSync(join(r.dir, FILE), "utf-8")).toBe(before);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe.skipIf(!hasSshKeygen)("sealed verdicts with no signers file at base", () => {
|
|
189
|
+
test("an unsealed verdict counts, and a seal is checked for integrity and reported without gating", async () => {
|
|
190
|
+
const { r, alice, bob } = workspace("inactive", { signers: false });
|
|
191
|
+
await review(r, "carol");
|
|
192
|
+
await review(r, "alice@example.test", alice);
|
|
193
|
+
await review(r, "bob@example.test", bob, "abstain");
|
|
194
|
+
const path = join(r.dir, FILE);
|
|
195
|
+
// bob's abstain turned into an agree by hand: its seal no longer covers it.
|
|
196
|
+
writeFileSync(path, readFileSync(path, "utf-8").replace('verdict: "abstain"', 'verdict: "agree"'));
|
|
197
|
+
const rec = await record(r);
|
|
198
|
+
expect(verdicts(rec)).toEqual([
|
|
199
|
+
"carol:counted:seal-missing",
|
|
200
|
+
"alice@example.test:counted:seal-unverifiable",
|
|
201
|
+
"bob@example.test:counted:seal-signature-invalid",
|
|
202
|
+
]);
|
|
203
|
+
expect(verdict(rec, 0).attested).toBeNull();
|
|
204
|
+
expect(verdict(rec, 1)).toMatchObject({ attested: null, attestation: { key: expect.stringMatching(/^SHA256:/), message: expect.stringMatching(/intact/) } });
|
|
205
|
+
expect(verdict(rec, 2).attested).toBe(false);
|
|
206
|
+
expect(rec.quorum).toMatchObject({ agreed: 3, met: true });
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe("--sign on the command line", () => {
|
|
211
|
+
test("takes a key file, or nothing for git's key", () => {
|
|
212
|
+
expect(parseArgs(["workspace", "records", "review", "ws-003", "--sign", "~/.ssh/id_ed25519", "--by", "a"]).sign).toBe("~/.ssh/id_ed25519");
|
|
213
|
+
expect(parseArgs(["workspace", "records", "review", "ws-003", "--sign", "--by", "a"]).sign).toBe(true);
|
|
214
|
+
expect(parseArgs(["workspace", "records", "review", "ws-003", "--by", "a", "--sign"]).sign).toBe(true);
|
|
215
|
+
expect(parseArgs(["workspace", "records", "review", "ws-003", "--by", "a"]).sign).toBeUndefined();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("records new and amend refuse it: author seals are #2688", async () => {
|
|
219
|
+
const lines: string[] = [];
|
|
220
|
+
const log = console.log;
|
|
221
|
+
console.log = (s: string) => lines.push(s);
|
|
222
|
+
try {
|
|
223
|
+
for (const verb of ["new", "amend"]) {
|
|
224
|
+
const code = await runRecordsWrite({ args: { ...parseArgs(["workspace", "records", verb, "x", "--sign"]), extraPositional: verb, extraPositional2: "x" } } as never);
|
|
225
|
+
expect(code).toBe(1);
|
|
226
|
+
}
|
|
227
|
+
} finally {
|
|
228
|
+
console.log = log;
|
|
229
|
+
}
|
|
230
|
+
for (const l of lines) expect(JSON.parse(l).error).toMatchObject({ code: "write-usage-invalid", message: expect.stringMatching(/#2688/) });
|
|
231
|
+
});
|
|
232
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sealed verdicts (#2687, part of #2547): a review entry signed by the
|
|
3
|
+
* principal it names, so the quorum counts it only when that principal
|
|
4
|
+
* signed it.
|
|
5
|
+
*
|
|
6
|
+
* A seal is a detached ssh signature, the same mechanism as the ssh-commit
|
|
7
|
+
* attestor (./ssh-commit.ts): `ssh-keygen -Y sign` to make it, and
|
|
8
|
+
* `ssh-keygen -Y verify` against the signers read at base to check it. It is
|
|
9
|
+
* made in its own namespace, `chant-review`, so neither a commit signature
|
|
10
|
+
* nor a signer-set signature can stand in for one.
|
|
11
|
+
*
|
|
12
|
+
* The signed bytes are the record id, the verdict's digest, the verdict, the
|
|
13
|
+
* reviewer and the date, each on its own line with no final newline. The
|
|
14
|
+
* digest is the record's text digest (`recordTextDigest`), so the seal binds
|
|
15
|
+
* the verdict to the text it judged. The seal lives inside the reviews block,
|
|
16
|
+
* which the digest leaves out, so sealing a verdict never moves the digest.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { execFileSync } from "node:child_process";
|
|
20
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { homedir, tmpdir } from "node:os";
|
|
22
|
+
import { join, resolve } from "node:path";
|
|
23
|
+
import type { SealCode } from "../records";
|
|
24
|
+
import { type TrustPolicy } from "./policy";
|
|
25
|
+
import { sshKeygen, verifySshSignature } from "./ssh-commit";
|
|
26
|
+
|
|
27
|
+
/** The ssh signature namespace of a verdict seal. */
|
|
28
|
+
export const REVIEW_SEAL_NAMESPACE = "chant-review";
|
|
29
|
+
|
|
30
|
+
/** A seal as a review entry holds it. */
|
|
31
|
+
export interface VerdictSeal {
|
|
32
|
+
/** The principal who signed: the reviewer, as the signers file names them. */
|
|
33
|
+
signer: string;
|
|
34
|
+
/** The signing key's fingerprint, such as `SHA256:...`. Reported, never trusted: the signature is the proof. */
|
|
35
|
+
key: string;
|
|
36
|
+
/** The armored ssh signature. */
|
|
37
|
+
signature: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** What a verdict's seal establishes. */
|
|
41
|
+
export interface SealCheck {
|
|
42
|
+
/** true: the seal verifies for the reviewer against the signers at base. false: it is missing under an active policy, or it fails. null: nothing here can say. */
|
|
43
|
+
attested: boolean | null;
|
|
44
|
+
/** Why it is not attested. Absent when `attested` is true. */
|
|
45
|
+
code?: SealCode;
|
|
46
|
+
message: string;
|
|
47
|
+
/** The fingerprint of the key that made the signature, when the signature was checked. */
|
|
48
|
+
key?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The verdict a seal covers. */
|
|
52
|
+
export interface SealedVerdict {
|
|
53
|
+
record: string | null;
|
|
54
|
+
reviewer: string;
|
|
55
|
+
verdict: string;
|
|
56
|
+
on: unknown;
|
|
57
|
+
digest: string | null;
|
|
58
|
+
seal: unknown;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The bytes a verdict seal signs. */
|
|
62
|
+
export function reviewSealPayload(record: string, digest: string, verdict: string, reviewer: string, on: string): Buffer {
|
|
63
|
+
return Buffer.from(`${record}\n${digest}\n${verdict}\n${reviewer}\n${on}`, "utf-8");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const normalise = (name: string): string => name.normalize("NFKC").trim().toLowerCase();
|
|
67
|
+
|
|
68
|
+
const FINGERPRINT = /key (SHA256:[A-Za-z0-9+/=]+)/;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Check a verdict's seal. With a signers file active at base, it verifies
|
|
72
|
+
* against the keys listed there for the reviewer. With none, a seal present
|
|
73
|
+
* is checked for integrity only (`ssh-keygen -Y check-novalidate`): nothing
|
|
74
|
+
* says whose key it is.
|
|
75
|
+
*/
|
|
76
|
+
export function checkVerdictSeal(policy: TrustPolicy, v: SealedVerdict): SealCheck {
|
|
77
|
+
const where = `${policy.signersPath} at base`;
|
|
78
|
+
if (v.seal === undefined || v.seal === null) {
|
|
79
|
+
return policy.active
|
|
80
|
+
? { attested: false, code: "seal-missing", message: `the verdict by ${v.reviewer} carries no seal` }
|
|
81
|
+
: { attested: null, code: "seal-missing", message: `the verdict by ${v.reviewer} carries no seal; there is no signers file at base, so none is needed` };
|
|
82
|
+
}
|
|
83
|
+
const seal = v.seal as Partial<Record<keyof VerdictSeal, unknown>>;
|
|
84
|
+
if (typeof seal !== "object" || Array.isArray(seal) || typeof seal.signer !== "string" || typeof seal.signature !== "string") {
|
|
85
|
+
return { attested: false, code: "seal-signature-invalid", message: `the seal on the verdict by ${v.reviewer} is malformed: it needs signer and signature` };
|
|
86
|
+
}
|
|
87
|
+
if (normalise(seal.signer) !== normalise(v.reviewer)) {
|
|
88
|
+
return { attested: false, code: "seal-signature-invalid", message: `the seal is by ${seal.signer}, and the verdict is ${v.reviewer}'s` };
|
|
89
|
+
}
|
|
90
|
+
if (v.record === null || v.digest === null || typeof v.on !== "string") {
|
|
91
|
+
return { attested: false, code: "seal-signature-invalid", message: `a seal covers the record id, the verdict's digest and on, and the verdict by ${v.reviewer} lacks one` };
|
|
92
|
+
}
|
|
93
|
+
const payload = reviewSealPayload(v.record, v.digest, v.verdict, v.reviewer, v.on);
|
|
94
|
+
if (!policy.active) return checkIntegrity(v.reviewer, payload, seal.signature);
|
|
95
|
+
const listed = policy.signers.filter((s) => normalise(s.principal) === normalise(v.reviewer));
|
|
96
|
+
if (listed.length === 0) {
|
|
97
|
+
return { attested: false, code: "seal-signer-unlisted", message: `${v.reviewer} has no key in ${where}, so the seal can't count` };
|
|
98
|
+
}
|
|
99
|
+
const r = verifySshSignature(listed, payload, seal.signature, REVIEW_SEAL_NAMESPACE);
|
|
100
|
+
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 } : {}) };
|
|
101
|
+
if (r.missing) return { attested: null, code: "seal-unverifiable", message: `the seal by ${v.reviewer} can't be checked here: ${r.reason}` };
|
|
102
|
+
return { attested: false, code: "seal-signature-invalid", message: `the seal does not verify for ${v.reviewer} against ${where}: ${r.reason}` };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A seal checked with no signers file: is the signature over these bytes intact? */
|
|
106
|
+
function checkIntegrity(reviewer: string, payload: Buffer, signature: string): SealCheck {
|
|
107
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-seal-"));
|
|
108
|
+
try {
|
|
109
|
+
const sigFile = join(dir, "signature");
|
|
110
|
+
writeFileSync(sigFile, signature);
|
|
111
|
+
const r = sshKeygen(["-Y", "check-novalidate", "-n", REVIEW_SEAL_NAMESPACE, "-s", sigFile], payload);
|
|
112
|
+
if (r.missing) return { attested: null, code: "seal-unverifiable", message: `the seal by ${reviewer} can't be checked here: ssh-keygen is not installed` };
|
|
113
|
+
if (r.status !== 0) {
|
|
114
|
+
return { attested: false, code: "seal-signature-invalid", message: `the seal by ${reviewer} does not verify over this verdict, even without a signers file` };
|
|
115
|
+
}
|
|
116
|
+
const key = FINGERPRINT.exec(r.stdout + r.stderr)?.[1];
|
|
117
|
+
return {
|
|
118
|
+
attested: null,
|
|
119
|
+
code: "seal-unverifiable",
|
|
120
|
+
message: `the signature is intact${key ? ` (${key})` : ""}, and there is no signers file at base to say whose key it is`,
|
|
121
|
+
...(key ? { key } : {}),
|
|
122
|
+
};
|
|
123
|
+
} finally {
|
|
124
|
+
rmSync(dir, { recursive: true, force: true });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Why a seal could not be made. */
|
|
129
|
+
export class SealError extends Error {
|
|
130
|
+
constructor(message: string) {
|
|
131
|
+
super(message);
|
|
132
|
+
this.name = "SealError";
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The key `--sign` names: the file given, or with none, git's
|
|
138
|
+
* `user.signingkey` when `gpg.format` is `ssh`, as `git commit -S` reads it.
|
|
139
|
+
* A literal public key (`key::ssh-...`, or `ssh-...`) signs through the ssh
|
|
140
|
+
* agent holding its private half. Returns the file to pass to ssh-keygen,
|
|
141
|
+
* and a cleanup for a temporary one.
|
|
142
|
+
*/
|
|
143
|
+
export function resolveSigningKey(sign: string | true, cwd: string): { file: string; cleanup: () => void } {
|
|
144
|
+
const none = { cleanup: () => {} };
|
|
145
|
+
if (sign !== true) return { file: resolve(cwd, expandHome(sign)), ...none };
|
|
146
|
+
const config = (key: string): string | undefined => {
|
|
147
|
+
try {
|
|
148
|
+
return execFileSync("git", ["config", "--get", key], { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim() || undefined;
|
|
149
|
+
} catch {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
const format = config("gpg.format");
|
|
154
|
+
const key = config("user.signingkey");
|
|
155
|
+
if (format !== "ssh" || key === undefined) {
|
|
156
|
+
throw new SealError(
|
|
157
|
+
`--sign with no key file uses git's user.signingkey when gpg.format is ssh, and git has ${format === "ssh" ? "no user.signingkey" : `gpg.format ${format ?? "unset"}`}; pass --sign <key file>`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
const literal = key.startsWith("key::") ? key.slice(5) : key.startsWith("ssh-") || key.startsWith("ecdsa-") || key.startsWith("sk-") ? key : undefined;
|
|
161
|
+
if (literal === undefined) return { file: resolve(cwd, expandHome(key)), ...none };
|
|
162
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-sign-"));
|
|
163
|
+
const file = join(dir, "key.pub");
|
|
164
|
+
writeFileSync(file, `${literal}\n`);
|
|
165
|
+
return { file, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function expandHome(path: string): string {
|
|
169
|
+
return path === "~" ? homedir() : path.startsWith("~/") ? join(homedir(), path.slice(2)) : path;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Seal a verdict with the key in `keyFile` (a private key, or a public key
|
|
174
|
+
* whose private half the ssh agent holds). Throws a {@link SealError}.
|
|
175
|
+
*/
|
|
176
|
+
export function sealVerdict(keyFile: string, v: { record: string; digest: string; verdict: string; reviewer: string; on: string }): VerdictSeal {
|
|
177
|
+
const payload = reviewSealPayload(v.record, v.digest, v.verdict, v.reviewer, v.on);
|
|
178
|
+
const signed = sshKeygen(["-q", "-Y", "sign", "-n", REVIEW_SEAL_NAMESPACE, "-f", keyFile], payload);
|
|
179
|
+
if (signed.missing) throw new SealError("--sign needs ssh-keygen, and it is not installed here");
|
|
180
|
+
if (signed.status !== 0 || !signed.stdout.startsWith("-----BEGIN SSH SIGNATURE-----")) {
|
|
181
|
+
throw new SealError(`ssh-keygen could not sign with ${keyFile}: ${signed.stderr.trim() || `exit ${signed.status}`}`);
|
|
182
|
+
}
|
|
183
|
+
// The fingerprint, read back from the signature itself.
|
|
184
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-seal-"));
|
|
185
|
+
try {
|
|
186
|
+
const sigFile = join(dir, "signature");
|
|
187
|
+
writeFileSync(sigFile, signed.stdout);
|
|
188
|
+
const check = sshKeygen(["-Y", "check-novalidate", "-n", REVIEW_SEAL_NAMESPACE, "-s", sigFile], payload);
|
|
189
|
+
const key = FINGERPRINT.exec(check.stdout + check.stderr)?.[1];
|
|
190
|
+
if (check.status !== 0 || !key) throw new SealError(`the signature ssh-keygen made with ${keyFile} does not check: ${check.stderr.trim()}`);
|
|
191
|
+
return { signer: v.reviewer, key, signature: signed.stdout };
|
|
192
|
+
} finally {
|
|
193
|
+
rmSync(dir, { recursive: true, force: true });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -65,8 +65,8 @@ function objectFormat(repo: string): string {
|
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
/** Run ssh-keygen; `missing` is true when it is not installed. */
|
|
69
|
-
function sshKeygen(args: string[], input?: Buffer): { status: number | null; stdout: string; stderr: string; missing: boolean } {
|
|
68
|
+
/** Run ssh-keygen; `missing` is true when it is not installed. Shared with the verdict seals (./seal.ts). */
|
|
69
|
+
export function sshKeygen(args: string[], input?: Buffer): { status: number | null; stdout: string; stderr: string; missing: boolean } {
|
|
70
70
|
const r = spawnSync("ssh-keygen", args, { input, encoding: "buffer", timeout: 30_000 });
|
|
71
71
|
const missing = (r.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
|
|
72
72
|
return { status: r.status, stdout: r.stdout?.toString("utf-8") ?? "", stderr: r.stderr?.toString("utf-8") ?? "", missing };
|