@intentius/chant 0.86.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.
Files changed (40) hide show
  1. package/dist/cli/main.d.ts.map +1 -1
  2. package/dist/cli/registry.d.ts +2 -0
  3. package/dist/cli/registry.d.ts.map +1 -1
  4. package/dist/workspace/intent.d.ts +6 -1
  5. package/dist/workspace/intent.d.ts.map +1 -1
  6. package/dist/workspace/reason-codes.d.ts +9 -2
  7. package/dist/workspace/reason-codes.d.ts.map +1 -1
  8. package/dist/workspace/records-cli.d.ts +6 -0
  9. package/dist/workspace/records-cli.d.ts.map +1 -1
  10. package/dist/workspace/records-write.d.ts +9 -2
  11. package/dist/workspace/records-write.d.ts.map +1 -1
  12. package/dist/workspace/records.d.ts +47 -5
  13. package/dist/workspace/records.d.ts.map +1 -1
  14. package/dist/workspace/trust/seal.d.ts +85 -0
  15. package/dist/workspace/trust/seal.d.ts.map +1 -0
  16. package/dist/workspace/trust/ssh-commit.d.ts +7 -0
  17. package/dist/workspace/trust/ssh-commit.d.ts.map +1 -1
  18. package/dist/workspace/work.d.ts +3 -3
  19. package/package.json +1 -1
  20. package/src/cli/main.ts +10 -3
  21. package/src/cli/registry.ts +2 -0
  22. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +12 -0
  23. package/src/workspace/intent-gaps.test.ts +217 -0
  24. package/src/workspace/intent.schema.json +23 -1
  25. package/src/workspace/intent.test.ts +2 -0
  26. package/src/workspace/intent.ts +35 -3
  27. package/src/workspace/reason-codes.test.ts +2 -1
  28. package/src/workspace/reason-codes.ts +10 -2
  29. package/src/workspace/record-assets.test.ts +2 -2
  30. package/src/workspace/records-cli.ts +70 -8
  31. package/src/workspace/records-contract.test.ts +2 -2
  32. package/src/workspace/records-review.schema.json +26 -1
  33. package/src/workspace/records-write.ts +32 -3
  34. package/src/workspace/records.schema.json +16 -1
  35. package/src/workspace/records.ts +78 -9
  36. package/src/workspace/trust/seal.test.ts +232 -0
  37. package/src/workspace/trust/seal.ts +195 -0
  38. package/src/workspace/trust/ssh-commit.ts +2 -2
  39. package/src/workspace/work.test.ts +3 -1
  40. package/src/workspace/work.ts +4 -4
@@ -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 };
@@ -146,7 +146,9 @@ describe("work records on read (#2683)", () => {
146
146
  const by = Object.fromEntries(doc.records.map((r) => [r.id, r]));
147
147
  expect(by["W-001"]).toMatchObject({ ready: false, blockedBy: [], implements: [{ id: "dec-001", state: "decided" }], warnings: [] });
148
148
  expect(by["W-002"]).toMatchObject({ ready: false, blockedBy: [{ id: "W-001", state: "in-progress" }], implements: [], warnings: [] });
149
- expect(by["W-003"]).toMatchObject({ ready: false, blockedBy: [], warnings: [] });
149
+ // W-003 is done, and intent-commit-undecided, its gap, still fires on app/server.mjs: c0 changed it before any decision (#2686).
150
+ expect(by["W-003"]).toMatchObject({ ready: false, blockedBy: [] });
151
+ expect(by["W-003"].warnings.map((w: { code: string }) => w.code)).toEqual(["work-done-gap-open"]);
150
152
  expect(doc.decisions).toEqual([
151
153
  { id: "dec-001", path: "decisions/dec-001-server.md", state: "decided", supersededBy: null, implementedBy: [{ id: "W-001", state: "in-progress" }] },
152
154
  { id: "dec-002", path: "decisions/dec-002-other.md", state: "decided", supersededBy: null, implementedBy: [{ id: "W-004", state: "dropped" }] },
@@ -17,9 +17,9 @@
17
17
  * - `implements`: each decision it names, with that decision's state;
18
18
  *
19
19
  * and each decision `implementedBy`, the work records naming it. The
20
- * warnings are closed codes. `work-done-gap-open` is not raised here: only
21
- * `graph --intent` walks a region, so only it can tell whether the finding a
22
- * done item came from still fires. It never writes a record.
20
+ * warnings are closed codes. `work-done-gap-open` is not raised here: it
21
+ * takes a walk of the item's region, which `graph --intent` makes and
22
+ * `records` asks it for (#2686). It never writes a record.
23
23
  */
24
24
 
25
25
  import { dirname } from "node:path";
@@ -40,7 +40,7 @@ export const WORK_WARNING_CODES = [
40
40
  "work-done-unpinned",
41
41
  /** The record is in a closed state and has no closing date. */
42
42
  "work-closed-without-date",
43
- /** The record is done, and the finding it came from (`source.finding`) still fires on its region. Raised by `graph --intent` only. */
43
+ /** The record is done, and the finding it came from (`source.finding`) still fires on its region. Raised by `graph --intent`, and by `records` through a walk of that region (#2686). */
44
44
  "work-done-gap-open",
45
45
  ] as const satisfies readonly ReasonCode[];
46
46
  export type WorkWarningCode = (typeof WORK_WARNING_CODES)[number];