@suveren/gateway 0.19.5 → 0.20.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 (46) hide show
  1. package/content/integrations/crm.json +1 -1
  2. package/content/integrations/deploy-github.json +1 -1
  3. package/content/integrations/erp.json +1 -1
  4. package/content/integrations/mail.json +1 -1
  5. package/content/integrations/records.json +1 -1
  6. package/dist/control-plane/index.mjs +21 -13
  7. package/dist/mcp-server/{chunk-ZRRZFGRL.mjs → chunk-APIQIEUA.mjs} +39 -27
  8. package/dist/mcp-server/http.mjs +308 -97
  9. package/dist/mcp-server/report-verify-cli.mjs +1 -1
  10. package/dist/ui/assets/index-1gCMlbLl.js +116 -0
  11. package/dist/ui/index.html +1 -1
  12. package/node_modules/@hap/core/README.md +92 -29
  13. package/node_modules/@hap/core/dist/index.d.mts +614 -593
  14. package/node_modules/@hap/core/dist/index.d.ts +614 -593
  15. package/node_modules/@hap/core/dist/index.js +774 -749
  16. package/node_modules/@hap/core/dist/index.mjs +744 -724
  17. package/node_modules/@hap/core/package.json +1 -1
  18. package/node_modules/@hap/core/src/base64url.ts +39 -0
  19. package/node_modules/@hap/core/src/commitment-modes.ts +0 -21
  20. package/node_modules/@hap/core/src/errors.ts +76 -0
  21. package/node_modules/@hap/core/src/frame.ts +47 -178
  22. package/node_modules/@hap/core/src/gatekeeper.ts +219 -600
  23. package/node_modules/@hap/core/src/identity.ts +9 -12
  24. package/node_modules/@hap/core/src/index.ts +8 -4
  25. package/node_modules/@hap/core/src/mandate.ts +177 -166
  26. package/node_modules/@hap/core/src/owner-signature.ts +282 -0
  27. package/node_modules/@hap/core/src/profile.ts +234 -0
  28. package/node_modules/@hap/core/src/ticket.ts +148 -0
  29. package/node_modules/@hap/core/src/types.ts +191 -344
  30. package/node_modules/@hap/core/src/versions.ts +47 -0
  31. package/package.json +2 -2
  32. package/profiles/calendar/0.6.profile.json +191 -0
  33. package/profiles/charge/0.6.profile.json +211 -0
  34. package/profiles/customers/0.9.profile.json +182 -0
  35. package/profiles/delegation/0.3.profile.json +118 -0
  36. package/profiles/deploy/0.11.profile.json +177 -0
  37. package/profiles/email/0.8.profile.json +226 -0
  38. package/profiles/index.json +12 -1
  39. package/profiles/publish/0.6.profile.json +187 -0
  40. package/profiles/purchase/0.7.profile.json +235 -0
  41. package/profiles/records/0.6.profile.json +118 -0
  42. package/profiles/reporting/0.3.profile.json +98 -0
  43. package/profiles/sales/0.4.profile.json +291 -0
  44. package/dist/ui/assets/index-Du68zJhH.js +0 -116
  45. package/node_modules/@hap/core/src/attestation.ts +0 -173
  46. package/node_modules/@hap/core/src/receipt.ts +0 -74
@@ -1,330 +1,174 @@
1
1
  /**
2
- * Gatekeeper — Stateless Verification for Bounded Execution
2
+ * Gatekeeper — local (Phase 1) verification for bounded execution (v0.7).
3
3
  *
4
- * Supports both v0.3 (frameSchema / frame_hash) and v0.4 (boundsSchema + contextSchema /
5
- * bounds_hash + context_hash).
6
- *
7
- * v0.3 flow (§8.6):
8
- * 1. Resolve profile from frame
9
- * 2. Recompute frame_hash
10
- * 3. For each required domain: find attestation, verify signature, verify frame_hash, verify TTL
11
- * 4. Check bounds: max → actual <= bound, enum → actual in allowed
12
- * 5. Return { approved } or { approved: false, errors: [...] }
13
- *
14
- * v0.4 flow:
4
+ * protocol.md → *Validation Steps* (Phase 1, never reaches the wire):
15
5
  * 1. Resolve profile from bounds.profile
16
- * 2. Recompute bounds_hash and context_hash
17
- * 3. For each required domain: find attestation, verify signature, verify bounds_hash + context_hash, verify TTL
18
- * 4. Check bounds (from boundsSchema), check context constraints (from contextSchema)
19
- * 5. Resolve cumulative fields, check cumulative limits
20
- * 6. Return { approved } or { approved: false, errors: [...] }
6
+ * 2. Recompute bounds_hash and scope_hash
7
+ * 3. For each mandate: decode, verify AS signature, verify version,
8
+ * verify bounds_hash / scope_hash / profile_hash, verify expiry
9
+ * 4. Check actionType registry membership
10
+ * 5. Check per_transaction bounds and enum capability flags locally
11
+ * (Enforcement Authority: this is what makes per_transaction
12
+ * compromise-resistant — *Enforcement classes*)
13
+ * 6. Check scope constraints (enum, subset, requiredFor) locally — the AS
14
+ * never sees scope plaintext, so the Gatekeeper is the sole enforcer
15
+ * 7. Return { approved } or { approved: false, errors: [...] }
16
+ *
17
+ * What this module deliberately does NOT do (protocol.md → *Enforcement
18
+ * Authority*, *Migration from v0.6* semantic change 9): enforce
19
+ * `cumulative_sum` / `cumulative_count` bounds. "Local cumulative
20
+ * enforcement is removed, not merely deprecated: a Gatekeeper MUST NOT
21
+ * refuse an execution on a cumulative bound from its own records" — only
22
+ * the Authority Server's ticket history is authoritative for a running
23
+ * total across every Gatekeeper exercising the bucket. v0.3/v0.4's
24
+ * frame-based path is retired entirely (CLAUDE.md "Decided by the owner").
21
25
  */
22
26
 
23
- import {
24
- decodeAttestationBlob,
25
- verifyAttestationSignature,
26
- checkAttestationExpiry,
27
- verifyFrameHash,
28
- verifyBoundsHash,
29
- verifyContextHash,
30
- isV4Attestation,
31
- } from './attestation';
32
- import { computeFrameHash, computeBoundsHash, computeContextHash } from './frame';
27
+ import { decodeMandateBlob, verifyMandateSignature, checkMandateExpiry, validateMandateOwners } from './mandate';
28
+ import { canonicalBounds, canonicalScope, computeBoundsHash, computeScopeHash, CanonicalValueError } from './frame';
29
+ import { computeProfileHash } from './profile';
33
30
  import { getProfile } from './profiles';
31
+ import { HapError, type HapErrorCode } from './errors';
32
+ import { PROTOCOL_VERSION } from './versions';
34
33
  import type {
35
34
  GatekeeperRequest,
36
35
  GatekeeperResult,
37
36
  GatekeeperError,
38
37
  AgentProfile,
39
38
  AgentBoundsParams,
40
- AgentContextParams,
41
- ExecutionLogQuery,
42
- CumulativeFieldDef,
39
+ AgentScopeParams,
40
+ MandatePayload,
41
+ Mandate,
43
42
  ProfileBoundsField,
44
- BoundType,
45
43
  } from './types';
46
44
 
45
+ /** Options for {@link verify}. */
46
+ export interface VerifyOptions {
47
+ /** Restricts mandate-signature verification to these Authority Server
48
+ * DIDs — protocol.md → *Ticket Verification* step 1 applies identically
49
+ * to mandates: "a verifier MUST reject a [...] whose issuer it does not
50
+ * trust." Omitted → any issuer that resolves a valid did:key is accepted
51
+ * (appropriate for a Gatekeeper that has not yet pinned an AS). */
52
+ trustedIssuers?: readonly string[];
53
+ now?: number;
54
+ }
55
+
47
56
  /**
48
- * Verify an execution request against attested authorization.
57
+ * Verify an execution request against the mandates supplied.
49
58
  *
50
- * For v0.4 profiles (have boundsSchema), the `frame` param is interpreted as `bounds`,
51
- * and the optional `context` param is used for the context hash check.
52
- *
53
- * For v0.3 profiles (have frameSchema only), existing logic is used unchanged.
54
- *
55
- * @param request - The frame/bounds, attestations, execution values, and optional context
56
- * @param publicKeyHex - The SP's public key in hex (cached locally by MCP server)
57
- * @param now - Current timestamp in seconds (for testing)
58
- * @param executionLog - Optional execution log for resolving cumulative fields
59
+ * @param request bounds, mandate blobs, execution values, and optional scope
59
60
  */
60
- export async function verify(
61
- request: GatekeeperRequest,
62
- publicKeyHex: string,
63
- now: number = Math.floor(Date.now() / 1000),
64
- executionLog?: ExecutionLogQuery,
65
- ): Promise<GatekeeperResult> {
66
- const errors: GatekeeperError[] = [];
61
+ export async function verify(request: GatekeeperRequest, opts: VerifyOptions = {}): Promise<GatekeeperResult> {
62
+ const now = opts.now ?? Math.floor(Date.now() / 1000);
67
63
 
68
- // 1. Resolve profile from frame/bounds
69
- const profileId = request.frame.profile;
64
+ // 1. Resolve profile from bounds.profile.
65
+ const profileId = request.bounds.profile;
70
66
  if (typeof profileId !== 'string') {
71
- return { approved: false, errors: [{ code: 'INVALID_PROFILE', message: 'Missing profile in frame' }] };
67
+ return { approved: false, errors: [{ code: 'PROFILE_NOT_FOUND', message: 'Missing profile in bounds' }] };
72
68
  }
73
-
74
69
  const profile = getProfile(profileId);
75
70
  if (!profile) {
76
- return { approved: false, errors: [{ code: 'INVALID_PROFILE', message: `Unknown profile: ${profileId}` }] };
71
+ return { approved: false, errors: [{ code: 'PROFILE_NOT_FOUND', message: `Unknown profile: ${profileId}` }] };
77
72
  }
78
73
 
79
- // Detect v0.4 vs v0.3 based on profile schema
80
- const isV4Profile = !!profile.boundsSchema;
81
-
82
- if (isV4Profile) {
83
- return verifyV4(request, profile, publicKeyHex, now, executionLog);
84
- } else {
85
- return verifyV3(request, profile, publicKeyHex, now, executionLog, errors);
74
+ // Fail closed when there is nothing to verify. A `for` loop over an empty
75
+ // (or missing) mandates array runs zero times and collects zero errors —
76
+ // "no errors" is not the same as "verified".
77
+ if (!Array.isArray(request.mandates) || request.mandates.length === 0) {
78
+ return { approved: false, errors: [{ code: 'MALFORMED_MANDATE', message: 'No mandate supplied — nothing to verify' }] };
86
79
  }
87
- }
88
80
 
89
- /**
90
- * Fail closed when there is nothing to verify.
91
- *
92
- * The attestation loop below is a `for` over `request.attestations`. With an
93
- * empty (or missing) array it runs zero times, pushes zero errors, and control
94
- * falls through to the bounds check — which happily approves a call whose
95
- * signature, bounds_hash, and TTL were never checked. "No errors" is not the
96
- * same as "verified"; an absent attestation is the one case where silence must
97
- * read as refusal.
98
- */
99
- function noAttestationSupplied(request: GatekeeperRequest): boolean {
100
- return !Array.isArray(request.attestations) || request.attestations.length === 0;
101
- }
81
+ // 2. Recompute bounds_hash and scope_hash from what the caller holds.
82
+ const bounds = request.bounds as AgentBoundsParams;
83
+ const scope: AgentScopeParams | undefined = request.scope;
102
84
 
103
- /** Built fresh per call so a caller cannot mutate a shared error array. */
104
- function noAttestationResult(): GatekeeperResult {
105
- return {
106
- approved: false,
107
- errors: [{ code: 'MALFORMED_ATTESTATION', message: 'No attestation supplied — nothing to verify' }],
108
- };
109
- }
110
-
111
- // ─── v0.3 Verification ────────────────────────────────────────────────────────
112
-
113
- async function verifyV3(
114
- request: GatekeeperRequest,
115
- profile: AgentProfile,
116
- publicKeyHex: string,
117
- now: number,
118
- executionLog: ExecutionLogQuery | undefined,
119
- errors: GatekeeperError[],
120
- ): Promise<GatekeeperResult> {
121
- if (noAttestationSupplied(request)) return noAttestationResult();
122
-
123
- let expectedFrameHash: string;
85
+ let expectedBoundsHash: string;
124
86
  try {
125
- expectedFrameHash = computeFrameHash(request.frame, profile);
87
+ expectedBoundsHash = computeBoundsHash(bounds, profile);
126
88
  } catch (err) {
127
- return { approved: false, errors: [{ code: 'FRAME_MISMATCH', message: `Frame hash computation failed: ${err}` }] };
128
- }
129
-
130
- // Verify attestations (domains come from SP group config in v0.4, not from profile)
131
- const requiredDomains: string[] = [];
132
- const coveredDomains = new Set<string>();
133
-
134
- for (const blob of request.attestations) {
135
- let attestation;
136
- try {
137
- attestation = decodeAttestationBlob(blob);
138
- } catch {
139
- errors.push({ code: 'MALFORMED_ATTESTATION', message: 'Failed to decode attestation blob' });
140
- continue;
141
- }
142
-
143
- try {
144
- await verifyAttestationSignature(attestation, publicKeyHex);
145
- } catch {
146
- errors.push({ code: 'INVALID_SIGNATURE', message: 'Attestation signature verification failed' });
147
- continue;
148
- }
149
-
150
- try {
151
- verifyFrameHash(attestation, expectedFrameHash);
152
- } catch {
153
- errors.push({ code: 'FRAME_MISMATCH', message: 'Attestation frame_hash does not match computed frame_hash' });
154
- continue;
89
+ if (err instanceof CanonicalValueError) {
90
+ return { approved: false, errors: [{ code: err.code, field: err.field, message: err.message }] };
155
91
  }
92
+ return { approved: false, errors: [{ code: 'BOUNDS_HASH_MISMATCH', message: `Bounds hash computation failed: ${err}` }] };
93
+ }
156
94
 
95
+ // Scope hash is only recomputed when scope is explicitly provided. Scope
96
+ // CONSTRAINTS (enum/subset/requiredFor) are always enforced below against
97
+ // whatever scope and execution values the caller supplies; this is a
98
+ // separate, stricter hash-identity check against the held mandate.
99
+ let expectedScopeHash: string | undefined;
100
+ if (scope && Object.keys(scope).length > 0) {
157
101
  try {
158
- checkAttestationExpiry(attestation.payload, now);
159
- } catch {
160
- const domainNames = (attestation.payload.resolved_domains ?? []).map(d => d.domain).join(', ');
161
- errors.push({ code: 'TTL_EXPIRED', message: `Attestation for domain "${domainNames}" has expired` });
162
- continue;
163
- }
164
-
165
- for (const rd of attestation.payload.resolved_domains ?? []) {
166
- coveredDomains.add(rd.domain);
102
+ expectedScopeHash = computeScopeHash(scope, profile);
103
+ } catch (err) {
104
+ if (err instanceof CanonicalValueError) {
105
+ return { approved: false, errors: [{ code: err.code, field: err.field, message: err.message }] };
106
+ }
107
+ return { approved: false, errors: [{ code: 'SCOPE_HASH_MISMATCH', message: `Scope hash computation failed: ${err}` }] };
167
108
  }
168
109
  }
169
110
 
170
- for (const domain of requiredDomains) {
171
- if (!coveredDomains.has(domain)) {
172
- errors.push({ code: 'DOMAIN_NOT_COVERED', message: `Required domain "${domain}" not covered by any valid attestation` });
173
- }
174
- }
111
+ const expectedProfileHash = computeProfileHash(profile);
175
112
 
113
+ // 3. Verify every mandate blob.
114
+ const errors: GatekeeperError[] = [];
115
+ for (const blob of request.mandates) {
116
+ const mandateErrors = await verifyOneMandate(blob, {
117
+ expectedBoundsHash,
118
+ expectedScopeHash,
119
+ expectedProfileHash,
120
+ trustedIssuers: opts.trustedIssuers,
121
+ now,
122
+ });
123
+ errors.push(...mandateErrors);
124
+ }
176
125
  if (errors.length > 0) {
177
126
  return { approved: false, errors };
178
127
  }
179
128
 
180
- // Resolve cumulative fields
181
- if (executionLog && profile.executionContextSchema?.fields) {
182
- const cumulativeErrors = resolveCumulativeFields(request, profile, executionLog, now);
183
- if (cumulativeErrors.length > 0) {
184
- return { approved: false, errors: cumulativeErrors };
129
+ // 4. actionType registry membership — checked before any bound is read
130
+ // (protocol.md → required-refusals vector "no-action-type": "Checked
131
+ // before any bound is read").
132
+ const actionType = typeof request.execution.action_type === 'string' ? request.execution.action_type : undefined;
133
+ const registry = profile.boundsSchema?.actionTypes;
134
+ if (registry && registry.length > 0) {
135
+ if (actionType === undefined || !registry.includes(actionType)) {
136
+ return {
137
+ approved: false,
138
+ errors: [{
139
+ code: 'INVALID_ACTION_TYPE',
140
+ field: 'action_type',
141
+ message: `action_type ${JSON.stringify(actionType)} is not in profile ${profile.id}'s actionTypes registry [${registry.join(', ')}]`,
142
+ }],
143
+ };
185
144
  }
186
145
  }
187
146
 
188
- // Check bounds using frameSchema
189
- const boundsErrors = checkBoundsFromFrameSchema(request, profile);
147
+ // 5. per_transaction bounds and enum capability flags, locally.
148
+ const boundsErrors = checkBounds(request, profile, actionType);
190
149
  if (boundsErrors.length > 0) {
191
150
  return { approved: false, errors: boundsErrors };
192
151
  }
193
152
 
194
- return { approved: true };
195
- }
196
-
197
- // ─── v0.4 Verification ────────────────────────────────────────────────────────
198
-
199
- async function verifyV4(
200
- request: GatekeeperRequest,
201
- profile: AgentProfile,
202
- publicKeyHex: string,
203
- now: number,
204
- executionLog: ExecutionLogQuery | undefined,
205
- ): Promise<GatekeeperResult> {
206
- if (noAttestationSupplied(request)) return noAttestationResult();
207
-
208
- const errors: GatekeeperError[] = [];
209
-
210
- // In v0.4 the `frame` param carries bounds; `context` carries context params
211
- const bounds = request.frame as AgentBoundsParams;
212
- const context: AgentContextParams | undefined = request.context;
213
-
214
- // Compute expected hashes
215
- let expectedBoundsHash: string;
216
- let expectedContextHash: string | undefined;
217
-
218
- try {
219
- expectedBoundsHash = computeBoundsHash(bounds, profile);
220
- } catch (err) {
221
- return { approved: false, errors: [{ code: 'BOUNDS_MISMATCH', message: `Bounds hash computation failed: ${err}` }] };
222
- }
223
-
224
- // Context hash is only computed when context is explicitly provided.
225
- // At execution time, context is not re-verified — it was checked at authorization time.
226
- if (context && Object.keys(context).length > 0) {
227
- try {
228
- expectedContextHash = computeContextHash(context, profile);
229
- } catch (err) {
230
- return { approved: false, errors: [{ code: 'CONTEXT_MISMATCH', message: `Context hash computation failed: ${err}` }] };
231
- }
232
- }
233
-
234
- // Verify attestations (domains come from SP group config, not profile)
235
- const requiredDomains: string[] = [];
236
- const coveredDomains = new Set<string>();
237
-
238
- for (const blob of request.attestations) {
239
- let attestation;
240
- try {
241
- attestation = decodeAttestationBlob(blob);
242
- } catch {
243
- errors.push({ code: 'MALFORMED_ATTESTATION', message: 'Failed to decode attestation blob' });
244
- continue;
245
- }
246
-
247
- try {
248
- await verifyAttestationSignature(attestation, publicKeyHex);
249
- } catch {
250
- errors.push({ code: 'INVALID_SIGNATURE', message: 'Attestation signature verification failed' });
251
- continue;
252
- }
253
-
254
- // Verify bounds hash
255
- try {
256
- verifyBoundsHash(attestation, expectedBoundsHash);
257
- } catch {
258
- errors.push({ code: 'BOUNDS_MISMATCH', message: 'Attestation bounds_hash does not match computed bounds_hash' });
259
- continue;
260
- }
261
-
262
- // Verify context hash (only when context was provided and hash was computed)
263
- if (isV4Attestation(attestation) && expectedContextHash) {
264
- try {
265
- verifyContextHash(attestation, expectedContextHash);
266
- } catch {
267
- errors.push({ code: 'CONTEXT_MISMATCH', message: 'Attestation context_hash does not match computed context_hash' });
268
- continue;
269
- }
270
- }
271
-
272
- try {
273
- checkAttestationExpiry(attestation.payload, now);
274
- } catch {
275
- const domainNames = (attestation.payload.resolved_domains ?? []).map(d => d.domain).join(', ');
276
- errors.push({ code: 'TTL_EXPIRED', message: `Attestation for domain "${domainNames}" has expired` });
277
- continue;
278
- }
279
-
280
- for (const rd of attestation.payload.resolved_domains ?? []) {
281
- coveredDomains.add(rd.domain);
282
- }
283
- }
284
-
285
- for (const domain of requiredDomains) {
286
- if (!coveredDomains.has(domain)) {
287
- errors.push({ code: 'DOMAIN_NOT_COVERED', message: `Required domain "${domain}" not covered by any valid attestation` });
288
- }
289
- }
290
-
291
- if (errors.length > 0) {
292
- return { approved: false, errors };
293
- }
294
-
295
- // v0.4 bounds check — single pass that dispatches on each field's
296
- // boundType.kind. Replaces the two-pass (resolveCumulativeFields +
297
- // checkBoundsFromBoundsSchema) approach, which used name-pattern
298
- // heuristics and silently failed when ctx field names didn't round-trip
299
- // through `${fieldName}_max`.
300
- const v4BoundsErrors = checkBoundsV4(request, profile, executionLog, now);
301
- if (v4BoundsErrors.length > 0) {
302
- return { approved: false, errors: v4BoundsErrors };
303
- }
304
-
305
- // Check context constraints using contextSchema.
306
- // Fail closed: if the profile declares enforceable context constraints but
307
- // no declared context was supplied, reject — the SP cannot enforce these
308
- // (it only holds context_hash), so skipping the check would silently
309
- // bypass scope restrictions like allowed_recipients / allowed_domains.
310
- if (profile.contextSchema && Object.keys(profile.contextSchema.fields).length > 0) {
311
- const enforceableFields = Object.entries(profile.contextSchema.fields)
153
+ // 6. Scope constraints — the sole enforcer, since the AS only ever holds scope_hash.
154
+ if (profile.scopeSchema && Object.keys(profile.scopeSchema.fields).length > 0) {
155
+ const enforceableFields = Object.entries(profile.scopeSchema.fields)
312
156
  .filter(([, def]) => def.constraint?.enforceable?.length);
313
157
 
314
- if (enforceableFields.length > 0 && !context) {
158
+ if (enforceableFields.length > 0 && !scope) {
315
159
  return {
316
160
  approved: false,
317
161
  errors: [{
318
162
  code: 'BOUND_EXCEEDED',
319
- message: `Declared context required for constraint enforcement but not provided. Fields: ${enforceableFields.map(([n]) => n).join(', ')}`,
163
+ message: `Declared scope required for constraint enforcement but not provided. Fields: ${enforceableFields.map(([n]) => n).join(', ')}`,
320
164
  }],
321
165
  };
322
166
  }
323
167
 
324
- if (context) {
325
- const contextErrors = checkContextConstraints(context, request.execution, profile);
326
- if (contextErrors.length > 0) {
327
- return { approved: false, errors: contextErrors };
168
+ if (scope) {
169
+ const scopeErrors = checkScopeConstraints(scope, request.execution, profile);
170
+ if (scopeErrors.length > 0) {
171
+ return { approved: false, errors: scopeErrors };
328
172
  }
329
173
  }
330
174
  }
@@ -332,158 +176,84 @@ async function verifyV4(
332
176
  return { approved: true };
333
177
  }
334
178
 
335
- // ─── Bounds Checking ─────────────────────────────────────────────────────────
336
-
337
- /**
338
- * Check execution values against authorization frame bounds (v0.3).
339
- * Uses the profile's frameSchema constraint definitions.
340
- */
341
- function checkBoundsFromFrameSchema(request: GatekeeperRequest, profile: AgentProfile): GatekeeperError[] {
342
- const errors: GatekeeperError[] = [];
343
-
344
- if (!profile.frameSchema) return errors;
179
+ // ─── Mandate Verification ────────────────────────────────────────────────────
345
180
 
346
- for (const [fieldName, fieldDef] of Object.entries(profile.frameSchema.fields)) {
347
- if (!fieldDef.constraint) continue;
348
-
349
- const constraint = fieldDef.constraint;
350
-
351
- for (const enforceType of constraint.enforceable) {
352
- if (enforceType === 'max') {
353
- const execField = fieldName.replace(/_max$/, '');
354
- const boundValue = request.frame[fieldName];
355
- const actualValue = request.execution[execField];
181
+ interface MandateCheckOptions {
182
+ expectedBoundsHash: string;
183
+ expectedScopeHash: string | undefined;
184
+ expectedProfileHash: string;
185
+ trustedIssuers: readonly string[] | undefined;
186
+ now: number;
187
+ }
356
188
 
357
- if (actualValue === undefined) continue;
189
+ /** The canonical code a thrown value names, from a {@link HapError} or from
190
+ * this package's `"CODE: message"` convention (mandate.ts, ticket.ts). */
191
+ function codeFromThrown(err: unknown): HapErrorCode {
192
+ if (err instanceof HapError) return err.code;
193
+ if (err instanceof Error) {
194
+ const [prefix] = err.message.split(':');
195
+ return prefix as HapErrorCode;
196
+ }
197
+ return 'MALFORMED_MANDATE';
198
+ }
358
199
 
359
- if (typeof boundValue !== 'number' || typeof actualValue !== 'number') {
360
- errors.push({
361
- code: 'BOUND_EXCEEDED',
362
- field: execField,
363
- message: `Bound check requires numeric values for "${execField}"`,
364
- bound: boundValue,
365
- actual: actualValue,
366
- });
367
- continue;
368
- }
200
+ async function verifyOneMandate(blob: string, opts: MandateCheckOptions): Promise<GatekeeperError[]> {
201
+ let mandate: Mandate;
202
+ try {
203
+ mandate = decodeMandateBlob(blob);
204
+ } catch (err) {
205
+ return [{ code: 'MALFORMED_MANDATE', message: (err as Error).message }];
206
+ }
369
207
 
370
- if (actualValue > boundValue) {
371
- errors.push({
372
- code: 'BOUND_EXCEEDED',
373
- field: execField,
374
- message: `Value ${actualValue} exceeds authorized maximum of ${boundValue}`,
375
- bound: boundValue,
376
- actual: actualValue,
377
- });
378
- }
379
- }
208
+ if (mandate.payload.version !== PROTOCOL_VERSION) {
209
+ return [{
210
+ code: 'VERSION_UNSUPPORTED',
211
+ message: `mandate version ${JSON.stringify(mandate.payload.version)} is not verifiable by this Gatekeeper (verifies ${PROTOCOL_VERSION} only)`,
212
+ }];
213
+ }
380
214
 
381
- if (enforceType === 'enum') {
382
- const boundValue = request.frame[fieldName];
383
- const actualValue = request.execution[fieldName];
215
+ try {
216
+ await verifyMandateSignature(mandate, { trustedIssuers: opts.trustedIssuers });
217
+ } catch (err) {
218
+ return [{ code: codeFromThrown(err), message: (err as Error).message }];
219
+ }
384
220
 
385
- if (actualValue === undefined) continue;
221
+ try {
222
+ checkMandateExpiry(mandate.payload, opts.now);
223
+ } catch (err) {
224
+ return [{ code: codeFromThrown(err), message: (err as Error).message }];
225
+ }
386
226
 
387
- const allowed = typeof boundValue === 'string'
388
- ? boundValue.split(',').map(s => s.trim())
389
- : [String(boundValue)];
227
+ try {
228
+ validateMandateOwners(mandate.payload);
229
+ } catch (err) {
230
+ return [{ code: codeFromThrown(err), message: (err as Error).message }];
231
+ }
390
232
 
391
- const actualStr = String(actualValue);
233
+ const errors: GatekeeperError[] = [];
234
+ const payload: MandatePayload = mandate.payload;
392
235
 
393
- if (!allowed.includes(actualStr)) {
394
- errors.push({
395
- code: 'BOUND_EXCEEDED',
396
- field: fieldName,
397
- message: `Value "${actualStr}" not in authorized values [${allowed.join(', ')}]`,
398
- bound: boundValue,
399
- actual: actualValue,
400
- });
401
- }
402
- }
403
- }
236
+ if (payload.bounds_hash !== opts.expectedBoundsHash) {
237
+ errors.push({ code: 'BOUNDS_HASH_MISMATCH', message: 'Mandate bounds_hash does not match the recomputed bounds_hash' });
238
+ }
239
+ if (opts.expectedScopeHash !== undefined && payload.scope_hash !== opts.expectedScopeHash) {
240
+ errors.push({ code: 'SCOPE_HASH_MISMATCH', message: 'Mandate scope_hash does not match the recomputed scope_hash' });
241
+ }
242
+ if (payload.profile_hash !== opts.expectedProfileHash) {
243
+ errors.push({ code: 'PROFILE_HASH_MISMATCH', message: 'Mandate profile_hash does not match the Gatekeeper\'s provisioned profile bytes' });
404
244
  }
405
245
 
406
246
  return errors;
407
247
  }
408
248
 
409
- /**
410
- * v0.4 bounds enforcement — dispatches on each bound field's declared
411
- * `boundType.kind` with no name-pattern heuristics. This is the single
412
- * source of truth for client-side bounds checking in v0.4.
413
- *
414
- * Why this replaces the old two-function approach:
415
- *
416
- * checkBoundsFromBoundsSchema (old) stripped "_max" from the bound name
417
- * to guess the execution context field, which required a fixed naming
418
- * convention (e.g., "amount_max" → "amount"). Profiles that used other
419
- * conventions silently failed to enforce.
420
- *
421
- * resolveCumulativeFields (old) appended "_max" to the cumulative
422
- * execution context field name to find its corresponding bound, which
423
- * required ANOTHER fixed naming convention (e.g., "amount_daily" →
424
- * "amount_daily_max"). Profiles where the ctx field added a "_count_"
425
- * infix (write_count_daily → write_daily_max, not
426
- * write_count_daily_max) silently failed too.
427
- *
428
- * The new function ignores ctx field names entirely and reads
429
- * enforcement semantics from `fieldDef.boundType`:
430
- *
431
- * per_transaction — compares `execution[of] <= bound`
432
- * cumulative_sum — sumByWindow(of, window) + execution[of] <= bound
433
- * cumulative_count — sumByWindow('_count', window) + 1 <= bound
434
- * enum — bound value must be in the allowed set (attest-time
435
- * validation; not an execution-time check)
436
- */
437
- /**
438
- * Does this bound govern the action being attempted?
439
- *
440
- * Cumulative totals are scoped by (profileId, path) only, so without this every
441
- * cumulative_count bound on a profile sees the same running count. On customers
442
- * — which declares both write_daily_max and delete_daily_max — the smaller
443
- * delete limit would count writes and block them.
444
- *
445
- * Preferred answer is the profile's declared `appliesTo`. Absent that, fall back
446
- * to the field-name convention (`delete_daily_max` → `delete`), matching how the
447
- * Authority Server selects bounds so the two enforcement points cannot disagree.
448
- *
449
- * Fails CLOSED: when the action type is unknown, or the bound's name implies no
450
- * particular action, the bound is enforced.
451
- */
452
- function boundGovernsAction(
453
- fieldName: string,
454
- fieldDef: ProfileBoundsField,
455
- bt: BoundType,
456
- actionType: string | undefined,
457
- ): boolean {
458
- if (fieldDef.appliesTo) {
459
- return actionType ? fieldDef.appliesTo.includes(actionType) : true;
460
- }
461
-
462
- // The name convention only ever disambiguated count bounds; sums are governed
463
- // by their `of` field and stay unfiltered, as at the Authority Server.
464
- if (bt.kind !== 'cumulative_count' || !actionType) return true;
465
-
466
- const prefix = fieldName.replace(/_(?:daily|monthly|weekly)_max$/, '');
467
- const namedForAnAction = prefix !== fieldName;
468
- const matches = prefix === actionType || prefix.startsWith('transaction');
469
- return !namedForAnAction || matches;
470
- }
249
+ // ─── Bounds Checking (per_transaction, enum) ─────────────────────────────────
471
250
 
472
251
  /**
473
- * Which action types' executions count toward this cumulative bound's running
474
- * total. `undefined` means all of them.
475
- *
476
- * `boundGovernsAction` decides whether a bound applies to the current call; this
477
- * decides what the bound's total is made of. Both are needed: selecting the right
478
- * bound and then feeding it the profile's combined total is still wrong — sales'
479
- * "orders per day" was checked only on orders, but counted every quote and send.
480
- * Consumption is partitioned by action type (*Cumulative Tracking* rule 4), as
481
- * the Authority Server already does.
482
- *
483
- * Same answer, same order of preference as `boundGovernsAction`: the declared
484
- * `appliesTo`; else, for a count bound named for an action (`delete_daily_max`),
485
- * that action; else every action type. Exported so display surfaces report the
486
- * same totals the gate enforces.
252
+ * Which action types' executions count toward this cumulative bound's
253
+ * running total — `undefined` means all of them. Display-only now that
254
+ * local cumulative enforcement is removed (protocol.md → *Enforcement
255
+ * Authority*: cumulative bounds are AS-only). Exported so a gateway's own
256
+ * usage display partitions totals the same way the Authority Server does.
487
257
  */
488
258
  export function boundActionTypes(
489
259
  fieldName: string,
@@ -499,69 +269,56 @@ export function boundActionTypes(
499
269
  return [prefix];
500
270
  }
501
271
 
502
- function checkBoundsV4(
272
+ /**
273
+ * Local bounds enforcement — `per_transaction` and `enum` only.
274
+ * `cumulative_sum` / `cumulative_count` fields are skipped entirely: they
275
+ * are AS-only (*Enforcement Authority*), and the Gatekeeper's own
276
+ * (incomplete, single-Gatekeeper) history is not authoritative for them.
277
+ */
278
+ function checkBounds(
503
279
  request: GatekeeperRequest,
504
280
  profile: AgentProfile,
505
- executionLog: ExecutionLogQuery | undefined,
506
- now: number,
281
+ actionType: string | undefined,
507
282
  ): GatekeeperError[] {
508
283
  const errors: GatekeeperError[] = [];
509
284
  if (!profile.boundsSchema) return errors;
510
285
 
511
- const bounds = request.frame as AgentBoundsParams;
512
- const profileId = String(bounds.profile ?? profile.id);
513
- const actionType = typeof request.execution.action_type === 'string'
514
- ? request.execution.action_type
515
- : undefined;
516
- // Prefer the explicit request path. `frame.path` is retained only as a
517
- // fallback for callers that legitimately declare `path` in their profile's
518
- // boundsSchema; for every shipped profile it is absent, which is exactly why
519
- // this silently resolved to "" and disabled the cumulative gate entirely.
520
- const path = request.path ?? (bounds.path ? String(bounds.path) : '');
286
+ const bounds = request.bounds as AgentBoundsParams;
521
287
 
522
288
  for (const [fieldName, fieldDef] of Object.entries(profile.boundsSchema.fields)) {
523
- if (fieldName === 'profile' || fieldName === 'path') continue;
289
+ if (fieldName === 'profile') continue;
524
290
 
525
291
  const boundValue = bounds[fieldName];
526
292
  if (boundValue === undefined) continue;
527
293
 
528
294
  const bt = fieldDef.boundType;
529
295
  if (!bt) {
530
- // v0.4 requires boundType on every non-metadata bound field. A
531
- // missing boundType is a profile authoring bug — fail closed on
532
- // enforcement rather than silently skipping (which is what caused
533
- // the write_daily_max display bug in the first place).
296
+ // A missing boundType is a profile authoring bug — fail closed on
297
+ // enforcement rather than silently skipping.
534
298
  errors.push({
535
- code: 'BOUND_EXCEEDED',
299
+ code: 'PROFILE_INVALID',
536
300
  field: fieldName,
537
- message: `Profile ${profile.id} bound "${fieldName}" has no boundType — enforcement semantics undefined. Add boundType to the profile's boundsSchema.`,
301
+ message: `Profile ${profile.id} bound "${fieldName}" has no boundType — enforcement semantics undefined.`,
538
302
  bound: boundValue,
539
303
  actual: boundValue,
540
304
  });
541
305
  continue;
542
306
  }
543
307
 
544
- if (!boundGovernsAction(fieldName, fieldDef, bt, actionType)) continue;
545
-
546
308
  switch (bt.kind) {
547
309
  case 'per_transaction': {
548
310
  const actual = request.execution[bt.of];
549
311
 
550
312
  // `requiredFor`: for a listed action_type, absence (or a non-numeric
551
- // value) is a denial, not a skip — see BoundType.per_transaction's
552
- // doc comment for why. Unlisted action types, or no `requiredFor` at
553
- // all, keep the pre-existing skip-on-absence behaviour below.
313
+ // value) is a denial, not a skip. Unlisted action types, or no
314
+ // `requiredFor` at all, keep skip-on-absence behaviour below.
554
315
  const engaged = !!bt.requiredFor?.length
555
316
  && actionType !== undefined
556
317
  && bt.requiredFor.includes(actionType);
557
318
 
558
319
  if (engaged) {
559
- // `null` is treated as "missing", not as the number 0 — JSON has no
560
- // undefined, so a value lost somewhere upstream (an unmapped
561
- // connector field serialized through JSON) commonly arrives as
562
- // null. `Number(null) === 0` would otherwise read as "an amount of
563
- // zero was declared and is within bound", which is the exact
564
- // silent-pass this feature exists to close.
320
+ // `null` is "missing", not the number 0 — a value lost upstream
321
+ // must not silently read as "amount 0, within bound".
565
322
  const missing = actual === undefined || actual === null;
566
323
  const numericActual = missing ? NaN : (typeof actual === 'number' ? actual : Number(actual));
567
324
  const missingOrNonNumeric = missing || Number.isNaN(numericActual);
@@ -606,76 +363,11 @@ function checkBoundsV4(
606
363
  break;
607
364
  }
608
365
 
609
- case 'cumulative_sum': {
610
- if (typeof boundValue !== 'number') {
611
- errors.push({
612
- code: 'CUMULATIVE_LIMIT_EXCEEDED',
613
- field: bt.of,
614
- message: `Cumulative bound "${fieldName}" must be numeric`,
615
- bound: boundValue,
616
- actual: 0,
617
- });
618
- break;
619
- }
620
- if (!executionLog) break;
621
-
622
- const runningTotal = executionLog.sumByWindow(
623
- profileId, path, bt.of, bt.window, now, boundActionTypes(fieldName, fieldDef),
624
- );
625
- const currentRaw = request.execution[bt.of];
626
- const current = typeof currentRaw === 'number'
627
- ? currentRaw
628
- : (currentRaw !== undefined ? Number(currentRaw) : 0);
629
- const total = runningTotal + current;
630
-
631
- if (total > boundValue) {
632
- errors.push({
633
- code: 'CUMULATIVE_LIMIT_EXCEEDED',
634
- field: bt.of,
635
- message: `Cumulative ${bt.window} sum of ${bt.of} (${total}) exceeds limit of ${boundValue} for ${fieldName}`,
636
- bound: boundValue,
637
- actual: total,
638
- });
639
- }
640
- break;
641
- }
642
-
643
- case 'cumulative_count': {
644
- if (typeof boundValue !== 'number') {
645
- errors.push({
646
- code: 'CUMULATIVE_LIMIT_EXCEEDED',
647
- field: fieldName,
648
- message: `Cumulative count bound "${fieldName}" must be numeric`,
649
- bound: boundValue,
650
- actual: 0,
651
- });
652
- break;
653
- }
654
- if (!executionLog) break;
655
-
656
- const runningCount = executionLog.sumByWindow(
657
- profileId, path, '_count', bt.window, now, boundActionTypes(fieldName, fieldDef),
658
- );
659
- const total = runningCount + 1;
660
-
661
- if (total > boundValue) {
662
- errors.push({
663
- code: 'CUMULATIVE_LIMIT_EXCEEDED',
664
- field: fieldName,
665
- message: `Cumulative ${bt.window} count (${total}) exceeds limit of ${boundValue} for ${fieldName}`,
666
- bound: boundValue,
667
- actual: total,
668
- });
669
- }
670
- break;
671
- }
672
-
673
366
  case 'enum': {
674
- // Enum bounds are capability flags. The stored bound value must
675
- // be in the allowed set (a profile authoring check, really
676
- // belongs at attestation time). They are NOT compared against
677
- // runtime execution here — tool-proxy gates tool calls against
678
- // the manifest's required value before they reach the gatekeeper.
367
+ // Enum bounds are capability flags, validated at mandate time. They
368
+ // are NOT compared against runtime execution here — tool-proxy gates
369
+ // tool calls against the manifest's required value before they
370
+ // reach the Gatekeeper.
679
371
  if (typeof boundValue === 'string' && !bt.values.includes(boundValue)) {
680
372
  errors.push({
681
373
  code: 'BOUND_EXCEEDED',
@@ -687,37 +379,43 @@ function checkBoundsV4(
687
379
  }
688
380
  break;
689
381
  }
382
+
383
+ case 'cumulative_sum':
384
+ case 'cumulative_count':
385
+ // AS-only (protocol.md → *Enforcement Authority*). A Gatekeeper
386
+ // MUST NOT refuse on these from its own records.
387
+ break;
690
388
  }
691
389
  }
692
390
 
693
391
  return errors;
694
392
  }
695
393
 
394
+ // ─── Scope Constraints ───────────────────────────────────────────────────────
395
+
696
396
  /**
697
- * Check context param values against contextSchema enum constraints (v0.4).
698
- * Context enum fields constrain the allowed values in execution.
397
+ * Check scope param values against scopeSchema enum/subset constraints.
398
+ * Scope enum fields constrain the allowed values in execution.
699
399
  */
700
- function checkContextConstraints(
701
- context: AgentContextParams,
400
+ function checkScopeConstraints(
401
+ scope: AgentScopeParams,
702
402
  execution: Record<string, string | number>,
703
403
  profile: AgentProfile,
704
404
  ): GatekeeperError[] {
705
405
  const errors: GatekeeperError[] = [];
706
406
 
707
- if (!profile.contextSchema) return errors;
407
+ if (!profile.scopeSchema) return errors;
708
408
 
709
- for (const [fieldName, fieldDef] of Object.entries(profile.contextSchema.fields)) {
409
+ for (const [fieldName, fieldDef] of Object.entries(profile.scopeSchema.fields)) {
710
410
  if (!fieldDef.constraint) continue;
711
411
 
712
- // A constrained dimension that the call does not expose cannot be checked.
713
- // Skipping is only safe when the action does not engage that dimension —
714
- // deleting a draft has no recipients. When it DOES engage it, silence must
715
- // not read as compliance: an absent value means the Gatekeeper cannot show
716
- // the call is in scope, and an unverifiable send is exactly what the scope
717
- // exists to prevent. The profile declares which actions engage the field.
412
+ // A constrained dimension that the call does not expose cannot be
413
+ // checked. Skipping is only safe when the action does not engage that
414
+ // dimension — deleting a draft has no recipients. When it DOES engage
415
+ // it, silence must not read as compliance.
718
416
  const requiredFor = fieldDef.constraint.requiredFor;
719
417
  if (requiredFor?.length) {
720
- const boundValue = context[fieldName];
418
+ const boundValue = scope[fieldName];
721
419
  const actualValue = execution[fieldName];
722
420
  const actionType = execution['action_type'];
723
421
  const constrained = boundValue !== undefined && boundValue !== '';
@@ -739,8 +437,7 @@ function checkContextConstraints(
739
437
 
740
438
  for (const enforceType of fieldDef.constraint.enforceable) {
741
439
  if (enforceType === 'enum') {
742
- // The context field value is the allowed value; execution must match
743
- const boundValue = context[fieldName];
440
+ const boundValue = scope[fieldName];
744
441
  const actualValue = execution[fieldName];
745
442
 
746
443
  if (actualValue === undefined) continue;
@@ -755,7 +452,7 @@ function checkContextConstraints(
755
452
  errors.push({
756
453
  code: 'BOUND_EXCEEDED',
757
454
  field: fieldName,
758
- message: `Value "${actualStr}" not in authorized context values [${allowed.join(', ')}]`,
455
+ message: `Value "${actualStr}" not in authorized scope values [${allowed.join(', ')}]`,
759
456
  bound: boundValue,
760
457
  actual: actualValue,
761
458
  });
@@ -763,7 +460,7 @@ function checkContextConstraints(
763
460
  }
764
461
 
765
462
  if (enforceType === 'subset') {
766
- const boundValue = context[fieldName];
463
+ const boundValue = scope[fieldName];
767
464
  const actualValue = execution[fieldName];
768
465
 
769
466
  if (boundValue === undefined || boundValue === '') continue;
@@ -788,81 +485,3 @@ function checkContextConstraints(
788
485
 
789
486
  return errors;
790
487
  }
791
-
792
- // ─── Cumulative Fields ────────────────────────────────────────────────────────
793
-
794
- /**
795
- * Resolve cumulative fields by querying the execution log, then check their bounds.
796
- *
797
- * For each cumulative field in the execution context schema:
798
- * 1. Query the execution log for the running total within the window
799
- * 2. Add the current call's contribution (field value or +1 for _count)
800
- * 3. Inject the resolved value into request.execution
801
- * 4. Check against the corresponding bounds field (fieldName + "_max")
802
- */
803
- function resolveCumulativeFields(
804
- request: GatekeeperRequest,
805
- profile: AgentProfile,
806
- executionLog: ExecutionLogQuery,
807
- now: number,
808
- ): GatekeeperError[] {
809
- const errors: GatekeeperError[] = [];
810
-
811
- // Profile ID comes from bounds (v0.4) or frame (v0.3)
812
- const profileId = String(request.frame.profile);
813
-
814
- // For v0.4, the bounds source (request.frame) holds the cumulative max fields
815
- const boundsOrFrame = request.frame;
816
-
817
- for (const [fieldName, fieldDef] of Object.entries(profile.executionContextSchema.fields)) {
818
- if (fieldDef.source !== 'cumulative') continue;
819
-
820
- const cumDef = fieldDef as CumulativeFieldDef;
821
- const { cumulativeField, window: windowType } = cumDef;
822
-
823
- const path = request.frame.path ? String(request.frame.path) : '';
824
- const runningTotal = executionLog.sumByWindow(profileId, path, cumulativeField, windowType, now);
825
-
826
- let currentContribution: number;
827
- if (cumulativeField === '_count') {
828
- currentContribution = 1;
829
- } else {
830
- const val = request.execution[cumulativeField];
831
- currentContribution = typeof val === 'number' ? val : (val !== undefined ? Number(val) : 0);
832
- }
833
-
834
- const cumulativeValue = runningTotal + currentContribution;
835
-
836
- // Inject resolved value into execution for downstream inspection
837
- request.execution[fieldName] = cumulativeValue;
838
-
839
- // Check against bound — convention: cumulative field "X_daily" → bound "X_daily_max"
840
- const boundFieldName = fieldName + '_max';
841
- const boundValue = boundsOrFrame[boundFieldName];
842
-
843
- if (boundValue === undefined) continue;
844
-
845
- if (typeof boundValue !== 'number') {
846
- errors.push({
847
- code: 'CUMULATIVE_LIMIT_EXCEEDED',
848
- field: fieldName,
849
- message: `Cumulative bound requires numeric value for "${boundFieldName}"`,
850
- bound: boundValue,
851
- actual: cumulativeValue,
852
- });
853
- continue;
854
- }
855
-
856
- if (cumulativeValue > boundValue) {
857
- errors.push({
858
- code: 'CUMULATIVE_LIMIT_EXCEEDED',
859
- field: fieldName,
860
- message: `Cumulative ${windowType} value ${cumulativeValue} exceeds limit of ${boundValue}`,
861
- bound: boundValue,
862
- actual: cumulativeValue,
863
- });
864
- }
865
- }
866
-
867
- return errors;
868
- }