@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.
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/workspace/intent.d.ts +6 -1
- package/dist/workspace/intent.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +9 -2
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +6 -0
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-write.d.ts +9 -2
- package/dist/workspace/records-write.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +47 -5
- package/dist/workspace/records.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 +3 -3
- package/package.json +1 -1
- package/src/cli/main.ts +10 -3
- package/src/cli/registry.ts +2 -0
- package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +12 -0
- package/src/workspace/intent-gaps.test.ts +217 -0
- package/src/workspace/intent.schema.json +23 -1
- package/src/workspace/intent.test.ts +2 -0
- package/src/workspace/intent.ts +35 -3
- package/src/workspace/reason-codes.test.ts +2 -1
- package/src/workspace/reason-codes.ts +10 -2
- package/src/workspace/record-assets.test.ts +2 -2
- package/src/workspace/records-cli.ts +70 -8
- package/src/workspace/records-contract.test.ts +2 -2
- package/src/workspace/records-review.schema.json +26 -1
- package/src/workspace/records-write.ts +32 -3
- package/src/workspace/records.schema.json +16 -1
- package/src/workspace/records.ts +78 -9
- 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 +3 -1
- 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
|
-
|
|
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" }] },
|
package/src/workspace/work.ts
CHANGED
|
@@ -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:
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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`
|
|
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];
|