@coderifts/agent-guard 15.1.0 → 17.0.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 (61) hide show
  1. package/dist/cjs/execution-grant.d.ts +4 -2
  2. package/dist/cjs/execution-grant.d.ts.map +1 -1
  3. package/dist/cjs/execution-grant.js +6 -0
  4. package/dist/cjs/execution-grant.js.map +1 -1
  5. package/dist/cjs/guard.d.ts.map +1 -1
  6. package/dist/cjs/guard.js +56 -0
  7. package/dist/cjs/guard.js.map +1 -1
  8. package/dist/cjs/index.d.ts +3 -0
  9. package/dist/cjs/index.d.ts.map +1 -1
  10. package/dist/cjs/index.js +12 -1
  11. package/dist/cjs/index.js.map +1 -1
  12. package/dist/cjs/offline-verify.d.ts +75 -0
  13. package/dist/cjs/offline-verify.d.ts.map +1 -0
  14. package/dist/cjs/offline-verify.js +74 -0
  15. package/dist/cjs/offline-verify.js.map +1 -0
  16. package/dist/cjs/policy.d.ts.map +1 -1
  17. package/dist/cjs/policy.js +1 -1
  18. package/dist/cjs/policy.js.map +1 -1
  19. package/dist/cjs/tool-registry.d.ts.map +1 -1
  20. package/dist/cjs/tool-registry.js +7 -1
  21. package/dist/cjs/tool-registry.js.map +1 -1
  22. package/dist/cjs/types.d.ts +63 -1
  23. package/dist/cjs/types.d.ts.map +1 -1
  24. package/dist/cjs/unguarded-mutation.d.ts +83 -0
  25. package/dist/cjs/unguarded-mutation.d.ts.map +1 -0
  26. package/dist/cjs/unguarded-mutation.js +111 -0
  27. package/dist/cjs/unguarded-mutation.js.map +1 -0
  28. package/dist/cjs/vendor/VENDOR.sha256 +13 -0
  29. package/dist/cjs/vendor/arity.js +53 -0
  30. package/dist/cjs/vendor/verify.js +454 -0
  31. package/dist/esm/execution-grant.d.ts +4 -2
  32. package/dist/esm/execution-grant.d.ts.map +1 -1
  33. package/dist/esm/execution-grant.js +6 -0
  34. package/dist/esm/execution-grant.js.map +1 -1
  35. package/dist/esm/guard.d.ts.map +1 -1
  36. package/dist/esm/guard.js +56 -0
  37. package/dist/esm/guard.js.map +1 -1
  38. package/dist/esm/index.d.ts +3 -0
  39. package/dist/esm/index.d.ts.map +1 -1
  40. package/dist/esm/index.js +6 -0
  41. package/dist/esm/index.js.map +1 -1
  42. package/dist/esm/offline-verify.d.ts +75 -0
  43. package/dist/esm/offline-verify.d.ts.map +1 -0
  44. package/dist/esm/offline-verify.js +70 -0
  45. package/dist/esm/offline-verify.js.map +1 -0
  46. package/dist/esm/policy.d.ts.map +1 -1
  47. package/dist/esm/policy.js +1 -1
  48. package/dist/esm/policy.js.map +1 -1
  49. package/dist/esm/tool-registry.d.ts.map +1 -1
  50. package/dist/esm/tool-registry.js +7 -1
  51. package/dist/esm/tool-registry.js.map +1 -1
  52. package/dist/esm/types.d.ts +63 -1
  53. package/dist/esm/types.d.ts.map +1 -1
  54. package/dist/esm/unguarded-mutation.d.ts +83 -0
  55. package/dist/esm/unguarded-mutation.d.ts.map +1 -0
  56. package/dist/esm/unguarded-mutation.js +106 -0
  57. package/dist/esm/unguarded-mutation.js.map +1 -0
  58. package/dist/esm/vendor/VENDOR.sha256 +13 -0
  59. package/dist/esm/vendor/arity.js +53 -0
  60. package/dist/esm/vendor/verify.js +454 -0
  61. package/package.json +5 -4
@@ -0,0 +1,454 @@
1
+ 'use strict';
2
+
3
+ /*
4
+ * CodeRifts chain-receipt verifier — PURE LIBRARY. Node >= 20, zero dependencies (node:crypto only).
5
+ *
6
+ * Verify the receipt yourself — offline, no live CodeRifts API call needed.
7
+ * The reference format is frozen in ./RECEIPT_FORMAT.md.
8
+ *
9
+ * THE SPLIT (1282-A'). This file used to be both the library and the command. The command half
10
+ * carried the parts a library must not have: a shebang, a `require.main` block, argument parsing,
11
+ * and `fetchKeyInfo` — a network call whose LEGACY single-key branch returns a key document with
12
+ * no status field, so a receipt signed by a key that was later revoked verified as current. Four
13
+ * repositories vendor this file as `src/verify.js`; every one of them was carrying that command,
14
+ * and none of them could ever run it.
15
+ *
16
+ * The command now lives in ./cli.js. `node cli.js <receipt> …` is the same CLI it always was.
17
+ * Library behaviour is BYTE-IDENTICAL: the cross-language corpus and the envelope-step vectors
18
+ * pass unmodified, which is the proof that this was a move and not a rewrite.
19
+ *
20
+ * WHAT STAYED, and why it is not an inconsistency: `loadKeyring` reads a registry from a URL **or
21
+ * a local file**, and three of the four vendoring repos call it with a FILE path — it is part of
22
+ * the library surface they depend on. `fetchKeyInfo` has no file branch and no consumer outside
23
+ * the command, so it left.
24
+ *
25
+ * CLI usage now:
26
+ * node cli.js <receipt> [--key pub.pem | --keys <url|file>] [--kid <kid>] [--fetch <url>]
27
+ * node cli.js --chain receipts.txt [--key pub.pem | --keys <url|file>] [--kid <kid>] [--fetch <url>]
28
+ *
29
+ * Key discovery: with no --key/--keys, keys are fetched from
30
+ * https://app.coderifts.com/.well-known/coderifts-keys.json (override with --fetch <url>).
31
+ * The fetch-and-resolve path accepts BOTH the registry array (active + retired)
32
+ * and the legacy single-key body from /api/v1/attestation/public-key.
33
+ * --keys resolves each receipt's key by kid from a registry
34
+ * ({ keys: [{ kid, public_key_pem, status, valid_from, retired_at }] }); accepts a URL or file.
35
+ *
36
+ * Output: JSON { valid, reason?, payload?, chain? } to stdout.
37
+ * Exit codes: 0 valid, 1 invalid, 2 usage error.
38
+ *
39
+ * Verification order (matches the reference taxonomy exactly):
40
+ * structure -> json -> kid -> signature
41
+ * Reasons: malformed_structure | bad_json | unknown_kid | signature_error | signature_mismatch
42
+ */
43
+
44
+ const crypto = require('node:crypto');
45
+ const fs = require('node:fs');
46
+ const { split3ary } = require('./arity');
47
+
48
+ const DEFAULT_FETCH_URL = 'https://app.coderifts.com/.well-known/coderifts-keys.json';
49
+ const SIGNING_PREFIX = 'crchain.v1';
50
+ const MAX_SUPPORTED_V = 4;
51
+ /** ID104 — verification expiry leeway (ms). `exp + leeway < now` → VERIFIED_EXPIRED. */
52
+ const CLOCK_SKEW_LEEWAY_MS = 30_000;
53
+
54
+ /**
55
+ * 0s grace only when context DECLARES destructive AND production.
56
+ * Public verifier has `--environment` / envelope.environment; no `destructive`
57
+ * / `operation_class` field — never guess from operation labels.
58
+ */
59
+ function expiryLeewayMs(_context) {
60
+ return CLOCK_SKEW_LEEWAY_MS;
61
+ }
62
+
63
+ function isExpiredAt(expiresAtMs, nowMs, context) {
64
+ if (!Number.isFinite(expiresAtMs) || !Number.isFinite(nowMs)) return false;
65
+ return (expiresAtMs + expiryLeewayMs(context)) < nowMs;
66
+ }
67
+
68
+ // Signed fields per the max version — the anti-downgrade delimiter guard rejects any that contain '|'.
69
+ const SIGNED_FIELDS = ['kid', 'fp', 'prev', 'caller', 'ts', 'reg', 'ir', 'expires_at', 'bh'];
70
+
71
+ function sha256hex(str) {
72
+ return crypto.createHash('sha256').update(String(str), 'utf8').digest('hex');
73
+ }
74
+
75
+ /**
76
+ * RFC 8785 (JCS) canonical JSON for our data domain — MUST match the issuer's src/canonical-json.js
77
+ * and verify.py's canonical_json byte-for-byte (ASCII keys, JSON.stringify scalars, sorted keys, no
78
+ * whitespace, reject NaN/Infinity/undefined). Used to recompute decision_body_hash from --envelope.
79
+ */
80
+ function canonicalJson(value) {
81
+ if (value === null) return 'null';
82
+ const t = typeof value;
83
+ if (t === 'boolean' || t === 'string') return JSON.stringify(value);
84
+ if (t === 'number') {
85
+ if (!Number.isFinite(value)) throw new TypeError('canonicalJson: non-finite number');
86
+ return JSON.stringify(value);
87
+ }
88
+ if (t === 'undefined') throw new TypeError('canonicalJson: undefined');
89
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
90
+ if (t === 'object') {
91
+ const keys = Object.keys(value).sort();
92
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(',')}}`;
93
+ }
94
+ throw new TypeError(`canonicalJson: unsupported type ${t}`);
95
+ }
96
+
97
+ /**
98
+ * Reconstruct the exact signed bytes from a parsed receipt body.
99
+ * v1 (v absent or 1): the base string.
100
+ * v2 (v === 2): base + '|' + reg.
101
+ * v3 (v === 3): base + '|' + reg + '|' + ir.
102
+ * v4 (v === 4): base + '|' + reg + '|' + ir + '|' + expires_at + '|' + bh.
103
+ * Field order matches the CodeRifts issuer exactly (chain-attestation.js signingInputV4).
104
+ * This string must be byte-identical to the Python implementation.
105
+ */
106
+ function reconstructSignedInput(payload) {
107
+ const base = `${SIGNING_PREFIX}|${payload.kid}|${payload.fp}|${payload.prev}|${payload.caller}|${payload.ts}`;
108
+ if (payload.v === 4) return `${base}|${payload.reg}|${payload.ir}|${payload.expires_at}|${payload.bh}`;
109
+ if (payload.v === 3) return `${base}|${payload.reg}|${payload.ir}`;
110
+ if (payload.v === 2) return `${base}|${payload.reg}`;
111
+ return base;
112
+ }
113
+
114
+ /**
115
+ * Resolve the verification key for a parsed payload.
116
+ * Two modes:
117
+ * - keyring (from --keys): pick the entry whose kid matches payload.kid; an
118
+ * unlisted kid resolves to null (=> unknown_kid). A retired key still verifies
119
+ * so receipts issued before a rotation stay checkable.
120
+ * - single key (default): the one loaded key, gated by expectedKid when known.
121
+ * Returns a KeyObject, or null when the kid is not accepted.
122
+ */
123
+ /**
124
+ * 1306(a) — accept the SERVED registry shape, not only a Map.
125
+ *
126
+ * MEASURED black-box 2026-09-02. `https://app.coderifts.com/.well-known/coderifts-keys.json`
127
+ * serves `{keys:[{kid, public_key_pem, status, ...}]}`. Passing that document straight into
128
+ * `verifyReceipt(token, {ctx:{keyring}})` threw `TypeError: ctx.keyring.get is not a function` —
129
+ * the library path rejected the exact bytes this project publishes for it, while the CLI's
130
+ * `--keys <url>` worked because it converts first.
131
+ *
132
+ * The fix is here rather than in a caller: every consumer that fetches the public registry would
133
+ * otherwise have to know to convert, and the ones that do not find out with a TypeError rather
134
+ * than a verdict. `expectedKid` is a separate trap on the same path and is NOT papered over —
135
+ * `{keyring}` with no `expectedKid` still resolves to null by design (see resolveEntry), because
136
+ * silently defaulting a kid gate is a different and worse behaviour than a clear TypeError.
137
+ */
138
+ function coerceKeyring(k, source) {
139
+ if (!k) return k;
140
+ if (typeof k.get === 'function') return k; // already a Map
141
+ if (Array.isArray(k.keys) || Array.isArray(k)) {
142
+ return keyringFromDocument(Array.isArray(k) ? { keys: k } : k, source || 'ctx.keyring');
143
+ }
144
+ return k;
145
+ }
146
+
147
+ function resolveEntry(ctx, payload) {
148
+ if (ctx.keyring) {
149
+ const entry = ctx.keyring.get(payload.kid);
150
+ if (!entry) return null;
151
+ if (ctx.expectedKid !== null && payload.kid !== ctx.expectedKid) return null;
152
+ return entry; // { publicKey, status, retired_at }
153
+ }
154
+ if (ctx.expectedKid !== null && payload.kid !== ctx.expectedKid) return null;
155
+ return { publicKey: ctx.publicKey, status: null, retired_at: null, revoked_at: null, compromised_at: null };
156
+ }
157
+
158
+ /**
159
+ * Derive the 12-status taxonomy verdict for an already-signature-valid receipt.
160
+ * RETIRED_KEY_VALID_AT_ISSUE — retired key, receipt ts predates retired_at (else INVALID_SIGNATURE).
161
+ * UNSUPPORTED_VERSION — payload.v beyond MAX_SUPPORTED_V.
162
+ * VERIFIED_EXPIRED — v4 receipt whose signed expires_at + 30s leeway is in the past.
163
+ * VERIFIED_WRONG_AUDIENCE / _WRONG_ENVIRONMENT — dormant: only when --envelope carries the field
164
+ * AND a check input (--audience/--environment) is supplied.
165
+ * VERIFIED_SUPERSEDED / _SCOPE_MISMATCH — dormant: no check input defined this round.
166
+ * VERIFIED_CURRENT — otherwise.
167
+ */
168
+ function deriveStatus(payload, entry, opts) {
169
+ if (typeof payload.v === 'number' && payload.v > MAX_SUPPORTED_V) return 'UNSUPPORTED_VERSION';
170
+ // FAIL CLOSED ON A STATUS WE DO NOT UNDERSTAND.
171
+ //
172
+ // MEASURED 2026-08-26: a registry entry with status "revoked" returned
173
+ // { valid: true, status: "VERIFIED_CURRENT" } here — the status was read for 'retired' and
174
+ // otherwise ignored, so anything else fell through to the healthy path. An operator who marked a
175
+ // stolen key revoked would have believed they had acted while this verifier kept accepting it.
176
+ // The app kernel and verify-attest/verify-toolset already reject an unknown status; these two
177
+ // did not, so the fleet disagreed about the same registry.
178
+ //
179
+ // This is a bug fix, not revocation: the revocation RULE (compromised_at, REVOKED_KEY /
180
+ // REVOKED_KEY_UNDECIDABLE) is a separate, larger change across eight verifiers. What lands here
181
+ // is only the direction of the unknown case. Safe by measurement: the live registry publishes
182
+ // 'active' only, so no real consumer changes behaviour.
183
+ const KNOWN_STATUSES = new Set(['active', 'retired', 'revoked', null, undefined]);
184
+ if (!KNOWN_STATUSES.has(entry.status)) {
185
+ return 'UNKNOWN_KEY_STATUS';
186
+ }
187
+ // 1079 B — OPTIONAL timestamps, additive. Absent both fields → this function continues
188
+ // exactly as before. Signing time is payload.ts (receipts have no iat).
189
+ // revoked_at = compromise: EVERY receipt under the key is invalid, including those
190
+ // whose ts predates revoked_at (the attacker chooses ts).
191
+ // retired_at = planned rotation: ts < retired_at stays on the existing path;
192
+ // ts >= retired_at is KEY_RETIRED_AFTER_SIGNING.
193
+ if (typeof entry.revoked_at === 'string' && entry.revoked_at.length > 0) {
194
+ return 'KEY_REVOKED';
195
+ }
196
+ if (typeof entry.retired_at === 'string' && entry.retired_at.length > 0 && payload.ts) {
197
+ const issued = Date.parse(payload.ts);
198
+ const retired = Date.parse(entry.retired_at);
199
+ if (Number.isFinite(issued) && Number.isFinite(retired) && issued >= retired) {
200
+ return 'KEY_RETIRED_AFTER_SIGNING';
201
+ }
202
+ }
203
+ // REVOKED — RECEIPT_FORMAT.md §7.1 (normative). The attacker chooses ts, so no timestamp may
204
+ // rehabilitate a revoked key's signature: BOTH outcomes are valid:false. UNDECIDABLE is not a
205
+ // softer valid; it reports that we cannot tell a legitimate pre-compromise receipt from a
206
+ // backdated forgery. A missing compromised_at means the whole key history is suspect.
207
+ if (entry.status === 'revoked') {
208
+ const at = entry.compromised_at;
209
+ if (typeof at !== 'string' || at.length === 0) return 'REVOKED_KEY_UNDECIDABLE';
210
+ const boundary = Date.parse(at);
211
+ const issued = Date.parse(payload.ts);
212
+ if (!Number.isFinite(boundary) || !Number.isFinite(issued)) return 'REVOKED_KEY_UNDECIDABLE';
213
+ return issued >= boundary ? 'REVOKED_KEY' : 'REVOKED_KEY_UNDECIDABLE';
214
+ }
215
+ if (entry.status === 'retired') {
216
+ if (entry.retired_at && payload.ts
217
+ && Date.parse(payload.ts) < Date.parse(entry.retired_at)) {
218
+ return 'RETIRED_KEY_VALID_AT_ISSUE';
219
+ }
220
+ return 'INVALID_SIGNATURE'; // signed by a key already retired at issue -> reject
221
+ }
222
+ const now = opts.now != null ? opts.now : Date.now();
223
+ if (payload.v === 4 && typeof payload.expires_at === 'string') {
224
+ const exp = Date.parse(payload.expires_at);
225
+ const context = opts.envelope || { environment: opts.expectedEnvironment };
226
+ if (isExpiredAt(exp, now, context)) return 'VERIFIED_EXPIRED';
227
+ }
228
+ if (opts.envelope) {
229
+ const env = opts.envelope;
230
+ if (opts.expectedAudience != null && env.audience != null && env.audience !== opts.expectedAudience) {
231
+ return 'VERIFIED_WRONG_AUDIENCE';
232
+ }
233
+ if (opts.expectedEnvironment != null && env.environment != null && env.environment !== opts.expectedEnvironment) {
234
+ return 'VERIFIED_WRONG_ENVIRONMENT';
235
+ }
236
+ }
237
+ return 'VERIFIED_CURRENT';
238
+ }
239
+
240
+ /**
241
+ * Verify a single receipt token against a public key (or keyring) + expected kid.
242
+ * @param {string} token
243
+ * @param {{ publicKey?: import('crypto').KeyObject, keyring?: Map<string,{publicKey:import('crypto').KeyObject}>, expectedKid: (string|null) }} ctx
244
+ * @returns {{ valid: boolean, reason?: string, payload?: object }}
245
+ */
246
+ function verifyReceiptInner(token, ctx, opts = {}) {
247
+ // 1. structure
248
+ if (typeof token !== 'string' || token.length === 0) {
249
+ return { valid: false, status: 'MALFORMED', reason: 'malformed_structure' };
250
+ }
251
+ const segments = token.split('.');
252
+ if (segments.length !== 2 || segments.some((s) => !s)) {
253
+ return { valid: false, status: 'MALFORMED', reason: 'malformed_structure' };
254
+ }
255
+
256
+ // 2. json
257
+ let payload;
258
+ try {
259
+ payload = JSON.parse(Buffer.from(segments[0], 'base64url').toString('utf8'));
260
+ } catch (_) {
261
+ return { valid: false, status: 'MALFORMED', reason: 'bad_json' };
262
+ }
263
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
264
+ return { valid: false, status: 'MALFORMED', reason: 'bad_json' };
265
+ }
266
+
267
+ // 3. kid -- resolve the entry by kid (keyring) or gate the single key by expectedKid.
268
+ // 1306(a): a served `{keys:[...]}` document is accepted here as well as a Map.
269
+ const entry = resolveEntry(
270
+ ctx.keyring ? { ...ctx, keyring: coerceKeyring(ctx.keyring, 'ctx.keyring') } : ctx,
271
+ payload,
272
+ );
273
+ if (!entry) {
274
+ return { valid: false, status: 'UNKNOWN_KEY', reason: 'unknown_kid', payload };
275
+ }
276
+
277
+ // 4. signature (raw Ed25519 over the reconstructed UTF-8 bytes)
278
+ const sig = Buffer.from(segments[1], 'base64url');
279
+ let ok = false;
280
+ try {
281
+ ok = crypto.verify(null, Buffer.from(reconstructSignedInput(payload), 'utf8'), entry.publicKey, sig);
282
+ } catch (_) {
283
+ return { valid: false, status: 'INVALID_SIGNATURE', reason: 'signature_error', payload };
284
+ }
285
+ if (!ok) return { valid: false, status: 'INVALID_SIGNATURE', reason: 'signature_mismatch', payload };
286
+
287
+ // 5. anti-downgrade delimiter guard — a signed field containing '|' could re-split into a lower
288
+ // version whose reconstructed bytes collide with these. Legitimate fields never contain '|'.
289
+ for (const k of SIGNED_FIELDS) {
290
+ if (typeof payload[k] === 'string' && payload[k].includes('|')) {
291
+ return { valid: false, status: 'INVALID_SIGNATURE', reason: 'delimiter_in_field', payload };
292
+ }
293
+ }
294
+
295
+ // 6. envelope binding (v4): a supplied envelope's canonical body_hash MUST equal payload.bh.
296
+ if (opts.envelope && payload.v === 4) {
297
+ const rest = { ...opts.envelope };
298
+ delete rest.receipt;
299
+ delete rest.decision_body_hash;
300
+ const recomputed = 'sha256:' + sha256hex(canonicalJson(rest));
301
+ if (recomputed !== payload.bh) {
302
+ return { valid: false, status: 'INVALID_SIGNATURE', reason: 'body_hash_mismatch', payload };
303
+ }
304
+ }
305
+
306
+ // 7. taxonomy status (freshness / retirement / dormant field checks).
307
+ const status = deriveStatus(payload, entry, opts);
308
+ if (status === 'KEY_REVOKED') {
309
+ return { valid: false, status, reason: 'KEY_REVOKED', payload };
310
+ }
311
+ if (status === 'KEY_RETIRED_AFTER_SIGNING') {
312
+ return { valid: false, status, reason: 'KEY_RETIRED_AFTER_SIGNING', payload };
313
+ }
314
+ if (status === 'INVALID_SIGNATURE') {
315
+ return { valid: false, status, reason: 'retired_key_after_issue', payload };
316
+ }
317
+ const valid = status === 'VERIFIED_CURRENT' || status === 'RETIRED_KEY_VALID_AT_ISSUE';
318
+ // UNKNOWN_KEY_STATUS is deliberately absent from the valid set — see deriveStatus.
319
+ return { valid, status, payload };
320
+ }
321
+
322
+ /**
323
+ * Verify a chain of tokens (oldest first): every signature valid AND every non-genesis
324
+ * link's prev == 'sha256:' + sha256hex(previous token string).
325
+ */
326
+ function verifyChainInner(tokens, ctx, opts = {}) {
327
+ const links = [];
328
+ let allValid = true;
329
+ let first = null;
330
+
331
+ for (let i = 0; i < tokens.length; i++) {
332
+ const token = tokens[i];
333
+ const res = verifyReceiptInner(token, ctx, opts);
334
+ const link = { index: i, signature_valid: res.valid };
335
+ if (!res.valid) link.reason = res.reason;
336
+ const prev = res.payload ? res.payload.prev : undefined;
337
+ if (res.payload) link.prev = prev;
338
+
339
+ if (i === 0) {
340
+ first = prev === 'null' ? 'genesis' : 'continuation';
341
+ link.role = first;
342
+ // A genesis needs prev === 'null'; a continuation links to a token we do not hold.
343
+ link.prev_ok = prev === 'null' ? true : null;
344
+ } else {
345
+ const expected = `sha256:${sha256hex(tokens[i - 1])}`;
346
+ link.expected_prev = expected;
347
+ link.prev_ok = prev === expected;
348
+ if (!link.prev_ok) allValid = false;
349
+ }
350
+
351
+ if (!res.valid) allValid = false;
352
+ links.push(link);
353
+ }
354
+
355
+ return { valid: allValid, chain: { length: tokens.length, first, links } };
356
+ }
357
+
358
+ // ---------------------------------------------------------------------------
359
+ // Key loading
360
+ // ---------------------------------------------------------------------------
361
+
362
+ function keyFromPem(pem) {
363
+ return crypto.createPublicKey(pem);
364
+ }
365
+
366
+ /**
367
+ * Build a kid -> key map from a registry document
368
+ * ({ keys: [{ kid, public_key_pem, status, valid_from, retired_at }] }).
369
+ * Returns null when `keys` is missing/empty so the caller can try the legacy
370
+ * single-key body. --keys still requires a non-empty keys[] (throws).
371
+ */
372
+ function keyringFromDocument(doc, source) {
373
+ const keys = doc && Array.isArray(doc.keys) ? doc.keys : null;
374
+ if (!keys || keys.length === 0) return null;
375
+ const keyring = new Map();
376
+ for (const k of keys) {
377
+ if (!k || !k.kid || !k.public_key_pem) throw new Error(`registry entry missing kid/public_key_pem in ${source}`);
378
+ // compromised_at MUST be carried through: deriveStatus reads it for the revoked rule
379
+ // (RECEIPT_FORMAT.md 7.1). Dropping it here made the rule inert — every revoked key
380
+ // returned UNDECIDABLE regardless of ts, which looks implemented and decides nothing.
381
+ keyring.set(k.kid, {
382
+ publicKey: keyFromPem(k.public_key_pem),
383
+ status: k.status || null,
384
+ retired_at: k.retired_at || null,
385
+ revoked_at: k.revoked_at || null,
386
+ compromised_at: k.compromised_at || null,
387
+ });
388
+ }
389
+ return keyring;
390
+ }
391
+
392
+ function pickActiveFromKeyring(keyring) {
393
+ for (const [kid, entry] of keyring) {
394
+ if (entry.status === 'active') return { kid, entry };
395
+ }
396
+ const first = keyring.entries().next().value;
397
+ return first ? { kid: first[0], entry: first[1] } : null;
398
+ }
399
+
400
+ /**
401
+ * Build a kid -> key map from a CodeRifts key registry
402
+ * ({ keys: [{ kid, public_key_pem, status, valid_from, retired_at }] }). A --keys source may be
403
+ * an http(s) URL or a local file path. Both active and retired keys are loaded.
404
+ */
405
+ async function loadKeyring(source) {
406
+ let text;
407
+ if (/^https?:\/\//i.test(source)) {
408
+ const res = await fetch(source, { headers: { Accept: 'application/json' } });
409
+ if (!res.ok) throw new Error(`fetch ${source} -> HTTP ${res.status}`);
410
+ text = await res.text();
411
+ } else {
412
+ text = fs.readFileSync(source, 'utf8');
413
+ }
414
+ const doc = JSON.parse(text);
415
+ const keyring = keyringFromDocument(doc, source);
416
+ if (!keyring) throw new Error(`no keys[] in registry ${source}`);
417
+ return keyring;
418
+ }
419
+
420
+ function verifyReceipt(token, second, third) {
421
+ const { ctx, opts } = split3ary('verifyReceipt', arguments.length, second, third);
422
+ return verifyReceiptInner(token, ctx, opts);
423
+ }
424
+
425
+ function verifyChain(tokens, second, third) {
426
+ const { ctx, opts } = split3ary('verifyChain', arguments.length, second, third);
427
+ return verifyChainInner(tokens, ctx, opts);
428
+ }
429
+
430
+ // Reusable API — require('./verify') imports the pure verify logic WITHOUT running the CLI. The
431
+ // GitHub Action + other embedders use these directly (verifyReceipt/verifyChain/deriveStatus/…);
432
+ // the receipt format + taxonomy are frozen in RECEIPT_FORMAT.md.
433
+ module.exports = {
434
+ verifyReceipt,
435
+ verifyChain,
436
+ deriveStatus,
437
+ resolveEntry,
438
+ reconstructSignedInput,
439
+ canonicalJson,
440
+ loadKeyring,
441
+ keyFromPem,
442
+ keyringFromDocument,
443
+ // Pure keyring helper. Exported for the command (cli.js fetchKeyInfo) after the split —
444
+ // it takes a keyring and returns the active entry; it performs no I/O.
445
+ pickActiveFromKeyring,
446
+ sha256hex,
447
+ DEFAULT_FETCH_URL,
448
+ SIGNING_PREFIX,
449
+ MAX_SUPPORTED_V,
450
+ SIGNED_FIELDS,
451
+ CLOCK_SKEW_LEEWAY_MS,
452
+ expiryLeewayMs,
453
+ isExpiredAt,
454
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coderifts/agent-guard",
3
- "version": "15.1.0",
3
+ "version": "17.0.0",
4
4
  "description": "Fail-closed guard for AI agent tool calls — preflight contract changes before they execute. Security core frozen (agent-guard-api v1.0); v1.1 adds client-side enforcement: receipt→envelope binding, decision↔action reconciliation, safe_for_agent + degraded fail-closed, and enforced⟺executed (P0 client-enforcement pack).",
5
5
  "main": "./dist/cjs/index.js",
6
6
  "module": "./dist/esm/index.js",
@@ -45,11 +45,12 @@
45
45
  "engines": {
46
46
  "node": ">=18"
47
47
  },
48
- "dependencies": {
49
- "@coderifts/sdk": "^3.10.0"
50
- },
51
48
  "devDependencies": {
49
+ "@coderifts/sdk": "^3.10.0",
52
50
  "@types/node": "^20.0.0",
53
51
  "typescript": "^5.4.0"
52
+ },
53
+ "peerDependencies": {
54
+ "@coderifts/sdk": "^3.10.0"
54
55
  }
55
56
  }