imbas-verify 0.0.0-stage → 0.1.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/read-file.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Reads a text file, returning a named failure instead of throwing.
3
+ *
4
+ * The same reasoning as `readAttestationFile`: a path the operator typed wrong must
5
+ * produce "cannot read that file", not a stack trace from `node:fs`. Verify is usually
6
+ * the first thing anyone runs on a fresh machine, with paths they were given.
7
+ */
8
+ export type TextRead = {
9
+ ok: true;
10
+ text: string;
11
+ } | {
12
+ ok: false;
13
+ check: "key_unreadable";
14
+ message: string;
15
+ };
16
+ export declare function readTextFile(path: string): TextRead;
17
+ /**
18
+ * Reads a file as raw bytes, for the patch.
19
+ *
20
+ * Separate from `readTextFile` because the patch digest is over bytes. Decoding to a
21
+ * string and re-encoding would go through UTF-8 a second time, and a patch is not
22
+ * guaranteed to be valid UTF-8 - the digest would then be of different bytes than the
23
+ * ones that were approved, and an honest file would report a mismatch.
24
+ */
25
+ export type BinaryRead = {
26
+ ok: true;
27
+ bytes: Buffer;
28
+ } | {
29
+ ok: false;
30
+ check: "patch_unreadable";
31
+ message: string;
32
+ };
33
+ export declare function readBinaryFile(path: string): BinaryRead;
package/read-file.js ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * File reads that return a named failure instead of throwing.
3
+ *
4
+ * Split from `attest.ts` for the same reason as `verify.ts`: the verifier needs these and
5
+ * nothing else in that module needs the engine.
6
+ */
7
+ import { readFileSync } from "node:fs";
8
+ export function readTextFile(path) {
9
+ try {
10
+ return { ok: true, text: readFileSync(path, "utf8") };
11
+ }
12
+ catch (cause) {
13
+ const code = cause.code ?? "unknown";
14
+ return { ok: false, check: "key_unreadable", message: `Cannot read ${path} (${code}).` };
15
+ }
16
+ }
17
+ export function readBinaryFile(path) {
18
+ try {
19
+ return { ok: true, bytes: readFileSync(path) };
20
+ }
21
+ catch (cause) {
22
+ const code = cause.code ?? "unknown";
23
+ return { ok: false, check: "patch_unreadable", message: `Cannot read ${path} (${code}).` };
24
+ }
25
+ }
package/record.d.ts ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The shape of the signed approval predicate, declared once.
3
+ *
4
+ * These interfaces lived in `predicate.ts`, beside the code that *builds* them. That was
5
+ * wrong for one reason: `attestation.ts` (the verifier) needs to name the type, and a type
6
+ * import still makes the compiler load the module it names. `predicate.ts` imports the
7
+ * gates, the pipeline state and the provenance store, so verifying a record dragged the
8
+ * whole engine into the program — and, in the published verifier, into the closure.
9
+ *
10
+ * Declaring them here means the verifier names nothing outside the package at all — not
11
+ * even in a type import. That matters twice over: a type import still makes the compiler
12
+ * load the named module while building, and it writes the name into the emitted `.d.ts`,
13
+ * where a consumer who installed only this package would see `any` or a typecheck error.
14
+ *
15
+ * `StageId` and `GateKind` therefore carry local declarations rather than imports, and
16
+ * `engine/test/verify-standalone.test.ts` asserts they still agree with the contract.
17
+ *
18
+ * `predicate.ts` re-exports everything in this file, so existing imports are unchanged.
19
+ * There is still exactly one declaration of the predicate: this one.
20
+ */
21
+ export type StageId = "intake" | "research" | "plan" | "build" | "review" | "demo" | "notify" | "handoff";
22
+ export type GateKind = "operator" | "engineer";
23
+ /** What the record says about one gate. */
24
+ export interface PredicateGate {
25
+ stage: StageId;
26
+ kind: GateKind;
27
+ /** The decision, or null if the gate was never decided. */
28
+ decision: "approve" | "reject" | null;
29
+ gate_id: string;
30
+ /**
31
+ * The artifact this gate decided, or null at a decision-only stage.
32
+ *
33
+ * HANDOFF produces nothing, so there is nothing to point at. Null says that
34
+ * plainly; a placeholder id would claim an artifact that never existed.
35
+ */
36
+ artifact_id: string | null;
37
+ requested_at: string;
38
+ decided_at: string | null;
39
+ principal_id: string | null;
40
+ principal_role: string | null;
41
+ authenticated_via: string | null;
42
+ /** Which explainer the approver acknowledged. Invariant 5, recorded. */
43
+ acknowledged_explainer_id: string | null;
44
+ explainer_view: string | null;
45
+ }
46
+ /** One stage in the pipeline that did not run. */
47
+ export interface PredicateNotRun {
48
+ stage: StageId;
49
+ /**
50
+ * Why, in the words that were shown when the stage was stepped over. Copied from
51
+ * the `stage_unavailable` record's explainer, never rewritten for the signature: a
52
+ * reason that reads differently in the record than in the log is a reason a reader
53
+ * cannot check.
54
+ */
55
+ reason: string;
56
+ }
57
+ /** One override, with the per-finding reasons exactly as they were recorded. */
58
+ export interface PredicateOverride {
59
+ override_id: string;
60
+ stage: StageId;
61
+ overridden_verdict: "changes_requested";
62
+ reviewer_artifact_id: string;
63
+ reviewer_principal_id: string;
64
+ reason: string;
65
+ finding_reasons: Array<{
66
+ finding_id: string;
67
+ reason: string;
68
+ }>;
69
+ /** Who set the verdict aside, and when. */
70
+ principal_id: string | null;
71
+ decided_at: string | null;
72
+ /** The provenance record carrying it, so a reader can find it in the chain. */
73
+ record_id: string;
74
+ }
75
+ /** A model that took part, and what it was, exactly as provenance recorded it. */
76
+ export interface PredicateModel {
77
+ stage: StageId;
78
+ adapter: string;
79
+ provider: string;
80
+ model: string;
81
+ mocked: boolean;
82
+ prompt_version: string | null;
83
+ }
84
+ export interface ApprovalPredicate {
85
+ /** The name and version of this predicate, so a reader can find the definition. */
86
+ predicate_name: "imbas.approval";
87
+ predicate_version: "1";
88
+ schema_version: string;
89
+ job_id: string;
90
+ job_title: string;
91
+ sample: boolean;
92
+ created_at: string;
93
+ completed_at: string;
94
+ /** Every gate in the job, in pipeline order. */
95
+ gates: PredicateGate[];
96
+ /** Every override, in the order they were recorded. */
97
+ overrides: PredicateOverride[];
98
+ /**
99
+ * Every stage in the pipeline that did not run, and why, copied from its
100
+ * `stage_unavailable` record.
101
+ *
102
+ * Signed rather than only logged, because the claim this record makes is "production
103
+ * was authorised for what was actually checked". A job that reached the engineer gate
104
+ * with DEMO and NOTIFY unexecuted is still a legitimate job — but a reader of the
105
+ * record must be able to see the gap without also being trusted to have read the
106
+ * chain. Empty means every stage ran; it is never derived by subtracting from the
107
+ * list of stages that happened to produce an artifact.
108
+ */
109
+ stages_not_run: PredicateNotRun[];
110
+ /** Every adapter that produced an artifact, in the order they ran. */
111
+ models: PredicateModel[];
112
+ /** The head of the provenance chain. Verifying the chain verifies this many records. */
113
+ provenance: {
114
+ head_hash: string;
115
+ record_count: number;
116
+ chain_valid: boolean;
117
+ };
118
+ /** The versions the record was produced under, so a reader can place it in time. */
119
+ versions: {
120
+ contracts: string;
121
+ glossary: string;
122
+ };
123
+ /** The installation whose key signed this. */
124
+ installation_keyid: string;
125
+ proves: string[];
126
+ does_not_prove: string[];
127
+ }
package/record.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,353 @@
1
+ import { type DsseEnvelope } from "./dsse.js";
2
+ import { type InstallationKey } from "./key.js";
3
+ /** One retirement, as it appears in the list. */
4
+ export interface KeyRetirement {
5
+ keyid: string;
6
+ /** ISO 8601. Records signed at or after this moment are the alarming ones. */
7
+ retired_at: string;
8
+ /** Why. Free text: "laptop stolen", "machine decommissioned". */
9
+ reason: string;
10
+ /** When the entry was written. Equal to `retired_at` for a timely retirement. */
11
+ recorded_at: string;
12
+ /**
13
+ * The keyid that replaces this one, when there is one.
14
+ *
15
+ * A **note**, not a proof. It says the operator intends that key to be used from here on;
16
+ * it says nothing about whether that key is trustworthy, whether the holder is the same
17
+ * person, or whether it was ever used. The only thing that makes the note worth reading is
18
+ * the policy key's signature over the whole list.
19
+ *
20
+ * Optional on purpose. A laptop that was stolen has no successor, and making the field
21
+ * required would turn "the key is gone" into a malformed entry. `undefined` and
22
+ * "superseded by B" are different facts and both are legitimate.
23
+ */
24
+ supersedes?: string;
25
+ }
26
+ export interface RetirementList {
27
+ schema_version: string;
28
+ retirements: KeyRetirement[];
29
+ }
30
+ /**
31
+ * A retirement list and the signature over it.
32
+ *
33
+ * The signature covers `entries` — the object holding `retirements` — and **not** the
34
+ * envelope that carries it, exactly as an attestation's signature covers the statement
35
+ * rather than the file. What a verifier must be able to establish is "these are the entries
36
+ * the policy key signed", and nothing about how the file was laid out.
37
+ *
38
+ * The payload is serialised once by `sealList` and those bytes are what travel. A verifier
39
+ * re-serialising the entries to check the signature would be checking a document that
40
+ * happened to mean the same thing, which is the failure the counter-signature slice spent
41
+ * its whole length avoiding.
42
+ */
43
+ export interface SignedRetirementList {
44
+ /** The entries, exactly as signed. */
45
+ entries: {
46
+ retirements: KeyRetirement[];
47
+ schema_version: string;
48
+ };
49
+ /** The DSSE envelope over the canonical bytes of `entries`; null when unsigned. */
50
+ envelope: DsseEnvelope | null;
51
+ }
52
+ /** What a verifier concluded about the list's own integrity. */
53
+ export type ListSignatureState =
54
+ /** The list carries an envelope and it verifies against the policy key supplied. */
55
+ {
56
+ state: "signed";
57
+ keyid: string;
58
+ }
59
+ /** The list is a bare list of entries with no envelope at all. */
60
+ | {
61
+ state: "unsigned";
62
+ }
63
+ /** An envelope is present but does not verify — the entries are not the signed ones. */
64
+ | {
65
+ state: "signature_invalid";
66
+ message: string;
67
+ }
68
+ /** The envelope verifies against a keyid the verifier supplied no key for. */
69
+ | {
70
+ state: "unknown_signer";
71
+ keyid: string;
72
+ }
73
+ /** An envelope is present, but the verifier supplied no policy key to check it with. */
74
+ | {
75
+ state: "unverified_signature";
76
+ };
77
+ /**
78
+ * The version this code writes.
79
+ *
80
+ * 2.0.0 added `supersedes`. It is a major bump rather than a minor one because the entries
81
+ * are **signed**: a list written at 1.0.0 and a list written at 2.0.0 are different
82
+ * documents, and a verifier re-serialising one to check the other would produce bytes that
83
+ * never existed. Calling that a minor change would invite exactly that mistake.
84
+ */
85
+ export declare const RETIREMENT_SCHEMA_VERSION = "2.0.0";
86
+ /** The payload type for a retirement list. Distinct from an attestation's, so a list can
87
+ * never be presented as a record or the other way round. */
88
+ export declare const RETIREMENT_PAYLOAD_TYPE = "application/vnd.imbas.retirements+json";
89
+ export declare class RetirementError extends Error {
90
+ readonly code: string;
91
+ constructor(code: string, message: string);
92
+ }
93
+ /** Where the retirement list lives inside a data dir. */
94
+ export declare function retirementsPath(dataDir: string): string;
95
+ /**
96
+ * Reads a retirement list, or returns an empty one when the file does not exist.
97
+ *
98
+ * **A missing file is an empty list, and that is not the same as "nothing is retired".**
99
+ * The caller must be able to tell those apart, so this returns `found` alongside the list
100
+ * and `lookupRetirement` reports `unknown` rather than `live` when the keyid is absent.
101
+ * Collapsing the two would make every verifier who was never handed a list report a
102
+ * confident "key not retired", which is the exact overclaim this whole module exists to
103
+ * avoid.
104
+ */
105
+ export type RetirementRead = {
106
+ ok: true;
107
+ list: RetirementList;
108
+ found: boolean;
109
+ /**
110
+ * The signature state, when the file carried an envelope. `null` means the file was a
111
+ * bare list with nothing to check — which `verifyListSignature` reports as `unsigned`.
112
+ *
113
+ * Carried here rather than checked here: this function's job is to read a file, and
114
+ * checking a signature needs a policy key the reader may not have. It reports what it
115
+ * saw so the caller can decide.
116
+ */
117
+ envelope: DsseEnvelope | null;
118
+ } | {
119
+ ok: false;
120
+ code: "retirements_json" | "retirements_unreadable";
121
+ message: string;
122
+ };
123
+ export declare function readRetirementList(path: string): RetirementRead;
124
+ /**
125
+ * Validates a parsed retirement list.
126
+ *
127
+ * Strict about shape and lenient about nothing, because the failure mode of a
128
+ * half-understood retirement list is a verifier that silently checks fewer entries than
129
+ * its author wrote. An entry missing `retired_at` would otherwise become `undefined`, and
130
+ * `new Date(undefined)` is an Invalid Date that compares false against everything — so a
131
+ * malformed entry would read as "retired after all records", or as "retired never",
132
+ * depending on which way the comparison fell. Refusing the file is the only safe answer.
133
+ */
134
+ export declare function parseRetirementList(value: unknown): {
135
+ ok: true;
136
+ list: RetirementList;
137
+ } | {
138
+ ok: false;
139
+ message: string;
140
+ };
141
+ /**
142
+ * True when the text is an ISO 8601 timestamp that names a real instant.
143
+ *
144
+ * `new Date("nonsense")` produces an Invalid Date rather than throwing, and every
145
+ * comparison against an Invalid Date is `false` — including `>=` and `<=`. A timestamp
146
+ * that cannot be compared is worse than a missing one, because it silently answers "no"
147
+ * to every question asked of it.
148
+ */
149
+ export declare function isIsoDate(text: string): boolean;
150
+ /**
151
+ * Appends a retirement.
152
+ *
153
+ * Refuses a duplicate keyid. Retiring the same key twice would produce a list where an
154
+ * earlier moment and a later one both claim to be the retirement, and a comparison that
155
+ * read the wrong one would call a post-retirement record genuine. The refusal is by name
156
+ * so an operator who runs the command twice learns what happened.
157
+ *
158
+ * Refuses to move a retirement *earlier* as well, for the same reason in the other
159
+ * direction: shortening the trusted period after records have been signed would
160
+ * retroactively disown approvals the operator made in good faith. A later retirement is
161
+ * a widening of trust, and this refuses that too — a list whose entries can be revised is
162
+ * a list a verifier cannot reason about.
163
+ */
164
+ export declare function appendRetirement(input: {
165
+ path: string;
166
+ keyid: string;
167
+ retiredAt: string;
168
+ reason: string;
169
+ now?: string;
170
+ /** The keyid that replaces this one, when the operator is rotating rather than losing it. */
171
+ supersedes?: string;
172
+ /** The policy key. Required for a signed list; absent writes a bare, unsigned one. */
173
+ policyKey?: InstallationKey;
174
+ }): KeyRetirement;
175
+ /**
176
+ * Serialises the entries once and signs those bytes.
177
+ *
178
+ * `JSON.stringify` without indentation, not the two-space form the file is written with.
179
+ * The signature must cover a canonical byte string so that a verifier can reproduce it from
180
+ * `entries` alone without knowing how the writer chose to format the file. Key order is
181
+ * insertion order, which is deterministic for a given object, and the entries are built in
182
+ * one place — so a re-serialisation by `verifyListSignature` produces the same bytes.
183
+ */
184
+ export declare function sealList(entries: {
185
+ schema_version: string;
186
+ retirements: KeyRetirement[];
187
+ }, key: InstallationKey): SignedRetirementList;
188
+ /**
189
+ * Checks the signature over a list, before any entry is read.
190
+ *
191
+ * Returns one of five states rather than a boolean, because "there is no signature", "the
192
+ * signature is wrong" and "I was given no key to check it with" are three different facts
193
+ * and only one of them says somebody tampered.
194
+ *
195
+ * `policyPem` is optional. When it is absent a signed list reports
196
+ * `unverified_signature` — a list that *has* a signature nobody checked, which is again
197
+ * different from a list with none. Collapsing those would let an operator hand a verifier
198
+ * an unsigned list and have it read as "unsigned is normal here".
199
+ */
200
+ export declare function verifyListSignature(list: unknown, policyPem: string | null): {
201
+ ok: true;
202
+ state: ListSignatureState;
203
+ list: RetirementList;
204
+ } | {
205
+ ok: false;
206
+ state: ListSignatureState;
207
+ message: string;
208
+ };
209
+ /** What a verifier can conclude about one keyid. */
210
+ export type RetirementVerdict =
211
+ /** The keyid is in the list, and the record was signed at or after the retirement. */
212
+ {
213
+ state: "retired_before_signing";
214
+ retirement: KeyRetirement;
215
+ }
216
+ /** The keyid is in the list, and the record was signed before the retirement. */
217
+ | {
218
+ state: "retired_after_signing";
219
+ retirement: KeyRetirement;
220
+ }
221
+ /** The keyid is not in the list the verifier holds. Says nothing about trust. */
222
+ | {
223
+ state: "not_listed";
224
+ }
225
+ /**
226
+ * The record names no signing moment this can be compared against, so the question
227
+ * cannot be answered. Distinct from both other answers: `not_listed` means "I looked
228
+ * and it is absent", this means "I could not look".
229
+ */
230
+ | {
231
+ state: "no_signing_time";
232
+ retirement: KeyRetirement;
233
+ };
234
+ /**
235
+ * What following the successors from one keyid produced.
236
+ *
237
+ * The two states that must never be confused are **"retired, and nothing replaced it"** and
238
+ * **"never retired at all"** — opposite facts about a key, and collapsing them would let a
239
+ * dead key read as live or a live one as dead.
240
+ *
241
+ * `path` is the walk itself, from the key asked about to the last retirement reached, so a
242
+ * reader can see A → B → C rather than being told a flat answer. Every path ends at a
243
+ * retirement; `state` says *why* the walk stopped there.
244
+ *
245
+ * Nothing here invents trust. A successor is a note the operator wrote; the only thing that
246
+ * makes it worth reading is the policy key's signature over the list it lives in.
247
+ */
248
+ export type SuccessionChain =
249
+ /** The key asked about is not in the list. This says nothing about whether it is trusted. */
250
+ {
251
+ state: "not_retired";
252
+ keyid: string;
253
+ }
254
+ /**
255
+ * The walk reached a retirement that names no successor — a stolen or decommissioned
256
+ * machine. A `path` of length one is the simple case: this key was retired and nothing
257
+ * replaced it.
258
+ */
259
+ | {
260
+ state: "retired_without_successor";
261
+ keyid: string;
262
+ path: SuccessionHop[];
263
+ }
264
+ /**
265
+ * The walk stopped because a retirement names a successor that is **not in this list**, so
266
+ * whether that key was itself retired cannot be said. Not an error: a successor is a note,
267
+ * not a proof, and a note may point outside the document that carries it.
268
+ */
269
+ | {
270
+ state: "successor_not_listed";
271
+ keyid: string;
272
+ path: SuccessionHop[];
273
+ successor: string;
274
+ }
275
+ /**
276
+ * The notes lead back to a key already walked. Only reachable from a hand-edited list,
277
+ * since both write paths refuse a self-successor. Reported rather than looped on, because
278
+ * a verifier that hangs on a malformed file is a denial of service of its own.
279
+ */
280
+ | {
281
+ state: "cycle";
282
+ keyid: string;
283
+ path: SuccessionHop[];
284
+ repeated: string;
285
+ };
286
+ /** One step of a succession walk: a keyid and the retirement record that ended its turn. */
287
+ export type SuccessionHop = {
288
+ keyid: string;
289
+ retirement: KeyRetirement;
290
+ };
291
+ /**
292
+ * Follows the successors from a keyid, to the end of the chain.
293
+ *
294
+ * Read-only: it walks the signed list and invents nothing, signs nothing, and changes no
295
+ * trust. The walk terminates on the first key that is not retired, on a successor the list
296
+ * does not mention, or on a repeat — and reports *which* it was, because they mean
297
+ * different things to a reader deciding whether a signature is still worth anything.
298
+ *
299
+ * It checks no signature. Call `verifyListSignature` first: following an unverified list is
300
+ * following an unsigned claim, which this whole module exists to avoid.
301
+ */
302
+ export declare function followSuccessors(list: RetirementList, keyid: string): SuccessionChain;
303
+ /**
304
+ * The walk in one sentence.
305
+ *
306
+ * Deliberately talks about *notes* rather than trust. A chain ending at "key C" says the
307
+ * operator wrote down that C replaced B; it does not say C is trusted, or even that it
308
+ * exists. Nothing here is revocation, expiry, or a statement about time.
309
+ */
310
+ export declare function describeSuccession(chain: SuccessionChain): string;
311
+ /**
312
+ * One sentence per retirement state, in the words the docs use.
313
+ *
314
+ * Shared by the CLI and the API on purpose. Two copies would drift, and the drift would be
315
+ * invisible: an operator reading the terminal and a verifier reading the panel would be
316
+ * told subtly different things about the same record.
317
+ */
318
+ export declare function describeVerdict(verdict: {
319
+ keyid: string;
320
+ state: "unchecked";
321
+ } | {
322
+ keyid: string;
323
+ state: "not_listed";
324
+ } | {
325
+ keyid: string;
326
+ state: "retired_after_signing";
327
+ retirement: KeyRetirement;
328
+ } | {
329
+ keyid: string;
330
+ state: "no_signing_time";
331
+ retirement: KeyRetirement;
332
+ } | undefined): string;
333
+ /**
334
+ * Decides what the list says about a record signed by this keyid.
335
+ *
336
+ * **The signing moment is the whole record's, not one signature's.** An envelope does not
337
+ * carry a per-signature timestamp, and inventing one would be a claim the format does not
338
+ * make. So the question is asked of the record's *envelope* — the interval from the signed
339
+ * `created_at` to the signed `completed_at` — and the later end is what decides:
340
+ *
341
+ * - A record whose `completed_at` is at or after the retirement is `retired_before_signing`.
342
+ * The key had been disowned by the time the record was sealed, so the signature claims
343
+ * something its own operator had withdrawn.
344
+ * - A record whose `completed_at` is before the retirement is `retired_after_signing`: the
345
+ * key was trusted throughout the job it describes.
346
+ *
347
+ * Using `completed_at` rather than `created_at` is deliberate. A long job that *started*
348
+ * before the retirement and finished after it was signed after the key was disowned. The
349
+ * earlier timestamp would launder exactly the case this feature exists to catch.
350
+ */
351
+ export declare function judgeRetirement(list: RetirementList, keyid: string, signing: {
352
+ completedAt: string | null;
353
+ }): RetirementVerdict;