@coderifts/agent-guard 17.2.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.
- package/dist/cjs/cas-attestation.d.ts +15 -0
- package/dist/cjs/cas-attestation.d.ts.map +1 -1
- package/dist/cjs/cas-attestation.js +136 -6
- package/dist/cjs/cas-attestation.js.map +1 -1
- package/dist/cjs/vendor/VENDOR.sha256 +29 -9
- package/dist/cjs/vendor/evidence-root.js +188 -0
- package/dist/cjs/vendor/verified-execution-binding.js +507 -0
- package/dist/cjs/vendor/verify-evidence.js +172 -0
- package/dist/cjs/vendor/verify-grant.js +79 -4
- package/dist/cjs/vendor/verify.js +6 -6
- package/dist/esm/cas-attestation.d.ts +15 -0
- package/dist/esm/cas-attestation.d.ts.map +1 -1
- package/dist/esm/cas-attestation.js +136 -6
- package/dist/esm/cas-attestation.js.map +1 -1
- package/dist/esm/vendor/VENDOR.sha256 +29 -9
- package/dist/esm/vendor/evidence-root.js +188 -0
- package/dist/esm/vendor/verified-execution-binding.js +507 -0
- package/dist/esm/vendor/verify-evidence.js +172 -0
- package/dist/esm/vendor/verify-grant.js +79 -4
- package/dist/esm/vendor/verify.js +6 -6
- package/package.json +1 -1
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* verifiedExecutionBinding — the ONE predicate that answers "authorized and committed".
|
|
5
|
+
*
|
|
6
|
+
* ── WHY ONE (1459) ──────────────────────────────────────────────────────────────────────────
|
|
7
|
+
*
|
|
8
|
+
* The same question was being answered in several places with different rules, and the answers
|
|
9
|
+
* disagreed. Measured on the public agent-guard 17.2.0, with a forged-signature grant and a REAL
|
|
10
|
+
* attestation from a trusted executor bound to that grant's jti and scope:
|
|
11
|
+
*
|
|
12
|
+
* ENFORCING_STRICT authorized_and_committed = false
|
|
13
|
+
* ENFORCING_ATOMIC authorized_and_committed = true
|
|
14
|
+
*
|
|
15
|
+
* Two profiles of one product, looking at one set of bytes, reaching opposite conclusions — because
|
|
16
|
+
* the Atomic formula was `receipt_verified && committed && class === 'executor_attested'` and never
|
|
17
|
+
* asked whether the GRANT was signed by anyone. A predicate that is recomputed is a predicate that
|
|
18
|
+
* drifts; this file exists so callers QUOTE the answer instead.
|
|
19
|
+
*
|
|
20
|
+
* ── FOUR AUTHORITIES, AND ONLY THEIR INTERSECTION ───────────────────────────────────────────
|
|
21
|
+
*
|
|
22
|
+
* Each is independently true or not, and each answers a different question. None of them implies
|
|
23
|
+
* another, which is exactly why the intersection — not any one of them — is the success condition:
|
|
24
|
+
*
|
|
25
|
+
* issuer_grant did CodeRifts authorize THIS change? (a signature under a pinned key)
|
|
26
|
+
* executor_attestation did the executor commit THAT grant? (a signature over the grant's ids)
|
|
27
|
+
* one_run_root are these bytes from ONE run? (cr.evidence.root.v1 digests)
|
|
28
|
+
* provider_witness did the provider record it? (a readback — UNSIGNED by nature)
|
|
29
|
+
*
|
|
30
|
+
* ── SHORTFALLS ARE NAMED, NOT FOLDED INTO `false` ───────────────────────────────────────────
|
|
31
|
+
*
|
|
32
|
+
* "Not authorized" and "authorized but the commit is unproven" are different facts with different
|
|
33
|
+
* remedies, and a boolean loses that. Every shortfall gets a state a human can act on, and the
|
|
34
|
+
* states are ORDERED by severity so the returned one is the most serious thing that is wrong.
|
|
35
|
+
*
|
|
36
|
+
* WHAT THIS DOES NOT DO. It reaches no network and holds no keys: every input is supplied by the
|
|
37
|
+
* caller, already-verified or verifiable, and the four authorities are recomputed here from those
|
|
38
|
+
* inputs rather than taken as claims. It also cannot see a lying executor — the root closes
|
|
39
|
+
* third-party splicing, not an executor misreporting its own run.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
const crypto = require('node:crypto');
|
|
43
|
+
|
|
44
|
+
const { verifyExecutionGrant } = require('./verify-grant.js');
|
|
45
|
+
const { verifyEvidenceRootBinding } = require('./verify-evidence.js');
|
|
46
|
+
const { verifyReceipt } = require('./verify.js');
|
|
47
|
+
|
|
48
|
+
const BINDING_V = 'cr.verified-execution-binding.v1';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Ordered most-severe first. The returned `state` is the FIRST unmet one, so a caller that renders
|
|
52
|
+
* a single line renders the thing that most needs fixing.
|
|
53
|
+
*/
|
|
54
|
+
const STATE = Object.freeze({
|
|
55
|
+
UNAUTHORIZED: 'UNAUTHORIZED',
|
|
56
|
+
COMMIT_UNPROVEN: 'COMMIT_UNPROVEN',
|
|
57
|
+
ONE_RUN_UNPROVEN: 'ONE_RUN_UNPROVEN',
|
|
58
|
+
RECORDED_UNWITNESSED: 'RECORDED_UNWITNESSED',
|
|
59
|
+
NOT_COMMITTED: 'NOT_COMMITTED',
|
|
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',
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/** What each authority is allowed to be missing for, so a caller can choose its own strictness. */
|
|
82
|
+
const AUTHORITY = Object.freeze({
|
|
83
|
+
ISSUER_GRANT: 'issuer_grant',
|
|
84
|
+
EXECUTOR_ATTESTATION: 'executor_attestation',
|
|
85
|
+
ONE_RUN_ROOT: 'one_run_root',
|
|
86
|
+
PROVIDER_WITNESS: 'provider_witness',
|
|
87
|
+
});
|
|
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
|
+
|
|
119
|
+
const sha256pref = (v) =>
|
|
120
|
+
`sha256:${crypto.createHash('sha256').update(String(v), 'utf8').digest('hex')}`;
|
|
121
|
+
|
|
122
|
+
const b64json = (seg) => {
|
|
123
|
+
try { return JSON.parse(Buffer.from(seg, 'base64url').toString('utf8')); } catch (_) { return null; }
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/** The four fields an attestation binds, read from its own signed preimage. */
|
|
127
|
+
function attestationClaims(token) {
|
|
128
|
+
if (typeof token !== 'string') return null;
|
|
129
|
+
const seg = token.split('|');
|
|
130
|
+
if (seg.length !== 4 || !seg[2]) return null;
|
|
131
|
+
const body = b64json(seg[2]);
|
|
132
|
+
return body && typeof body === 'object' ? body : null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The grant's identity, in one vocabulary across v1 and v2. */
|
|
136
|
+
function grantClaims(token) {
|
|
137
|
+
if (typeof token !== 'string') return null;
|
|
138
|
+
const body = b64json(String(token).split('.')[0]);
|
|
139
|
+
if (!body || typeof body !== 'object') return null;
|
|
140
|
+
return {
|
|
141
|
+
v: body.v,
|
|
142
|
+
jti: body.grant_id || body.jti || null,
|
|
143
|
+
scope_hash: body.after_payload_hash || body.scope_hash || null,
|
|
144
|
+
receipt_hash: body.receipt_hash || body.receipt_digest || null,
|
|
145
|
+
operation: body.operation || null,
|
|
146
|
+
// The rest of what a grant SAYS, so the attestation can be checked against all of it rather
|
|
147
|
+
// than against the two fields that happened to be compared first.
|
|
148
|
+
target: body.target_uri || body.target_id || null,
|
|
149
|
+
tenant_id: body.tenant_id || null,
|
|
150
|
+
executor_id: body.executor_id || null,
|
|
151
|
+
adapter_id: body.adapter_id || null,
|
|
152
|
+
audience: body.audience_hash || body.audience || null,
|
|
153
|
+
policy_hash: body.policy_hash || null,
|
|
154
|
+
state_token: body.expected_state_token || body.state_nonce || null,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* @param {object} o
|
|
160
|
+
* @param {{verified: boolean, token?: string}} o.receipt
|
|
161
|
+
* @param {{token: string, publicKey?, keyring?, intended?: object, now?: number}} o.grant
|
|
162
|
+
* THE EXACT BYTES THE ISSUER SIGNED. Not a grant read back out of a tool result: a caller
|
|
163
|
+
* that lets the executed tool hand back its own authorization has already lost.
|
|
164
|
+
* @param {{token: string, registry?: object, verify?: Function}} [o.attestation]
|
|
165
|
+
* @param {{artifact: object, executorKey}} [o.evidenceRoot]
|
|
166
|
+
* @param {{signed: boolean}} [o.providerReadback]
|
|
167
|
+
* @param {boolean} o.committed
|
|
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.
|
|
172
|
+
*/
|
|
173
|
+
function verifiedExecutionBinding(o = {}) {
|
|
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]));
|
|
217
|
+
const shortfalls = [];
|
|
218
|
+
const authorities = {};
|
|
219
|
+
const note = (name, ok, detail) => {
|
|
220
|
+
authorities[name] = { ok, required: required.has(name), detail };
|
|
221
|
+
if (!ok && required.has(name)) shortfalls.push(`${name}: ${detail}`);
|
|
222
|
+
return ok;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// ── 1. THE ISSUER GRANT ────────────────────────────────────────────────────────────────
|
|
226
|
+
const g = o.grant || {};
|
|
227
|
+
let grantOk = false;
|
|
228
|
+
let gClaims = null;
|
|
229
|
+
if (typeof g.token !== 'string' || g.token.length === 0) {
|
|
230
|
+
note(AUTHORITY.ISSUER_GRANT, false, 'no execution grant was supplied');
|
|
231
|
+
} else if (!g.publicKey && !g.keyring) {
|
|
232
|
+
// FAIL-CLOSED, and named as its own thing: "we had no key" is not "the signature was bad".
|
|
233
|
+
note(AUTHORITY.ISSUER_GRANT, false,
|
|
234
|
+
'no pinned issuer keyring was supplied, so the grant could not be authenticated');
|
|
235
|
+
} else {
|
|
236
|
+
const r = verifyExecutionGrant(g.token, {
|
|
237
|
+
ctx: { publicKey: g.publicKey, keyring: g.keyring, expectedKid: g.expectedKid ?? null },
|
|
238
|
+
...(g.intended ? { intended: g.intended } : {}),
|
|
239
|
+
...(Number.isFinite(g.now) ? { now: g.now } : {}),
|
|
240
|
+
});
|
|
241
|
+
gClaims = grantClaims(g.token);
|
|
242
|
+
grantOk = note(AUTHORITY.ISSUER_GRANT, r.valid === true,
|
|
243
|
+
`${r.status}${r.reason ? `/${r.reason}` : ''}`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── 2. THE EXECUTOR ATTESTATION ────────────────────────────────────────────────────────
|
|
247
|
+
const a = o.attestation || {};
|
|
248
|
+
let attOk = false;
|
|
249
|
+
if (typeof a.token !== 'string' || a.token.length === 0) {
|
|
250
|
+
note(AUTHORITY.EXECUTOR_ATTESTATION, false, 'no executor attestation was supplied');
|
|
251
|
+
} else if (typeof a.verify !== 'function') {
|
|
252
|
+
note(AUTHORITY.EXECUTOR_ATTESTATION, false,
|
|
253
|
+
'no attestation verifier was supplied, so the commit could not be checked');
|
|
254
|
+
} else {
|
|
255
|
+
const r = a.verify(a.token, { registry: a.registry });
|
|
256
|
+
const sigOk = r && r.valid === true;
|
|
257
|
+
// BOUND TO THE VERIFIED GRANT'S OWN IDS — not to ids the caller passed alongside. This is the
|
|
258
|
+
// join that makes the two signatures one statement instead of two unrelated true things.
|
|
259
|
+
const c = attestationClaims(a.token);
|
|
260
|
+
// ── THE ATTESTATION MUST BIND THE SAME EXECUTION, NOT MERELY THE SAME NAMES (1464) ────
|
|
261
|
+
//
|
|
262
|
+
// REPRODUCED before this was written. A correctly-signed grant bound to receipt R1, and a
|
|
263
|
+
// correctly-signed attestation from a trusted executor bound to receipt R2, sharing a
|
|
264
|
+
// grant_jti and a scope_hash — R1 != R2 — read AUTHORIZED_AND_COMMITTED. Both signatures are
|
|
265
|
+
// real; the two documents describe DIFFERENT executions and the join could not tell.
|
|
266
|
+
//
|
|
267
|
+
// `jti` and `scope_hash` are the two fields an attacker controls most cheaply: they are copied
|
|
268
|
+
// FROM the grant into the attestation by whoever assembles the pair. Comparing only those is
|
|
269
|
+
// comparing a value with its own copy. What binds is the receipt each side was issued against,
|
|
270
|
+
// and — the strongest available — the sha256 of the exact grant token bytes.
|
|
271
|
+
const attReceipt = c ? String(c.receipt_digest || '') : '';
|
|
272
|
+
const grantReceipt = gClaims ? String(gClaims.receipt_hash || '') : '';
|
|
273
|
+
const mismatch = (() => {
|
|
274
|
+
if (!sigOk) return `attestation ${r ? r.status : 'unverifiable'}`;
|
|
275
|
+
if (!gClaims) return 'there is no verified grant for the attestation to bind';
|
|
276
|
+
if (String(c.grant_jti || '') !== String(gClaims.jti || '')) {
|
|
277
|
+
return 'the attestation binds a different grant id than the verified grant';
|
|
278
|
+
}
|
|
279
|
+
if (String(c.scope_hash || '') !== String(gClaims.scope_hash || '')) {
|
|
280
|
+
return 'the attestation binds a different scope than the verified grant';
|
|
281
|
+
}
|
|
282
|
+
// THE CROSS-RECEIPT CHECK. Empty on either side is a mismatch: an attestation that names no
|
|
283
|
+
// receipt cannot be shown to be about this authorization, and "unstated" must not read as
|
|
284
|
+
// "the same".
|
|
285
|
+
if (!attReceipt || !grantReceipt || attReceipt !== grantReceipt) {
|
|
286
|
+
return `the grant was issued against receipt ${grantReceipt || '(none)'} and the `
|
|
287
|
+
+ `attestation commits receipt ${attReceipt || '(none)'} — two different executions`;
|
|
288
|
+
}
|
|
289
|
+
// ── WHAT cr.exec.attest.v1 CAN AND CANNOT BE ASKED ──────────────────────────────
|
|
290
|
+
//
|
|
291
|
+
// MEASURED, and it bounds this check rather than the check bounding the format: the
|
|
292
|
+
// attestation body is a CLOSED set — executor_kid, grant_jti, receipt_digest, scope_hash,
|
|
293
|
+
// committed_at, state_nonce, result_digest, meta. Any other key is refused
|
|
294
|
+
// ATTEST_MALFORMED / unknown_field by its own verifier.
|
|
295
|
+
//
|
|
296
|
+
// So target, operation, tenant, executor, adapter, audience and policy CANNOT be
|
|
297
|
+
// cross-checked here: the attestation never states them, and a comparison against a field
|
|
298
|
+
// that cannot exist is not a check — it is a line that always passes. They are named in
|
|
299
|
+
// `does_not_prove` instead, which is the honest place for a binding the format cannot carry.
|
|
300
|
+
//
|
|
301
|
+
// The same is true of the exact grant-token digest: there is no field for it. Binding the
|
|
302
|
+
// grant BYTES rather than its claims would be the tightest join available and it needs a
|
|
303
|
+
// format change (a `grant_token_digest` slot in cr.exec.attest.v2), not a check here.
|
|
304
|
+
//
|
|
305
|
+
// What the format DOES let us bind is the state nonce, and it is bound below.
|
|
306
|
+
if (gClaims.state_token != null && c.state_nonce != null
|
|
307
|
+
&& String(gClaims.state_token) !== String(c.state_nonce)) {
|
|
308
|
+
return 'the grant and the attestation disagree about the state nonce';
|
|
309
|
+
}
|
|
310
|
+
return null;
|
|
311
|
+
})();
|
|
312
|
+
attOk = note(AUTHORITY.EXECUTOR_ATTESTATION, mismatch === null, mismatch || 'bound');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ── 3. ONE RUN ─────────────────────────────────────────────────────────────────────────
|
|
316
|
+
const er = o.evidenceRoot || null;
|
|
317
|
+
if (!er || !er.artifact) {
|
|
318
|
+
note(AUTHORITY.ONE_RUN_ROOT, false,
|
|
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');
|
|
336
|
+
} else {
|
|
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'));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ── 4. THE PROVIDER WITNESS ────────────────────────────────────────────────────────────
|
|
348
|
+
// A readback is an UNSIGNED document by nature. It is carried, not verified, and this authority
|
|
349
|
+
// is false unless a caller states it was witnessed some stronger way — never true by default.
|
|
350
|
+
// ── A CALLER BOOLEAN IS NOT EVIDENCE (1465) ────────────────────────────────────────────
|
|
351
|
+
//
|
|
352
|
+
// REPRODUCED before this was written:
|
|
353
|
+
//
|
|
354
|
+
// required: ['provider_witness'], receipt: {verified: true},
|
|
355
|
+
// providerReadback: {signed: true}, committed: true, NO grant, NO attestation, NO root
|
|
356
|
+
// → AUTHORIZED_AND_COMMITTED, shortfalls: []
|
|
357
|
+
//
|
|
358
|
+
// `signed: true` was a bare boolean the caller wrote, and this function aggregated it into a
|
|
359
|
+
// global success. Nothing was verified; a field named `signed` was believed because it was set.
|
|
360
|
+
//
|
|
361
|
+
// A witness now requires a VERIFIED witness envelope: bytes plus a verifier plus a trust anchor.
|
|
362
|
+
// No such format exists yet (phase D measured that the readback is unsigned by nature), so this
|
|
363
|
+
// authority cannot currently be satisfied at all — and saying that plainly is the honest answer.
|
|
364
|
+
// Asking for it yields RECORDED_UNWITNESSED, which is exactly what it means.
|
|
365
|
+
//
|
|
366
|
+
// NOT a breaking change for the five consumers: none of them requires `provider_witness` today
|
|
367
|
+
// (guard and contract-gate ask for issuer_grant + executor_attestation; prove and conformance for
|
|
368
|
+
// issuer_grant + one_run_root). It removes a way to LIE, not a way anyone works.
|
|
369
|
+
const pw = o.providerReadback || null;
|
|
370
|
+
const witnessVerified = !!(pw && pw.verified === true && pw.envelope && pw.verifier);
|
|
371
|
+
note(AUTHORITY.PROVIDER_WITNESS, witnessVerified,
|
|
372
|
+
pw
|
|
373
|
+
? (pw.signed === true && !witnessVerified
|
|
374
|
+
? 'the caller asserted `signed: true` and supplied no verifiable witness envelope — a '
|
|
375
|
+
+ 'boolean is not evidence, and no signed-witness format exists yet'
|
|
376
|
+
: 'the provider readback is an unsigned document (carried, not verified)')
|
|
377
|
+
: 'no provider readback was supplied');
|
|
378
|
+
|
|
379
|
+
// ── THE INTERSECTION ───────────────────────────────────────────────────────────────────
|
|
380
|
+
const committed = o.committed === true;
|
|
381
|
+
// THE RECEIPT, and what this function can honestly say about it.
|
|
382
|
+
//
|
|
383
|
+
// `verified` is the CALLER's determination: this core is not given the receipt token or a
|
|
384
|
+
// keyring, so it cannot re-establish it. That is recorded rather than hidden — a reader of the
|
|
385
|
+
// result can see whether the receipt was verified HERE or asserted by whoever called.
|
|
386
|
+
//
|
|
387
|
+
// Left as-is deliberately: making a bare boolean insufficient would change the input shape of
|
|
388
|
+
// all five consumers at once, and that belongs with the closed-profile work (1465), not
|
|
389
|
+
// half-done in a round that would leave them broken. The gap is named, not narrowed in silence.
|
|
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
|
+
}
|
|
441
|
+
|
|
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;
|
|
449
|
+
if (!receiptOk || (required.has(AUTHORITY.ISSUER_GRANT) && !grantOk)) state = STATE.UNAUTHORIZED;
|
|
450
|
+
else if (required.has(AUTHORITY.EXECUTOR_ATTESTATION) && !attOk) state = STATE.COMMIT_UNPROVEN;
|
|
451
|
+
else if (required.has(AUTHORITY.ONE_RUN_ROOT) && !authorities[AUTHORITY.ONE_RUN_ROOT].ok) {
|
|
452
|
+
state = STATE.ONE_RUN_UNPROVEN;
|
|
453
|
+
} else if (required.has(AUTHORITY.PROVIDER_WITNESS) && !authorities[AUTHORITY.PROVIDER_WITNESS].ok) {
|
|
454
|
+
state = STATE.RECORDED_UNWITNESSED;
|
|
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
|
+
|
|
459
|
+
|
|
460
|
+
return {
|
|
461
|
+
v: BINDING_V,
|
|
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,
|
|
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,
|
|
481
|
+
authorities,
|
|
482
|
+
shortfalls,
|
|
483
|
+
/** True when `receipt.verified` was taken on the caller's word rather than established here. */
|
|
484
|
+
receipt_caller_asserted: receiptAsserted,
|
|
485
|
+
// Said out loud so a caller cannot read success as more than it is.
|
|
486
|
+
does_not_prove: [
|
|
487
|
+
'that the executor told the truth about its own run — the evidence root closes third-party '
|
|
488
|
+
+ 'splicing, not an executor misreporting itself',
|
|
489
|
+
'that a provider merged anything; `provider_witness` is an unsigned readback unless a caller '
|
|
490
|
+
+ 'states otherwise',
|
|
491
|
+
'that the grant and the attestation agree about target, operation, tenant, executor, adapter, '
|
|
492
|
+
+ 'audience or policy — cr.exec.attest.v1 is a closed field set that states none of them, so '
|
|
493
|
+
+ 'those are UNCHECKED here rather than checked and equal (1464)',
|
|
494
|
+
'that the attestation commits the exact grant BYTES — the format carries no grant-token '
|
|
495
|
+
+ 'digest, so the join is over the grant id, scope, receipt and state nonce',
|
|
496
|
+
...(receiptAsserted
|
|
497
|
+
? ['that the decision receipt verifies — `receipt.verified` was asserted by the caller and '
|
|
498
|
+
+ 'not established here; this core is given no receipt token or keyring to check it with']
|
|
499
|
+
: []),
|
|
500
|
+
],
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
module.exports = {
|
|
505
|
+
verifiedExecutionBinding, STATE, AUTHORITY, PROFILE, PROFILE_NAMES, BINDING_V,
|
|
506
|
+
grantClaims, attestationClaims,
|
|
507
|
+
};
|