@coderifts/agent-guard 17.3.0 → 17.3.1

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.
@@ -43,6 +43,7 @@ const crypto = require('node:crypto');
43
43
 
44
44
  const { verifyExecutionGrant } = require('./verify-grant.js');
45
45
  const { verifyEvidenceRootBinding } = require('./verify-evidence.js');
46
+ const { verifyReceipt } = require('./verify.js');
46
47
 
47
48
  const BINDING_V = 'cr.verified-execution-binding.v1';
48
49
 
@@ -57,6 +58,24 @@ const STATE = Object.freeze({
57
58
  RECORDED_UNWITNESSED: 'RECORDED_UNWITNESSED',
58
59
  NOT_COMMITTED: 'NOT_COMMITTED',
59
60
  AUTHORIZED_AND_COMMITTED: 'AUTHORIZED_AND_COMMITTED',
61
+ /**
62
+ * A CUSTOM aggregation was satisfied — and it is NOT the global success token.
63
+ *
64
+ * ── THE HOLE THIS CLOSES, REPRODUCED BEFORE IT WAS WRITTEN ──────────────────────────────
65
+ *
66
+ * valid grant + committed + NO attestation + NO root, required: ['issuer_grant']
67
+ * -> AUTHORIZED_AND_COMMITTED, authorized_and_committed: true
68
+ *
69
+ * Nothing was forged. The caller simply asked for less, and the function handed back the token
70
+ * every consumer reads as "authorized and committed". `required[]` was a strictness dial that
71
+ * also selected the NAME of success, so narrowing the dial upgraded the verdict — the strongest
72
+ * word in the vocabulary, reachable by asking for the least.
73
+ *
74
+ * A caller may still choose its own authorities. What it may no longer do is call the result by
75
+ * the global name. `AUTHORIZED_AND_COMMITTED` is now reachable ONLY through a closed, versioned
76
+ * profile whose authority set the caller does not get to shorten.
77
+ */
78
+ CUSTOM_REQUIREMENTS_SATISFIED: 'CUSTOM_REQUIREMENTS_SATISFIED',
60
79
  });
61
80
 
62
81
  /** What each authority is allowed to be missing for, so a caller can choose its own strictness. */
@@ -67,6 +86,36 @@ const AUTHORITY = Object.freeze({
67
86
  PROVIDER_WITNESS: 'provider_witness',
68
87
  });
69
88
 
89
+ /**
90
+ * CLOSED, VERSIONED ASSURANCE PROFILES.
91
+ *
92
+ * A profile fixes its authority set. The caller names the profile; it does not get to edit what
93
+ * the profile means, and the version in the name is what lets the set change later without
94
+ * silently changing what an older caller was promised.
95
+ *
96
+ * `TRUSTED_EXECUTOR_INTEGRITY_V1` is the honest ceiling of a single-machine chain: the decision
97
+ * receipt verified, the issuer's grant verified, the executor's attestation verified, and one
98
+ * signed root binding the set to one run. It says nothing about a third party, and the name is
99
+ * chosen so it cannot be read as one — `EXTERNALLY_WITNESSED_EXECUTION_V1` is a different profile
100
+ * and no producer can satisfy it today (no signed-witness format exists), which is why it is not
101
+ * declared here as an empty promise.
102
+ */
103
+ const PROFILE = Object.freeze({
104
+ TRUSTED_EXECUTOR_INTEGRITY_V1: Object.freeze({
105
+ name: 'TRUSTED_EXECUTOR_INTEGRITY_V1',
106
+ authorities: Object.freeze([
107
+ AUTHORITY.ISSUER_GRANT,
108
+ AUTHORITY.EXECUTOR_ATTESTATION,
109
+ AUTHORITY.ONE_RUN_ROOT,
110
+ ]),
111
+ // The receipt is mandatory in every profile and is not listed as an authority because it is
112
+ // not optional anywhere — it gates the whole predicate above the state machine.
113
+ proof_scope: 'TRUSTED_EXECUTOR',
114
+ externally_witnessed: false,
115
+ }),
116
+ });
117
+ const PROFILE_NAMES = Object.freeze(Object.keys(PROFILE));
118
+
70
119
  const sha256pref = (v) =>
71
120
  `sha256:${crypto.createHash('sha256').update(String(v), 'utf8').digest('hex')}`;
72
121
 
@@ -116,12 +165,55 @@ function grantClaims(token) {
116
165
  * @param {{artifact: object, executorKey}} [o.evidenceRoot]
117
166
  * @param {{signed: boolean}} [o.providerReadback]
118
167
  * @param {boolean} o.committed
119
- * @param {string[]} [o.required] authorities this caller demands; default: grant + attestation.
168
+ * @param {string} [o.profile] a CLOSED profile name (see PROFILE). Its authority set cannot be
169
+ * edited by the caller, and only a profile can reach AUTHORIZED_AND_COMMITTED.
170
+ * @param {string[]} [o.required] a CUSTOM authority set. Legal, and it can never produce the
171
+ * global success token — a satisfied custom set reads CUSTOM_REQUIREMENTS_SATISFIED.
120
172
  */
121
173
  function verifiedExecutionBinding(o = {}) {
122
- const required = new Set(Array.isArray(o.required) && o.required.length
123
- ? o.required
124
- : [AUTHORITY.ISSUER_GRANT, AUTHORITY.EXECUTOR_ATTESTATION]);
174
+ // ── WHICH SET, AND WHO CHOSE IT ─────────────────────────────────────────────────────────
175
+ //
176
+ // A profile and a custom set are different KINDS of question, so they are answered separately
177
+ // and never merged. Passing both is refused rather than resolved by precedence: a caller that
178
+ // names a profile and then also lists authorities is asking two things at once, and picking one
179
+ // silently is how a caller ends up believing it got the other.
180
+ const profileName = typeof o.profile === 'string' && o.profile ? o.profile : null;
181
+ const customRequired = Array.isArray(o.required) && o.required.length ? o.required : null;
182
+ if (profileName && customRequired) {
183
+ return {
184
+ v: BINDING_V,
185
+ authorized_and_committed: false,
186
+ requirements_satisfied: false,
187
+ profile: null,
188
+ proof_scope: null,
189
+ externally_witnessed: false,
190
+ state: STATE.UNAUTHORIZED,
191
+ authorities: {},
192
+ shortfalls: ['profile: both `profile` and `required` were supplied — a closed profile\'s '
193
+ + 'authority set is not editable, so this asks two different questions at once'],
194
+ does_not_prove: [],
195
+ };
196
+ }
197
+ if (profileName && !Object.prototype.hasOwnProperty.call(PROFILE, profileName)) {
198
+ // FAIL CLOSED on an unknown profile. Falling back to a default would let a typo — or a caller
199
+ // written against a future version — silently receive a weaker check than it named.
200
+ return {
201
+ v: BINDING_V,
202
+ authorized_and_committed: false,
203
+ requirements_satisfied: false,
204
+ profile: null,
205
+ proof_scope: null,
206
+ externally_witnessed: false,
207
+ state: STATE.UNAUTHORIZED,
208
+ authorities: {},
209
+ shortfalls: [`profile: unknown assurance profile "${profileName}"; known: ${PROFILE_NAMES.join(', ')}`],
210
+ does_not_prove: [],
211
+ };
212
+ }
213
+ const profile = profileName ? PROFILE[profileName] : null;
214
+ const required = new Set(profile
215
+ ? profile.authorities
216
+ : (customRequired || [AUTHORITY.ISSUER_GRANT, AUTHORITY.EXECUTOR_ATTESTATION]));
125
217
  const shortfalls = [];
126
218
  const authorities = {};
127
219
  const note = (name, ok, detail) => {
@@ -225,9 +317,31 @@ function verifiedExecutionBinding(o = {}) {
225
317
  if (!er || !er.artifact) {
226
318
  note(AUTHORITY.ONE_RUN_ROOT, false,
227
319
  'no cr.evidence.root.v1 was supplied, so these bytes are not shown to be one run');
320
+ } else if (typeof verifyEvidenceRootBinding !== 'function') {
321
+ // ── A MISSING VERIFIER IS A REFUSAL, NEVER A CRASH ───────────────────────────────────
322
+ //
323
+ // MEASURED on a consumer: agent-guard vendors an OLDER `verify-evidence.js` that does not
324
+ // export `verifyEvidenceRootBinding` (true on its HEAD too), so this line threw a TypeError
325
+ // the moment anyone passed an `evidenceRoot`. Latent there — that guard holds no root — and
326
+ // the SHAPE is what matters: a crash is not an answer, and a caller cannot tell "the core
327
+ // blew up" from "the core is unavailable" from "the binding failed".
328
+ //
329
+ // Every OTHER dependency this file has is already used unconditionally, so this is the one
330
+ // place a mixed vendor pin can leave a hole. Refusing here is the fail-closed answer, and it
331
+ // names the cause rather than reporting a generic unbound root.
332
+ note(AUTHORITY.ONE_RUN_ROOT, false,
333
+ 'the evidence-root verifier is not available in this build (the vendored verify-evidence.js '
334
+ + 'does not export verifyEvidenceRootBinding) — the root was NOT checked, and an unchecked '
335
+ + 'root is refused rather than reported as unbound');
228
336
  } else {
229
- const r = verifyEvidenceRootBinding(er.artifact, { executorKey: er.executorKey, sidecars: er.sidecars });
230
- note(AUTHORITY.ONE_RUN_ROOT, r.ok === true, r.ok ? 'bound' : (r.failures[0] || 'unbound'));
337
+ let r;
338
+ try {
339
+ r = verifyEvidenceRootBinding(er.artifact, { executorKey: er.executorKey, sidecars: er.sidecars });
340
+ } catch (err) {
341
+ r = { ok: false, failures: [`the evidence-root verifier threw: ${(err && err.message) || 'error'}`] };
342
+ }
343
+ note(AUTHORITY.ONE_RUN_ROOT, r.ok === true,
344
+ r.ok ? 'bound' : ((r.failures && r.failures[0]) || 'unbound'));
231
345
  }
232
346
 
233
347
  // ── 4. THE PROVIDER WITNESS ────────────────────────────────────────────────────────────
@@ -273,11 +387,65 @@ function verifiedExecutionBinding(o = {}) {
273
387
  // Left as-is deliberately: making a bare boolean insufficient would change the input shape of
274
388
  // all five consumers at once, and that belongs with the closed-profile work (1465), not
275
389
  // half-done in a round that would leave them broken. The gap is named, not narrowed in silence.
276
- const receiptOk = !!(o.receipt && o.receipt.verified === true);
277
- const receiptAsserted = receiptOk && !(o.receipt.token && (o.receipt.keyring || o.receipt.publicKey));
278
- if (!receiptOk) shortfalls.unshift('receipt: the decision receipt did not verify');
390
+ // ── THE DECISION RECEIPT, VERIFIED HERE WHEN IT CAN BE ─────────────────────────────────
391
+ //
392
+ // ── THE FOURTH CALLER-BOOLEAN THIS REPOSITORY HAS MET ──────────────────────────────────
393
+ //
394
+ // providerReadback: { signed: true } believed because it was set (closed)
395
+ // attestation: { present: true } believed because it was set (closed)
396
+ // call_hash present == "tool-call bound" (pre-empted)
397
+ // receipt: { verified: true } believed because it was set (this)
398
+ //
399
+ // `receipt.verified` is the CALLER's word. With no token and no keyring, nothing was checked and
400
+ // this function had no way to check it — and a caller that simply set the flag reached a
401
+ // satisfied verdict. `receipt_caller_asserted` recorded it, which is honest reporting and not a
402
+ // gate: a field nobody branches on does not stop anything.
403
+ //
404
+ // Now: when a token AND a key source are supplied, THIS function verifies the receipt. When they
405
+ // are not, the assertion is accepted only for a CUSTOM aggregation — where the caller owns its
406
+ // own question — and can never reach AUTHORIZED_AND_COMMITTED, which is the word every consumer
407
+ // reads as the answer.
408
+ const rc = o.receipt || {};
409
+ let receiptOk = false;
410
+ let receiptAsserted = false;
411
+ let receiptDetail = 'the decision receipt did not verify';
412
+ const keySource = rc.keyring || rc.publicKey;
413
+ if (typeof rc.token === 'string' && rc.token.length > 0 && keySource) {
414
+ let rv;
415
+ try {
416
+ rv = verifyReceipt(rc.token, {
417
+ ctx: { ...(rc.keyring ? { keyring: rc.keyring } : { publicKey: rc.publicKey }),
418
+ expectedKid: rc.expectedKid === undefined ? null : rc.expectedKid },
419
+ ...(Number.isFinite(rc.now) ? { now: rc.now } : {}),
420
+ });
421
+ } catch (err) {
422
+ rv = { valid: false, status: 'VERIFIER_ERROR', reason: (err && err.message) || 'error' };
423
+ }
424
+ receiptOk = rv.valid === true;
425
+ if (!receiptOk) receiptDetail = `the decision receipt did not verify (${rv.status}: ${rv.reason})`;
426
+ } else if (rc.verified === true) {
427
+ // Accepted, and MARKED. The state machine below refuses to hand this the global name.
428
+ receiptOk = true;
429
+ receiptAsserted = true;
430
+ } else if (rc.token || keySource) {
431
+ receiptDetail = 'the receipt was supplied without '
432
+ + `${rc.token ? 'a keyring or public key' : 'its token'}, so it could not be verified here`;
433
+ } else {
434
+ receiptDetail = 'no decision receipt was supplied';
435
+ }
436
+ if (!receiptOk) shortfalls.unshift(`receipt: ${receiptDetail}`);
437
+ if (receiptAsserted) {
438
+ shortfalls.push('decision_receipt: `verified` was taken on the caller\'s word — no token and '
439
+ + 'no keyring were supplied, so nothing was checked here');
440
+ }
279
441
 
280
- let state = STATE.AUTHORIZED_AND_COMMITTED;
442
+ // THE NAME OF SUCCESS DEPENDS ON WHO CHOSE THE SET, not on how much of it passed. A custom
443
+ // aggregation that meets everything it asked for is satisfied — and says so in its own words.
444
+ // A CALLER-ASSERTED RECEIPT CAN NEVER BE THE GLOBAL CLAIM. It is not downgraded to a failure —
445
+ // a custom aggregation may legitimately own that determination — but the strongest word in the
446
+ // vocabulary is not available to a run whose receipt nobody verified.
447
+ let state = (profile && !receiptAsserted)
448
+ ? STATE.AUTHORIZED_AND_COMMITTED : STATE.CUSTOM_REQUIREMENTS_SATISFIED;
281
449
  if (!receiptOk || (required.has(AUTHORITY.ISSUER_GRANT) && !grantOk)) state = STATE.UNAUTHORIZED;
282
450
  else if (required.has(AUTHORITY.EXECUTOR_ATTESTATION) && !attOk) state = STATE.COMMIT_UNPROVEN;
283
451
  else if (required.has(AUTHORITY.ONE_RUN_ROOT) && !authorities[AUTHORITY.ONE_RUN_ROOT].ok) {
@@ -285,11 +453,31 @@ function verifiedExecutionBinding(o = {}) {
285
453
  } else if (required.has(AUTHORITY.PROVIDER_WITNESS) && !authorities[AUTHORITY.PROVIDER_WITNESS].ok) {
286
454
  state = STATE.RECORDED_UNWITNESSED;
287
455
  } else if (!committed) state = STATE.NOT_COMMITTED;
456
+ // The default set (no profile, no custom list) is the historical grant+attestation pair. It is
457
+ // an implicit choice by the caller, not a profile, so it lands in the custom lane too.
458
+
288
459
 
289
460
  return {
290
461
  v: BINDING_V,
291
462
  authorized_and_committed: state === STATE.AUTHORIZED_AND_COMMITTED,
463
+ /**
464
+ * Did the set THIS CALLER ASKED FOR pass? True for a satisfied profile and for a satisfied
465
+ * custom aggregation alike.
466
+ *
467
+ * Separated from `authorized_and_committed` on purpose. A caller that only wants to know
468
+ * whether its own question was answered should not have to string-compare a state name, and
469
+ * — more importantly — should not be tempted to read the global claim because it was the only
470
+ * boolean available. That temptation is how `required: ['issuer_grant']` came to mean
471
+ * "authorized and committed" in the first place.
472
+ */
473
+ requirements_satisfied: state === STATE.AUTHORIZED_AND_COMMITTED
474
+ || state === STATE.CUSTOM_REQUIREMENTS_SATISFIED,
292
475
  state,
476
+ /** Which closed profile answered this, or null when the caller aggregated its own set. */
477
+ profile: profile ? profile.name : null,
478
+ /** Stated on every result so a reader never has to infer it from the state name. */
479
+ proof_scope: profile ? profile.proof_scope : null,
480
+ externally_witnessed: profile ? profile.externally_witnessed : false,
293
481
  authorities,
294
482
  shortfalls,
295
483
  /** True when `receipt.verified` was taken on the caller's word rather than established here. */
@@ -313,4 +501,7 @@ function verifiedExecutionBinding(o = {}) {
313
501
  };
314
502
  }
315
503
 
316
- module.exports = { verifiedExecutionBinding, STATE, AUTHORITY, BINDING_V, grantClaims, attestationClaims };
504
+ module.exports = {
505
+ verifiedExecutionBinding, STATE, AUTHORITY, PROFILE, PROFILE_NAMES, BINDING_V,
506
+ grantClaims, attestationClaims,
507
+ };
@@ -36,8 +36,18 @@ const crypto = require('node:crypto');
36
36
  const { verifyReceipt, keyringFromDocument } = require('./verify.js');
37
37
  const { verifyExecutionGrant } = require('./verify-grant.js');
38
38
  const { verifyProveTranscript } = require('./verify-prove-transcript.js');
39
+ const {
40
+ ROOT_V, SLOTS, SLOT_NAMES, digestToken, verifyEvidenceRoot, canonicalJson,
41
+ } = require('./evidence-root.js');
39
42
 
40
43
  const CORRELATION_V = 'cr.exec.correlation.v1';
44
+
45
+ /**
46
+ * The version four consumers must agree on. `verifyEvidenceRootBinding` returns it, so "prove,
47
+ * conformance, the guard and the provider all ran the same library" is something a report can
48
+ * SHOW rather than assert. Bump it when a check is added, removed or changed in meaning.
49
+ */
50
+ const LIBRARY_VERSION = 'cr.evidence-verifier.1';
41
51
  const US = '\x1f';
42
52
 
43
53
  /** The slots this verifier knows how to authenticate. */
@@ -227,7 +237,169 @@ function verifyEvidenceEnvelope(artifact, o = {}) {
227
237
  return { ok: failures.length === 0, slots, failures };
228
238
  }
229
239
 
240
+ /**
241
+ * THE ROOT CHECK — is this set of tokens ONE run?
242
+ *
243
+ * Ten questions, in the order a reader would ask them. Each is answered against something the
244
+ * producer signed, never against a value copied out of the thing being checked.
245
+ *
246
+ * 1 the root's own signature verifies against the executor key
247
+ * 2 every mandatory slot is present in the root (absence is refused, not skipped)
248
+ * 3 every token PRESENT in the envelope has the EXACT byte digest the root recorded
249
+ * 4 every token the root records is present to be checked, or named as unavailable
250
+ * 5 the grant's claims match the root's claims (grant_id, scope, policy)
251
+ * 6 grant.receipt_hash === sha256(the chain_receipt bytes actually carried)
252
+ * 7 grant_id === the consumed jti === the attested jti
253
+ * 8 run_id === the transcript's run_id === the correlation's scope binding
254
+ * 9 the outer artifact's summaries agree with what the tokens say
255
+ * 10 the verifier reports its own version, so four consumers can be shown to run one library
256
+ *
257
+ * @param {object} artifact
258
+ * @param {object} o
259
+ * @param {import('crypto').KeyObject} o.executorKey the key the root and correlation are signed with
260
+ * @param {object} [o.sidecars] tokens the artifact does not carry but the caller holds, by slot
261
+ * name (e.g. provider_readback bytes, atomic_attestation token)
262
+ */
263
+ function verifyEvidenceRootBinding(artifact, o = {}) {
264
+ const failures = [];
265
+ const checks = [];
266
+ const note = (id, ok, detail) => { checks.push({ id, ok, detail }); if (!ok) failures.push(detail); };
267
+
268
+ const root = artifact && artifact.evidence_root;
269
+ if (!root) {
270
+ return {
271
+ ok: false,
272
+ present: false,
273
+ library: LIBRARY_VERSION,
274
+ checks: [],
275
+ failures: ['cross_run_collage: the artifact carries no cr.evidence.root.v1, so nothing binds '
276
+ + 'its tokens to ONE run'],
277
+ };
278
+ }
279
+
280
+ // 1 — the root's own signature.
281
+ const rv = verifyEvidenceRoot(root, o.executorKey);
282
+ note('root_signature', rv.valid,
283
+ rv.valid ? 'the evidence root is signed by the executor key'
284
+ : `the evidence root signature does not verify (${rv.status}: ${rv.reason})`);
285
+ // Everything below reads the root. A root that does not verify is not a source of truth about
286
+ // anything, so the remaining checks are not run rather than run against unsigned values.
287
+ if (!rv.valid) return { ok: false, present: true, library: LIBRARY_VERSION, checks, failures };
288
+
289
+ // The tokens as they travel, by slot. Sidecars are tokens the artifact does not republish but
290
+ // the caller holds — the provider readback is one, and binding it is what stops a readback from
291
+ // another run being paired with this artifact.
292
+ const carried = {
293
+ chain_receipt: artifact.issuance && artifact.issuance.chain_receipt,
294
+ execution_grant: artifact.issuance && artifact.issuance.execution_grant,
295
+ transcript_token: artifact.transcript_token,
296
+ correlation: artifact.correlation || null,
297
+ atomic_attestation: null,
298
+ provider_readback: null,
299
+ ...(o.sidecars || {}),
300
+ };
301
+
302
+ // 2 — mandatory slots. A root that omits a token would make deletion look like "not applicable".
303
+ for (const name of SLOT_NAMES) {
304
+ if (!SLOTS[name].mandatory) continue;
305
+ note(`root_slot_${name}`, root.artifact_digests && root.artifact_digests[name] != null,
306
+ `the root records no digest for the mandatory ${name}`);
307
+ }
308
+
309
+ // 3 & 4 — EXACT BYTES. This is the check the collage fails: a substituted token is authentic and
310
+ // has different bytes, so its digest cannot match whatever it says inside.
311
+ for (const name of SLOT_NAMES) {
312
+ const want = root.artifact_digests ? root.artifact_digests[name] : null;
313
+ const got = digestToken(carried[name]);
314
+ if (want == null && got == null) continue;
315
+ if (want == null) {
316
+ note(`digest_${name}`, false,
317
+ `the envelope carries a ${name} the root does not account for — an extra token is not evidence`);
318
+ continue;
319
+ }
320
+ if (got == null) {
321
+ // Not a failure for an optional slot the caller simply did not supply: it is UNCHECKED, and
322
+ // saying so beats grading a token nobody looked at.
323
+ note(`digest_${name}`, !SLOTS[name].mandatory,
324
+ `the root records a ${name} digest but no such token was supplied to check it`);
325
+ continue;
326
+ }
327
+ note(`digest_${name}`, got === want,
328
+ got === want ? `${name} bytes match the root`
329
+ : `${name} does not match the root's digest — these bytes were not emitted by run ${root.run_id}`);
330
+ }
331
+
332
+ // 5 — the grant's own claims vs the root's.
333
+ const grantBody = (() => {
334
+ const t = carried.execution_grant;
335
+ if (typeof t !== 'string') return null;
336
+ try { return JSON.parse(Buffer.from(t.split('.')[0], 'base64url').toString('utf8')); } catch (_) { return null; }
337
+ })();
338
+ if (grantBody) {
339
+ const gid = grantBody.grant_id || grantBody.jti || null;
340
+ note('claim_grant_id', root.grant_id == null || root.grant_id === gid,
341
+ `the root names grant ${root.grant_id} but the grant it carries is ${gid}`);
342
+ const scope = grantBody.after_payload_hash || grantBody.scope_hash || null;
343
+ note('claim_scope_hash', root.scope_hash == null || root.scope_hash === scope,
344
+ `the root names scope ${root.scope_hash} but the grant scopes ${scope}`);
345
+ note('claim_policy_hash', root.policy_hash == null || grantBody.policy_hash == null
346
+ || root.policy_hash === grantBody.policy_hash,
347
+ 'the root and the grant disagree about policy_hash');
348
+
349
+ // 6 — grant → receipt, by the digest of the receipt actually carried, not by a copied string.
350
+ const rh = grantBody.receipt_hash || grantBody.receipt_digest || null;
351
+ if (rh && typeof carried.chain_receipt === 'string') {
352
+ const actual = digestToken(carried.chain_receipt);
353
+ note('grant_binds_receipt', rh === actual,
354
+ `the grant was issued against receipt ${rh}, but the receipt carried here hashes to ${actual}`);
355
+ }
356
+ }
357
+
358
+ // 7 — one grant, through consume and attestation. Read from the continuity block the producer
359
+ // signed into the transcript, re-derived rather than trusted: the identities must agree with the
360
+ // root's grant_id too, or the root and the chain are describing different executions.
361
+ const ids = (artifact.continuity && artifact.continuity.identities) || {};
362
+ if (root.grant_id != null && ids.issued_jti != null) {
363
+ const oneGrant = ids.issued_jti === root.grant_id
364
+ && ids.consumed_jti === root.grant_id
365
+ && ids.attestation_jti === root.grant_id;
366
+ note('identity_chain', oneGrant,
367
+ `the root names grant ${root.grant_id}, the chain records issued ${ids.issued_jti} / `
368
+ + `consumed ${ids.consumed_jti} / attested ${ids.attestation_jti}`);
369
+ }
370
+
371
+ // 8 — one run, through the transcript and the correlation.
372
+ note('run_id_artifact', artifact.run_id === root.run_id,
373
+ `the artifact says run ${artifact.run_id}, the root says ${root.run_id}`);
374
+ if (carried.correlation && root.scope_hash != null) {
375
+ note('run_id_correlation', carried.correlation.scope_hash === root.scope_hash,
376
+ `the correlation binds scope ${carried.correlation.scope_hash}, the root ${root.scope_hash}`);
377
+ }
378
+ if (root.contract_commit != null && carried.correlation) {
379
+ note('contract_commit', carried.correlation.contract_commit === root.contract_commit,
380
+ `the correlation names commit ${carried.correlation.contract_commit}, the root ${root.contract_commit}`);
381
+ }
382
+
383
+ // 9 — the outer summary vs the tokens. The artifact is a wrapper; a wrapper that disagrees with
384
+ // what it wraps is the thing that is wrong.
385
+ const tv = typeof carried.transcript_token === 'string'
386
+ ? verifyProveTranscript(carried.transcript_token, { keyring: null, publicKey: o.executorKey })
387
+ : null;
388
+ if (tv && tv.valid && tv.payload) {
389
+ const claimed = tv.payload.run_id || tv.payload.deployment_id || null;
390
+ if (claimed && tv.payload.run_id) {
391
+ note('transcript_run_id', tv.payload.run_id === root.run_id,
392
+ `the signed transcript is run ${tv.payload.run_id}, the root says ${root.run_id}`);
393
+ }
394
+ }
395
+
396
+ return { ok: failures.length === 0, present: true, library: LIBRARY_VERSION, checks, failures };
397
+ }
398
+
230
399
  module.exports = {
400
+ verifyEvidenceRootBinding,
401
+ LIBRARY_VERSION,
402
+ ROOT_V,
231
403
  verifyEvidenceEnvelope,
232
404
  verifyCorrelation,
233
405
  verifyAtomicAttestationToken,
@@ -58,6 +58,35 @@ const V2_REQUIRED_STRINGS = Object.freeze([
58
58
  'operation', 'target_uri', 'expected_state_token', 'after_payload_hash',
59
59
  'nonce_hash', 'policy_hash', 'audience_hash', 'not_before', 'expires_at',
60
60
  ]);
61
+ /**
62
+ * RESERVED, AND INERT. Optional v2 fields a grant MAY carry and this verifier reads as NOTHING.
63
+ *
64
+ * ── WHY THEY EXIST BEFORE THE GATE THAT USES THEM ───────────────────────────────────────────
65
+ *
66
+ * The admitted key set is closed: an unknown field is `MALFORMED/unknown_field`, which is the
67
+ * right default and also means a future field cannot be introduced without every deployed
68
+ * verifier refusing the grants that carry it. Reserving the two names now is what keeps that
69
+ * introduction from being a breaking change later.
70
+ *
71
+ * ── WHAT THEY DO NOT DO, WHICH IS THE POINT ─────────────────────────────────────────────────
72
+ *
73
+ * NOTHING. A grant carrying `call_hash` is graded EXACTLY as one without it. There is no check,
74
+ * no comparison, and no `intended` field they bind to.
75
+ *
76
+ * A VERIFIER THAT READS THEIR PRESENCE AS AUTHORIZATION IS WRONG. `call_hash` present does not
77
+ * mean a tool call was bound; `executor_image_digest` present does not mean an executor image was
78
+ * pinned. Nothing signs a promise that the value is true, nothing compares it to anything, and an
79
+ * attacker who can mint a grant can put any value in them. Presence is not proof — it is a slot.
80
+ *
81
+ * They are unsigned-by-default only in the sense that no separate signature covers them: the v2
82
+ * signing input is the canonical JSON of the WHOLE body, so a grant that carries them signs
83
+ * different bytes than one that does not, and neither can be edited into the other. That binds
84
+ * the VALUE to the issuer; it says nothing about whether the value means anything.
85
+ *
86
+ * When a gate for them lands it will be a NEW check with its own negative fixtures, and this
87
+ * comment is what a reader should be shown if anyone claims otherwise before then.
88
+ */
89
+ const V2_RESERVED_INERT = Object.freeze(['call_hash', 'executor_image_digest']);
61
90
  const TARGET_SCHEMES = Object.freeze(['fs', 'git', 'api', 'db', 'registry', 'deploy']);
62
91
  const DEFAULT_FETCH_URL = 'https://app.coderifts.com/api/v1/attestation/public-key';
63
92
 
@@ -177,7 +206,9 @@ function verifyExecutionGrantV2(payload, sigB64, ctx, opts = {}) {
177
206
  if (!Number.isInteger(payload.max_attempts) || payload.max_attempts < 1) {
178
207
  return { valid: false, status: 'MALFORMED', reason: 'bad_max_attempts', payload };
179
208
  }
180
- const allowed = new Set([...V2_REQUIRED_STRINGS, 'max_attempts']);
209
+ // The reserved names are ADMITTED, never inspected. Everything below this line treats a payload
210
+ // carrying them identically to one that does not — verified by test, not by intent.
211
+ const allowed = new Set([...V2_REQUIRED_STRINGS, 'max_attempts', ...V2_RESERVED_INERT]);
181
212
  for (const k of Object.keys(payload)) {
182
213
  if (!allowed.has(k)) return { valid: false, status: 'MALFORMED', reason: 'unknown_field', payload };
183
214
  }
@@ -555,6 +586,8 @@ module.exports = {
555
586
  SIGNING_PREFIX_V2,
556
587
  SIGNED_FIELDS,
557
588
  V1_OPTIONAL_SIGNED_FIELDS,
589
+ V2_REQUIRED_STRINGS,
590
+ V2_RESERVED_INERT,
558
591
  CLOCK_SKEW_LEEWAY_MS,
559
592
  isIssuedInFuture,
560
593
  };
@@ -23,13 +23,13 @@
23
23
  * the command, so it left.
24
24
  *
25
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>]
26
+ * node cli.js <receipt> [--key pub.pem | --keys <url|file>] [--kid <kid>] [--fetch <url>] [--refresh-keys]
27
+ * node cli.js --chain receipts.txt [--key pub.pem | --keys <url|file>] [--kid <kid>] [--fetch <url>] [--refresh-keys]
28
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.
29
+ * Key discovery: with no --key/--keys/--fetch/--refresh-keys, the command loads the
30
+ * vendored snapshot at keys/coderifts-keys.json (offline). Live fetch is opt-in
31
+ * (--refresh-keys or --fetch <url> / --keys <url>). DEFAULT_FETCH_URL is the well-known
32
+ * registry used by those opt-in flags. This library never fetches.
33
33
  * --keys resolves each receipt's key by kid from a registry
34
34
  * ({ keys: [{ kid, public_key_pem, status, valid_from, retired_at }] }); accepts a URL or file.
35
35
  *
@@ -178,6 +178,21 @@ export type EvaluateCasEvidenceOpts = {
178
178
  status?: string;
179
179
  }>;
180
180
  } | null;
181
+ /**
182
+ * The decision receipt's own bytes, and the keyring that signs them.
183
+ *
184
+ * Optional, and their ABSENCE is what it looks like: the core marks the result caller-asserted
185
+ * and says so in its shortfalls. Supplying them moves the receipt from "this guard's own bind
186
+ * step said so" to "the shared predicate verified a signature".
187
+ */
188
+ receipt_token?: string | null;
189
+ receipt_keyring?: {
190
+ keys?: Array<{
191
+ kid?: string;
192
+ public_key_pem?: string;
193
+ status?: string;
194
+ }>;
195
+ } | null;
181
196
  /** Clock injection for grant expiry, tests only. */
182
197
  now?: number;
183
198
  /** Strict-only tightening of derived.authorized_and_committed. Absent = 9.0.0 formula. */
@@ -1 +1 @@
1
- {"version":3,"file":"cas-attestation.d.ts","sourceRoot":"","sources":["../../src/cas-attestation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAGH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAC1D,OAAO,KAAK,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAErF,OAAO,KAAK,EAAE,yBAAyB,EAAE,mBAAmB,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAE3G,uEAAuE;AACvE,eAAO,MAAM,oBAAoB,EAAG,oBAA6B,CAAC;AAElE;;;GAGG;AACH,MAAM,MAAM,oBAAoB,GAAG;IACjC,uEAAuE;IACvE,iCAAiC,EAAE,IAAI,CAAC;IACxC,mGAAmG;IACnG,mDAAmD,EAAE,IAAI,CAAC;IAC1D,qGAAqG;IACrG,sCAAsC,EAAE,IAAI,CAAC;IAC7C,4FAA4F;IAC5F,qDAAqD,EAAE,IAAI,CAAC;IAC5D,yFAAyF;IACzF,iCAAiC,EAAE,IAAI,CAAC;IACxC,yFAAyF;IACzF,oCAAoC,EAAE,IAAI,CAAC;CAC5C,CAAC;AAWF,kFAAkF;AAClF,MAAM,MAAM,iBAAiB,GACzB;IACE,MAAM,EAAE,WAAW,CAAC;IACpB,SAAS,EAAE,IAAI,CAAC;IAChB,aAAa,EAAE,YAAY,CAAC;CAC7B,GACD;IACE,MAAM,EAAE,SAAS,CAAC;IAClB,SAAS,EAAE,KAAK,CAAC;IACjB,MAAM,EAAE,qBAAqB,CAAC;IAC9B,cAAc,EAAE,YAAY,CAAC;IAC7B,aAAa,EAAE,YAAY,GAAG,IAAI,CAAC;CACpC,GACD;IACE,MAAM,EAAE,0BAA0B,CAAC;IACnC,SAAS,EAAE,IAAI,CAAC;IAChB,MAAM,EAAE,qBAAqB,CAAC;IAC9B,cAAc,EAAE,YAAY,CAAC;IAC7B,iBAAiB,EAAE,YAAY,GAAG,IAAI,CAAC;CACxC,GACD;IACE,MAAM,EAAE,eAAe,CAAC;IACxB;;;OAGG;IACH,SAAS,EAAE,SAAS,CAAC;IACrB,MAAM,EAAE,mBAAmB,CAAC;IAC5B,cAAc,EAAE,YAAY,CAAC;IAC7B,cAAc,EAAE,YAAY,GAAG,IAAI,CAAC;CACrC,CAAC;AAEN;;;;GAIG;AACH,MAAM,MAAM,cAAc,GAAG;IAC3B,gBAAgB,EAAE,OAAO,oBAAoB,CAAC;IAC9C,UAAU,EAAE;QACV,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;QAC3B,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;QACzB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;QACzB,qBAAqB,EAAE,mBAAmB,CAAC;QAC3C,gBAAgB,EAAE,OAAO,CAAC;KAC3B,CAAC;IACF,GAAG,EAAE,iBAAiB,CAAC;IACvB,OAAO,EAAE;QACP;;;WAGG;QACH,wBAAwB,EAAE,OAAO,CAAC;QAClC;;;WAGG;QACH,sCAAsC,CAAC,EAAE,OAAO,CAAC;QACjD,iEAAiE;QACjE,SAAS,EAAE,OAAO,CAAC;QACnB,qDAAqD;QACrD,mBAAmB,EAAE,OAAO,CAAC;QAC7B,oCAAoC;QACpC,OAAO,EAAE,OAAO,CAAC;QACjB;;;WAGG;QACH,aAAa,EAAE,OAAO,CAAC;KACxB,CAAC;IACF;;;;;OAKG;IACH,YAAY,EAAE,WAAW,CAAC;IAC1B,MAAM,EAAE,oBAAoB,CAAC;CAC9B,CAAC;AAEF,uEAAuE;AACvE,MAAM,MAAM,gBAAgB,GAAG,mBAAmB,GAAG,cAAc,GAAG,QAAQ,CAAC;AAE/E,MAAM,MAAM,WAAW,GAAG;IACxB,KAAK,EAAE,gBAAgB,CAAC;IACxB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B,CAAC;AAEF,MAAM,MAAM,yBAAyB,GAAG;IACtC,+EAA+E;IAC/E,QAAQ,EAAE,mBAAmB,CAAC;IAC9B;;;;;;;;;;;OAWG;IACH,aAAa,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,KAAK,CAAC;YAAE,GAAG,CAAC,EAAE,MAAM,CAAC;YAAC,cAAc,CAAC,EAAE,MAAM,CAAC;YAAC,MAAM,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC,CAAA;KAAE,GAAG,IAAI,CAAC;CACrG,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG;IACpC,QAAQ,CAAC,EAAE,mBAAmB,GAAG,IAAI,CAAC;IACtC,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,YAAY,CAAC,EAAE;QACb,GAAG,CAAC,EAAE,MAAM,CAAC;QACb,UAAU,CAAC,EAAE,MAAM,CAAC;QACpB,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,cAAc,CAAC,EAAE,MAAM,CAAC;KACzB,GAAG,IAAI,CAAC;IACT;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,aAAa,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,KAAK,CAAC;YAAE,GAAG,CAAC,EAAE,MAAM,CAAC;YAAC,cAAc,CAAC,EAAE,MAAM,CAAC;YAAC,MAAM,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC,CAAA;KAAE,GAAG,IAAI,CAAC;IACpG,oDAAoD;IACpD,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,0FAA0F;IAC1F,OAAO,CAAC,EAAE,kBAAkB,GAAG,kBAAkB,CAAC;CACnD,CAAC;AAwBF,mFAAmF;AACnF,MAAM,MAAM,mBAAmB,GAAG;IAChC,uEAAuE;IACvE,aAAa,EAAE,OAAO,CAAC;IACvB,2EAA2E;IAC3E,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB,CAAC;AAEF;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAChC,OAAO,EAAE,uBAAuB,CAAC,eAAe,CAAC,EACjD,GAAG,CAAC,EAAE,MAAM,GACX,mBAAmB,CA0CrB;AAED,6FAA6F;AAC7F,MAAM,MAAM,WAAW,GAAG,0BAA0B,GAAG,0BAA0B,GAAG,wCAAwC,CAAC;AAC7H,eAAO,MAAM,uBAAuB,EAAG,yBAAkC,CAAC;AAE1E,MAAM,MAAM,uBAAuB,GAAG;IACpC,YAAY,EAAE,WAAW,CAAC;IAC1B,sBAAsB,CAAC,EAAE,OAAO,uBAAuB,CAAC;CACzD,CAAC;AA8CF;;;;;GAKG;AACH,wBAAgB,uBAAuB,CACrC,OAAO,EAAE,OAAO,EAChB,QAAQ,EAAE,WAAW,GAAG,SAAS,EACjC,IAAI,GAAE,uBAA4B,GACjC,uBAAuB,CAWzB;AAkBD,qFAAqF;AACrF,wBAAgB,+BAA+B,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAY/E;AAiBD;;;GAGG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,OAAO,EAChB,IAAI,GAAE,uBAA4B,GACjC,WAAW,CAsDb;AAED,2DAA2D;AAC3D,wBAAgB,qBAAqB,CAAC,CAAC,EAAE,OAAO,GAAG,CAAC,IAAI,mBAAmB,CAQ1E;AAED,iFAAiF;AACjF,wBAAgB,2BAA2B,CAAC,CAAC,EAAE,OAAO,GAAG,CAAC,IAAI,yBAAyB,CAAC,OAAO,CAAC,CAuB/F;AA0CD;;;;;GAKG;AACH,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,mBAAmB,EAC1B,OAAO,EAAE,yBAAyB,CAAC,OAAO,CAAC,EAC3C,IAAI,GAAE,uBAA4B,GACjC,cAAc,CAiIhB"}
1
+ {"version":3,"file":"cas-attestation.d.ts","sourceRoot":"","sources":["../../src/cas-attestation.ts"],"names":[],"mappings":"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"}
@@ -387,6 +387,18 @@ export function buildCasAttestation(proof, outcome, opts = {}) {
387
387
  binding = { state: 'UNAUTHORIZED', shortfalls: ['the vendored core predicate could not be loaded'] };
388
388
  }
389
389
  else {
390
+ // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires
391
+ const { createPublicKey: createPk } = require('node:crypto');
392
+ const receiptKeyring = opts.receipt_keyring && Array.isArray(opts.receipt_keyring.keys)
393
+ ? new Map(opts.receipt_keyring.keys
394
+ .filter((k) => k && k.kid && k.public_key_pem)
395
+ .map((k) => [k.kid, {
396
+ publicKey: createPk(k.public_key_pem),
397
+ status: k.status || 'active',
398
+ retired_at: null,
399
+ compromised_at: null,
400
+ }]))
401
+ : null;
390
402
  const keyring = opts.grant_keyring && Array.isArray(opts.grant_keyring.keys)
391
403
  ? new Map(opts.grant_keyring.keys
392
404
  .filter((k) => k && typeof k.kid === 'string' && typeof k.public_key_pem === 'string')
@@ -399,7 +411,24 @@ export function buildCasAttestation(proof, outcome, opts = {}) {
399
411
  }]))
400
412
  : null;
401
413
  const r = core.verifiedExecutionBinding({
402
- receipt: { verified: receipt_verified },
414
+ // ── THE RECEIPT, HANDED OVER WHEN THE CALLER HAS IT ─────────────────────────────
415
+ //
416
+ // `proof.receipt` records the guard's OWN bind step — it carries no token, by design. So
417
+ // when a caller supplies the receipt bytes and a keyring, the core verifies them; when it
418
+ // does not, `verified` is still passed and the core marks the result caller-asserted.
419
+ //
420
+ // That marking is not cosmetic any more: a caller-asserted receipt can no longer reach the
421
+ // global success token. It does not change THIS surface's answer, because this surface
422
+ // asks a CUSTOM authority set and reads `requirements_satisfied` — but the distinction is
423
+ // now visible in the state rather than only in a boolean nobody branched on.
424
+ receipt: opts.receipt_token
425
+ ? {
426
+ token: opts.receipt_token,
427
+ keyring: receiptKeyring,
428
+ expectedKid: null,
429
+ ...(Number.isFinite(opts.now) ? { now: opts.now } : {}),
430
+ }
431
+ : { verified: receipt_verified },
403
432
  grant: { token: grantToken, keyring, expectedKid: null, ...(Number.isFinite(opts.now) ? { now: opts.now } : {}) },
404
433
  attestation: {
405
434
  token: attToken,
@@ -413,7 +442,16 @@ export function buildCasAttestation(proof, outcome, opts = {}) {
413
442
  required: ['issuer_grant', 'executor_attestation'],
414
443
  });
415
444
  binding = { state: r.state, shortfalls: r.shortfalls };
416
- authorized_and_committed = r.authorized_and_committed === true;
445
+ // ── THE FIELD THIS SURFACE IS ENTITLED TO ────────────────────────────────────────
446
+ //
447
+ // This asks a CUSTOM authority set (issuer_grant + executor_attestation), and the core no
448
+ // longer lets a custom set reach `authorized_and_committed` — asking for less must not
449
+ // produce the word every consumer reads as the answer. `requirements_satisfied` is "the set
450
+ // I asked for is met", which is exactly the question this surface asks.
451
+ //
452
+ // MEASURED: reading `authorized_and_committed` after the core change would have made this
453
+ // permanently false, and a guard that refuses everything looks like a guard that works.
454
+ authorized_and_committed = r.requirements_satisfied === true;
417
455
  }
418
456
  }
419
457
  if (opts.profile === 'ENFORCING_ATOMIC') {