@humanagencyp/hap-core 0.4.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.
@@ -0,0 +1,577 @@
1
+ /**
2
+ * Gatekeeper — Stateless Verification for Bounded Execution
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:
15
+ * 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: [...] }
21
+ */
22
+
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';
33
+ import { getProfile } from './profiles';
34
+ import type {
35
+ GatekeeperRequest,
36
+ GatekeeperResult,
37
+ GatekeeperError,
38
+ AgentProfile,
39
+ AgentBoundsParams,
40
+ AgentContextParams,
41
+ ExecutionLogQuery,
42
+ CumulativeFieldDef,
43
+ } from './types';
44
+
45
+ /**
46
+ * Verify an execution request against attested authorization.
47
+ *
48
+ * For v0.4 profiles (have boundsSchema), the `frame` param is interpreted as `bounds`,
49
+ * and the optional `context` param is used for the context hash check.
50
+ *
51
+ * For v0.3 profiles (have frameSchema only), existing logic is used unchanged.
52
+ *
53
+ * @param request - The frame/bounds, attestations, execution values, and optional context
54
+ * @param publicKeyHex - The SP's public key in hex (cached locally by MCP server)
55
+ * @param now - Current timestamp in seconds (for testing)
56
+ * @param executionLog - Optional execution log for resolving cumulative fields
57
+ */
58
+ export async function verify(
59
+ request: GatekeeperRequest,
60
+ publicKeyHex: string,
61
+ now: number = Math.floor(Date.now() / 1000),
62
+ executionLog?: ExecutionLogQuery,
63
+ ): Promise<GatekeeperResult> {
64
+ const errors: GatekeeperError[] = [];
65
+
66
+ // 1. Resolve profile from frame/bounds
67
+ const profileId = request.frame.profile;
68
+ if (typeof profileId !== 'string') {
69
+ return { approved: false, errors: [{ code: 'INVALID_PROFILE', message: 'Missing profile in frame' }] };
70
+ }
71
+
72
+ const profile = getProfile(profileId);
73
+ if (!profile) {
74
+ return { approved: false, errors: [{ code: 'INVALID_PROFILE', message: `Unknown profile: ${profileId}` }] };
75
+ }
76
+
77
+ // Detect v0.4 vs v0.3 based on profile schema
78
+ const isV4Profile = !!profile.boundsSchema;
79
+
80
+ if (isV4Profile) {
81
+ return verifyV4(request, profile, publicKeyHex, now, executionLog);
82
+ } else {
83
+ return verifyV3(request, profile, publicKeyHex, now, executionLog, errors);
84
+ }
85
+ }
86
+
87
+ // ─── v0.3 Verification ────────────────────────────────────────────────────────
88
+
89
+ async function verifyV3(
90
+ request: GatekeeperRequest,
91
+ profile: AgentProfile,
92
+ publicKeyHex: string,
93
+ now: number,
94
+ executionLog: ExecutionLogQuery | undefined,
95
+ errors: GatekeeperError[],
96
+ ): Promise<GatekeeperResult> {
97
+ const pathId = request.frame.path;
98
+ if (typeof pathId !== 'string') {
99
+ return { approved: false, errors: [{ code: 'INVALID_PROFILE', message: 'Missing path in frame' }] };
100
+ }
101
+
102
+ const executionPath = profile.executionPaths[pathId];
103
+ if (!executionPath) {
104
+ return { approved: false, errors: [{ code: 'INVALID_PROFILE', message: `Unknown execution path: ${pathId}` }] };
105
+ }
106
+
107
+ let expectedFrameHash: string;
108
+ try {
109
+ expectedFrameHash = computeFrameHash(request.frame, profile);
110
+ } catch (err) {
111
+ return { approved: false, errors: [{ code: 'FRAME_MISMATCH', message: `Frame hash computation failed: ${err}` }] };
112
+ }
113
+
114
+ // Verify attestations
115
+ const requiredDomains = executionPath.requiredDomains ?? [];
116
+ const coveredDomains = new Set<string>();
117
+
118
+ for (const blob of request.attestations) {
119
+ let attestation;
120
+ try {
121
+ attestation = decodeAttestationBlob(blob);
122
+ } catch {
123
+ errors.push({ code: 'MALFORMED_ATTESTATION', message: 'Failed to decode attestation blob' });
124
+ continue;
125
+ }
126
+
127
+ try {
128
+ await verifyAttestationSignature(attestation, publicKeyHex);
129
+ } catch {
130
+ errors.push({ code: 'INVALID_SIGNATURE', message: 'Attestation signature verification failed' });
131
+ continue;
132
+ }
133
+
134
+ try {
135
+ verifyFrameHash(attestation, expectedFrameHash);
136
+ } catch {
137
+ errors.push({ code: 'FRAME_MISMATCH', message: 'Attestation frame_hash does not match computed frame_hash' });
138
+ continue;
139
+ }
140
+
141
+ try {
142
+ checkAttestationExpiry(attestation.payload, now);
143
+ } catch {
144
+ const domainNames = attestation.payload.resolved_domains.map(d => d.domain).join(', ');
145
+ errors.push({ code: 'TTL_EXPIRED', message: `Attestation for domain "${domainNames}" has expired` });
146
+ continue;
147
+ }
148
+
149
+ for (const rd of attestation.payload.resolved_domains) {
150
+ coveredDomains.add(rd.domain);
151
+ }
152
+ }
153
+
154
+ for (const domain of requiredDomains) {
155
+ if (!coveredDomains.has(domain)) {
156
+ errors.push({ code: 'DOMAIN_NOT_COVERED', message: `Required domain "${domain}" not covered by any valid attestation` });
157
+ }
158
+ }
159
+
160
+ if (errors.length > 0) {
161
+ return { approved: false, errors };
162
+ }
163
+
164
+ // Resolve cumulative fields
165
+ if (executionLog && profile.executionContextSchema?.fields) {
166
+ const cumulativeErrors = resolveCumulativeFields(request, profile, executionLog, now);
167
+ if (cumulativeErrors.length > 0) {
168
+ return { approved: false, errors: cumulativeErrors };
169
+ }
170
+ }
171
+
172
+ // Check bounds using frameSchema
173
+ const boundsErrors = checkBoundsFromFrameSchema(request, profile);
174
+ if (boundsErrors.length > 0) {
175
+ return { approved: false, errors: boundsErrors };
176
+ }
177
+
178
+ return { approved: true };
179
+ }
180
+
181
+ // ─── v0.4 Verification ────────────────────────────────────────────────────────
182
+
183
+ async function verifyV4(
184
+ request: GatekeeperRequest,
185
+ profile: AgentProfile,
186
+ publicKeyHex: string,
187
+ now: number,
188
+ executionLog: ExecutionLogQuery | undefined,
189
+ ): Promise<GatekeeperResult> {
190
+ const errors: GatekeeperError[] = [];
191
+
192
+ // In v0.4 the `frame` param carries bounds; `context` carries context params
193
+ const bounds = request.frame as AgentBoundsParams;
194
+ const context: AgentContextParams = request.context ?? {};
195
+
196
+ const pathId = bounds.path;
197
+ if (typeof pathId !== 'string') {
198
+ return { approved: false, errors: [{ code: 'INVALID_PROFILE', message: 'Missing path in bounds' }] };
199
+ }
200
+
201
+ const executionPath = profile.executionPaths[pathId];
202
+ if (!executionPath) {
203
+ return { approved: false, errors: [{ code: 'INVALID_PROFILE', message: `Unknown execution path: ${pathId}` }] };
204
+ }
205
+
206
+ // Compute expected hashes
207
+ let expectedBoundsHash: string;
208
+ let expectedContextHash: string;
209
+
210
+ try {
211
+ expectedBoundsHash = computeBoundsHash(bounds, profile);
212
+ } catch (err) {
213
+ return { approved: false, errors: [{ code: 'BOUNDS_MISMATCH', message: `Bounds hash computation failed: ${err}` }] };
214
+ }
215
+
216
+ try {
217
+ expectedContextHash = computeContextHash(context, profile);
218
+ } catch (err) {
219
+ return { approved: false, errors: [{ code: 'CONTEXT_MISMATCH', message: `Context hash computation failed: ${err}` }] };
220
+ }
221
+
222
+ // Verify attestations (requiredDomains may be undefined in v0.4 — domains come from SP group config)
223
+ const requiredDomains = executionPath.requiredDomains ?? [];
224
+ const coveredDomains = new Set<string>();
225
+
226
+ for (const blob of request.attestations) {
227
+ let attestation;
228
+ try {
229
+ attestation = decodeAttestationBlob(blob);
230
+ } catch {
231
+ errors.push({ code: 'MALFORMED_ATTESTATION', message: 'Failed to decode attestation blob' });
232
+ continue;
233
+ }
234
+
235
+ try {
236
+ await verifyAttestationSignature(attestation, publicKeyHex);
237
+ } catch {
238
+ errors.push({ code: 'INVALID_SIGNATURE', message: 'Attestation signature verification failed' });
239
+ continue;
240
+ }
241
+
242
+ // Verify bounds hash
243
+ try {
244
+ verifyBoundsHash(attestation, expectedBoundsHash);
245
+ } catch {
246
+ errors.push({ code: 'BOUNDS_MISMATCH', message: 'Attestation bounds_hash does not match computed bounds_hash' });
247
+ continue;
248
+ }
249
+
250
+ // Verify context hash (only for v0.4 attestations that have context_hash)
251
+ if (isV4Attestation(attestation)) {
252
+ try {
253
+ verifyContextHash(attestation, expectedContextHash);
254
+ } catch {
255
+ errors.push({ code: 'CONTEXT_MISMATCH', message: 'Attestation context_hash does not match computed context_hash' });
256
+ continue;
257
+ }
258
+ }
259
+
260
+ try {
261
+ checkAttestationExpiry(attestation.payload, now);
262
+ } catch {
263
+ const domainNames = attestation.payload.resolved_domains.map(d => d.domain).join(', ');
264
+ errors.push({ code: 'TTL_EXPIRED', message: `Attestation for domain "${domainNames}" has expired` });
265
+ continue;
266
+ }
267
+
268
+ for (const rd of attestation.payload.resolved_domains) {
269
+ coveredDomains.add(rd.domain);
270
+ }
271
+ }
272
+
273
+ for (const domain of requiredDomains) {
274
+ if (!coveredDomains.has(domain)) {
275
+ errors.push({ code: 'DOMAIN_NOT_COVERED', message: `Required domain "${domain}" not covered by any valid attestation` });
276
+ }
277
+ }
278
+
279
+ if (errors.length > 0) {
280
+ return { approved: false, errors };
281
+ }
282
+
283
+ // Resolve cumulative fields from execution log
284
+ if (executionLog && profile.executionContextSchema?.fields) {
285
+ const cumulativeErrors = resolveCumulativeFields(request, profile, executionLog, now);
286
+ if (cumulativeErrors.length > 0) {
287
+ return { approved: false, errors: cumulativeErrors };
288
+ }
289
+ }
290
+
291
+ // Check bounds using boundsSchema
292
+ const boundsErrors = checkBoundsFromBoundsSchema(request, profile);
293
+ if (boundsErrors.length > 0) {
294
+ return { approved: false, errors: boundsErrors };
295
+ }
296
+
297
+ // Check context constraints using contextSchema
298
+ if (profile.contextSchema && Object.keys(profile.contextSchema.fields).length > 0) {
299
+ const contextErrors = checkContextConstraints(context, request.execution, profile);
300
+ if (contextErrors.length > 0) {
301
+ return { approved: false, errors: contextErrors };
302
+ }
303
+ }
304
+
305
+ return { approved: true };
306
+ }
307
+
308
+ // ─── Bounds Checking ─────────────────────────────────────────────────────────
309
+
310
+ /**
311
+ * Check execution values against authorization frame bounds (v0.3).
312
+ * Uses the profile's frameSchema constraint definitions.
313
+ */
314
+ function checkBoundsFromFrameSchema(request: GatekeeperRequest, profile: AgentProfile): GatekeeperError[] {
315
+ const errors: GatekeeperError[] = [];
316
+
317
+ if (!profile.frameSchema) return errors;
318
+
319
+ for (const [fieldName, fieldDef] of Object.entries(profile.frameSchema.fields)) {
320
+ if (!fieldDef.constraint) continue;
321
+
322
+ const constraint = fieldDef.constraint;
323
+
324
+ for (const enforceType of constraint.enforceable) {
325
+ if (enforceType === 'max') {
326
+ const execField = fieldName.replace(/_max$/, '');
327
+ const boundValue = request.frame[fieldName];
328
+ const actualValue = request.execution[execField];
329
+
330
+ if (actualValue === undefined) continue;
331
+
332
+ if (typeof boundValue !== 'number' || typeof actualValue !== 'number') {
333
+ errors.push({
334
+ code: 'BOUND_EXCEEDED',
335
+ field: execField,
336
+ message: `Bound check requires numeric values for "${execField}"`,
337
+ bound: boundValue,
338
+ actual: actualValue,
339
+ });
340
+ continue;
341
+ }
342
+
343
+ if (actualValue > boundValue) {
344
+ errors.push({
345
+ code: 'BOUND_EXCEEDED',
346
+ field: execField,
347
+ message: `Value ${actualValue} exceeds authorized maximum of ${boundValue}`,
348
+ bound: boundValue,
349
+ actual: actualValue,
350
+ });
351
+ }
352
+ }
353
+
354
+ if (enforceType === 'enum') {
355
+ const boundValue = request.frame[fieldName];
356
+ const actualValue = request.execution[fieldName];
357
+
358
+ if (actualValue === undefined) continue;
359
+
360
+ const allowed = typeof boundValue === 'string'
361
+ ? boundValue.split(',').map(s => s.trim())
362
+ : [String(boundValue)];
363
+
364
+ const actualStr = String(actualValue);
365
+
366
+ if (!allowed.includes(actualStr)) {
367
+ errors.push({
368
+ code: 'BOUND_EXCEEDED',
369
+ field: fieldName,
370
+ message: `Value "${actualStr}" not in authorized values [${allowed.join(', ')}]`,
371
+ bound: boundValue,
372
+ actual: actualValue,
373
+ });
374
+ }
375
+ }
376
+ }
377
+ }
378
+
379
+ return errors;
380
+ }
381
+
382
+ /**
383
+ * Check execution values against boundsSchema constraints (v0.4).
384
+ * Convention: bounds field "X_max" → checks execution field "X".
385
+ */
386
+ function checkBoundsFromBoundsSchema(request: GatekeeperRequest, profile: AgentProfile): GatekeeperError[] {
387
+ const errors: GatekeeperError[] = [];
388
+ const bounds = request.frame as AgentBoundsParams;
389
+
390
+ if (!profile.boundsSchema) return errors;
391
+
392
+ for (const [fieldName, fieldDef] of Object.entries(profile.boundsSchema.fields)) {
393
+ if (!fieldDef.constraint) continue;
394
+
395
+ const constraint = fieldDef.constraint;
396
+
397
+ for (const enforceType of constraint.enforceable) {
398
+ if (enforceType === 'max') {
399
+ // Convention: bounds field "X_max" maps to execution field "X"
400
+ const execField = fieldName.replace(/_max$/, '');
401
+ const boundValue = bounds[fieldName];
402
+ const actualValue = request.execution[execField];
403
+
404
+ if (actualValue === undefined) continue;
405
+
406
+ if (typeof boundValue !== 'number' || typeof actualValue !== 'number') {
407
+ errors.push({
408
+ code: 'BOUND_EXCEEDED',
409
+ field: execField,
410
+ message: `Bound check requires numeric values for "${execField}"`,
411
+ bound: boundValue,
412
+ actual: actualValue,
413
+ });
414
+ continue;
415
+ }
416
+
417
+ if (actualValue > boundValue) {
418
+ errors.push({
419
+ code: 'BOUND_EXCEEDED',
420
+ field: execField,
421
+ message: `Value ${actualValue} exceeds authorized maximum of ${boundValue}`,
422
+ bound: boundValue,
423
+ actual: actualValue,
424
+ });
425
+ }
426
+ }
427
+ }
428
+ }
429
+
430
+ return errors;
431
+ }
432
+
433
+ /**
434
+ * Check context param values against contextSchema enum constraints (v0.4).
435
+ * Context enum fields constrain the allowed values in execution.
436
+ */
437
+ function checkContextConstraints(
438
+ context: AgentContextParams,
439
+ execution: Record<string, string | number>,
440
+ profile: AgentProfile,
441
+ ): GatekeeperError[] {
442
+ const errors: GatekeeperError[] = [];
443
+
444
+ if (!profile.contextSchema) return errors;
445
+
446
+ for (const [fieldName, fieldDef] of Object.entries(profile.contextSchema.fields)) {
447
+ if (!fieldDef.constraint) continue;
448
+
449
+ for (const enforceType of fieldDef.constraint.enforceable) {
450
+ if (enforceType === 'enum') {
451
+ // The context field value is the allowed value; execution must match
452
+ const boundValue = context[fieldName];
453
+ const actualValue = execution[fieldName];
454
+
455
+ if (actualValue === undefined) continue;
456
+
457
+ const allowed = typeof boundValue === 'string'
458
+ ? boundValue.split(',').map(s => s.trim())
459
+ : [String(boundValue)];
460
+
461
+ const actualStr = String(actualValue);
462
+
463
+ if (!allowed.includes(actualStr)) {
464
+ errors.push({
465
+ code: 'BOUND_EXCEEDED',
466
+ field: fieldName,
467
+ message: `Value "${actualStr}" not in authorized context values [${allowed.join(', ')}]`,
468
+ bound: boundValue,
469
+ actual: actualValue,
470
+ });
471
+ }
472
+ }
473
+
474
+ if (enforceType === 'subset') {
475
+ const boundValue = context[fieldName];
476
+ const actualValue = execution[fieldName];
477
+
478
+ if (boundValue === undefined || boundValue === '') continue;
479
+ if (actualValue === undefined || actualValue === '') continue;
480
+
481
+ const allowed = String(boundValue).split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
482
+ const actuals = String(actualValue).split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
483
+
484
+ const disallowed = actuals.filter(v => !allowed.includes(v));
485
+ if (disallowed.length > 0) {
486
+ errors.push({
487
+ code: 'BOUND_EXCEEDED',
488
+ field: fieldName,
489
+ message: `Values [${disallowed.join(', ')}] not in authorized set [${allowed.join(', ')}]`,
490
+ bound: boundValue,
491
+ actual: actualValue,
492
+ });
493
+ }
494
+ }
495
+ }
496
+ }
497
+
498
+ return errors;
499
+ }
500
+
501
+ // ─── Cumulative Fields ────────────────────────────────────────────────────────
502
+
503
+ /**
504
+ * Resolve cumulative fields by querying the execution log, then check their bounds.
505
+ *
506
+ * For each cumulative field in the execution context schema:
507
+ * 1. Query the execution log for the running total within the window
508
+ * 2. Add the current call's contribution (field value or +1 for _count)
509
+ * 3. Inject the resolved value into request.execution
510
+ * 4. Check against the corresponding bounds field (fieldName + "_max")
511
+ */
512
+ function resolveCumulativeFields(
513
+ request: GatekeeperRequest,
514
+ profile: AgentProfile,
515
+ executionLog: ExecutionLogQuery,
516
+ now: number,
517
+ ): GatekeeperError[] {
518
+ const errors: GatekeeperError[] = [];
519
+
520
+ // Profile ID and path come from bounds (v0.4) or frame (v0.3)
521
+ const profileId = String(request.frame.profile);
522
+ const path = String(request.frame.path);
523
+
524
+ // For v0.4, the bounds source (request.frame) holds the cumulative max fields
525
+ const boundsOrFrame = request.frame;
526
+
527
+ for (const [fieldName, fieldDef] of Object.entries(profile.executionContextSchema.fields)) {
528
+ if (fieldDef.source !== 'cumulative') continue;
529
+
530
+ const cumDef = fieldDef as CumulativeFieldDef;
531
+ const { cumulativeField, window: windowType } = cumDef;
532
+
533
+ const runningTotal = executionLog.sumByWindow(profileId, path, cumulativeField, windowType, now);
534
+
535
+ let currentContribution: number;
536
+ if (cumulativeField === '_count') {
537
+ currentContribution = 1;
538
+ } else {
539
+ const val = request.execution[cumulativeField];
540
+ currentContribution = typeof val === 'number' ? val : (val !== undefined ? Number(val) : 0);
541
+ }
542
+
543
+ const cumulativeValue = runningTotal + currentContribution;
544
+
545
+ // Inject resolved value into execution for downstream inspection
546
+ request.execution[fieldName] = cumulativeValue;
547
+
548
+ // Check against bound — convention: cumulative field "X_daily" → bound "X_daily_max"
549
+ const boundFieldName = fieldName + '_max';
550
+ const boundValue = boundsOrFrame[boundFieldName];
551
+
552
+ if (boundValue === undefined) continue;
553
+
554
+ if (typeof boundValue !== 'number') {
555
+ errors.push({
556
+ code: 'CUMULATIVE_LIMIT_EXCEEDED',
557
+ field: fieldName,
558
+ message: `Cumulative bound requires numeric value for "${boundFieldName}"`,
559
+ bound: boundValue,
560
+ actual: cumulativeValue,
561
+ });
562
+ continue;
563
+ }
564
+
565
+ if (cumulativeValue > boundValue) {
566
+ errors.push({
567
+ code: 'CUMULATIVE_LIMIT_EXCEEDED',
568
+ field: fieldName,
569
+ message: `Cumulative ${windowType} value ${cumulativeValue} exceeds limit of ${boundValue}`,
570
+ bound: boundValue,
571
+ actual: cumulativeValue,
572
+ });
573
+ }
574
+ }
575
+
576
+ return errors;
577
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * HAP Core — Agent Demo
3
+ *
4
+ * Shared protocol logic for agent-oriented bounded execution.
5
+ */
6
+
7
+ export * from './types';
8
+ export * from './frame';
9
+ export * from './attestation';
10
+ export * from './gatekeeper';
11
+ export * from './profiles';
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Profile Registry — dynamically populated from git-hosted profile sources.
3
+ */
4
+
5
+ import type { AgentProfile } from '../types';
6
+
7
+ const PROFILES: Record<string, AgentProfile> = {};
8
+
9
+ export function registerProfile(profileId: string, profile: AgentProfile): void {
10
+ PROFILES[profileId] = profile;
11
+ }
12
+
13
+ export function getProfile(profileId: string): AgentProfile | undefined {
14
+ return PROFILES[profileId];
15
+ }
16
+
17
+ export function listProfiles(): string[] {
18
+ return Object.keys(PROFILES);
19
+ }
20
+
21
+ export function getAllProfiles(): AgentProfile[] {
22
+ return Object.values(PROFILES);
23
+ }
24
+
25
+ export function clearProfiles(): void {
26
+ for (const key of Object.keys(PROFILES)) {
27
+ delete PROFILES[key];
28
+ }
29
+ }