@humanagencyp/hap-core 0.4.3 → 0.4.5

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/index.d.mts CHANGED
@@ -25,6 +25,16 @@ interface AttestationPayload {
25
25
  execution_context_hash: string;
26
26
  resolved_domains: ResolvedDomain[];
27
27
  gate_content_hashes: Record<string, string>;
28
+ /**
29
+ * v0.4 — commitment mode chosen by the decision owner at attestation time.
30
+ * - 'automatic': agent may invoke bounded tools without per-action review.
31
+ * - 'review': every tool call requires an approved proposal before the
32
+ * receipt route will sign a receipt.
33
+ *
34
+ * Cryptographically bound in the signed payload so a compromised SP cannot
35
+ * silently flip 'review' → 'automatic'. Absent on v0.3 attestations.
36
+ */
37
+ commitment_mode?: 'automatic' | 'review';
28
38
  issued_at: number;
29
39
  expires_at: number;
30
40
  }
@@ -53,8 +63,67 @@ interface ProfileFrameField {
53
63
  constraint?: FieldConstraint;
54
64
  enum?: string[];
55
65
  }
66
+ /**
67
+ * Bound enforcement semantics — how a v0.4 bound is checked.
68
+ *
69
+ * Every bounds field in a v0.4 profile declares a `boundType` so the SP
70
+ * receipt route and hap-core gatekeeper can dispatch on it directly,
71
+ * without parsing field names or guessing conventions. This is the
72
+ * single source of truth for "what does this bound mean at enforcement
73
+ * time" — if a new kind is needed, add a variant here and update the
74
+ * dispatch sites.
75
+ *
76
+ * See `ProfileBoundsField.boundType`.
77
+ */
78
+ type BoundType =
79
+ /**
80
+ * Per-transaction cap. The execution context field named in `of` must
81
+ * satisfy `execution[of] <= bound` for the current call. No cumulative
82
+ * tracking. Used by: amount_max, recipient_max, booking_duration_max, etc.
83
+ */
84
+ {
85
+ kind: 'per_transaction';
86
+ of: string;
87
+ }
88
+ /**
89
+ * Cumulative sum within a time window. The SP maintains a running sum
90
+ * of `execution[of]` across all prior executions in the window; the
91
+ * current call is approved iff `running_sum + execution[of] <= bound`.
92
+ * Used by: amount_daily_max, spend_monthly_max, etc.
93
+ */
94
+ | {
95
+ kind: 'cumulative_sum';
96
+ of: string;
97
+ window: CumulativeWindow;
98
+ }
99
+ /**
100
+ * Cumulative count within a time window. Every qualifying execution
101
+ * counts as +1; the current call is approved iff
102
+ * `running_count + 1 <= bound`. No execution context field is read.
103
+ * Used by: write_daily_max, post_monthly_max, booking_daily_max, etc.
104
+ */
105
+ | {
106
+ kind: 'cumulative_count';
107
+ window: CumulativeWindow;
108
+ }
109
+ /**
110
+ * String bound restricted to a fixed set of allowed values. The bound's
111
+ * value must be one of `values` at attestation time. The gateway
112
+ * tool-proxy gates tool calls based on the stored bound (via the
113
+ * integration manifest's `boundField` + `requiredValue`). Not cumulated,
114
+ * not checked by the SP receipt route — it's a capability flag.
115
+ * Used by: read_access, delete_access, archive_access.
116
+ */
117
+ | {
118
+ kind: 'enum';
119
+ values: readonly string[];
120
+ };
56
121
  /**
57
122
  * Bounds field definition within a v0.4 profile.
123
+ *
124
+ * v0.4 adds the required `boundType` — an explicit declaration of how
125
+ * the bound is enforced. The older `constraint.enforceable` pattern is
126
+ * deprecated and superseded by `boundType`.
58
127
  */
59
128
  interface ProfileBoundsField {
60
129
  type: 'string' | 'number';
@@ -62,7 +131,16 @@ interface ProfileBoundsField {
62
131
  description?: string;
63
132
  displayName?: string;
64
133
  format?: 'email' | 'domain' | 'url' | 'currency';
134
+ /**
135
+ * v0.4 enforcement semantics. Required for all new profiles.
136
+ * Optional here only so the type stays backward compatible with v0.3
137
+ * profiles that predate the boundType convention — consumers must
138
+ * treat a missing boundType as an error when running in v0.4 mode.
139
+ */
140
+ boundType?: BoundType;
141
+ /** @deprecated v0.4: use boundType instead. */
65
142
  constraint?: FieldConstraint;
143
+ /** @deprecated v0.4: use boundType: { kind: 'enum', values: [...] }. */
66
144
  enum?: string[];
67
145
  }
68
146
  /**
@@ -502,4 +580,4 @@ declare function listProfiles(): string[];
502
580
  declare function getAllProfiles(): AgentProfile[];
503
581
  declare function clearProfiles(): void;
504
582
 
505
- export { type AgentBoundsParams, type AgentContextParams, type AgentFrameParams, type AgentProfile, type Attestation, type AttestationHeader, type AttestationPayload, type CumulativeFieldDef, type CumulativeWindow, type DeclaredFieldDef, type ExecutionContextFieldDef, type ExecutionLogEntry, type ExecutionLogQuery, type ExecutionMappingTransform, type ExecutionMappingValue, type ExecutionPath, type FieldConstraint, type GateQuestion, type GatekeeperError, type GatekeeperRequest, type GatekeeperResult, type ProfileBoundsField, type ProfileContextField, type ProfileFrameField, type ProfileToolGating, type ProfileToolGatingEntry, type ResolvedDomain, attestationId, canonicalBounds, canonicalContext, canonicalFrame, checkAttestationExpiry, clearProfiles, computeBoundsHash, computeContextHash, computeFrameHash, decodeAttestationBlob, encodeAttestationBlob, frameHash, getAllProfiles, getProfile, isV4Attestation, listProfiles, registerProfile, validateBoundsParams, validateContextParams, validateFrameParams, verify, verifyAttestation, verifyAttestationSignature, verifyAttestationV4, verifyBoundsHash, verifyContextHash, verifyFrameHash };
583
+ export { type AgentBoundsParams, type AgentContextParams, type AgentFrameParams, type AgentProfile, type Attestation, type AttestationHeader, type AttestationPayload, type BoundType, type CumulativeFieldDef, type CumulativeWindow, type DeclaredFieldDef, type ExecutionContextFieldDef, type ExecutionLogEntry, type ExecutionLogQuery, type ExecutionMappingTransform, type ExecutionMappingValue, type ExecutionPath, type FieldConstraint, type GateQuestion, type GatekeeperError, type GatekeeperRequest, type GatekeeperResult, type ProfileBoundsField, type ProfileContextField, type ProfileFrameField, type ProfileToolGating, type ProfileToolGatingEntry, type ResolvedDomain, attestationId, canonicalBounds, canonicalContext, canonicalFrame, checkAttestationExpiry, clearProfiles, computeBoundsHash, computeContextHash, computeFrameHash, decodeAttestationBlob, encodeAttestationBlob, frameHash, getAllProfiles, getProfile, isV4Attestation, listProfiles, registerProfile, validateBoundsParams, validateContextParams, validateFrameParams, verify, verifyAttestation, verifyAttestationSignature, verifyAttestationV4, verifyBoundsHash, verifyContextHash, verifyFrameHash };
package/dist/index.d.ts CHANGED
@@ -25,6 +25,16 @@ interface AttestationPayload {
25
25
  execution_context_hash: string;
26
26
  resolved_domains: ResolvedDomain[];
27
27
  gate_content_hashes: Record<string, string>;
28
+ /**
29
+ * v0.4 — commitment mode chosen by the decision owner at attestation time.
30
+ * - 'automatic': agent may invoke bounded tools without per-action review.
31
+ * - 'review': every tool call requires an approved proposal before the
32
+ * receipt route will sign a receipt.
33
+ *
34
+ * Cryptographically bound in the signed payload so a compromised SP cannot
35
+ * silently flip 'review' → 'automatic'. Absent on v0.3 attestations.
36
+ */
37
+ commitment_mode?: 'automatic' | 'review';
28
38
  issued_at: number;
29
39
  expires_at: number;
30
40
  }
@@ -53,8 +63,67 @@ interface ProfileFrameField {
53
63
  constraint?: FieldConstraint;
54
64
  enum?: string[];
55
65
  }
66
+ /**
67
+ * Bound enforcement semantics — how a v0.4 bound is checked.
68
+ *
69
+ * Every bounds field in a v0.4 profile declares a `boundType` so the SP
70
+ * receipt route and hap-core gatekeeper can dispatch on it directly,
71
+ * without parsing field names or guessing conventions. This is the
72
+ * single source of truth for "what does this bound mean at enforcement
73
+ * time" — if a new kind is needed, add a variant here and update the
74
+ * dispatch sites.
75
+ *
76
+ * See `ProfileBoundsField.boundType`.
77
+ */
78
+ type BoundType =
79
+ /**
80
+ * Per-transaction cap. The execution context field named in `of` must
81
+ * satisfy `execution[of] <= bound` for the current call. No cumulative
82
+ * tracking. Used by: amount_max, recipient_max, booking_duration_max, etc.
83
+ */
84
+ {
85
+ kind: 'per_transaction';
86
+ of: string;
87
+ }
88
+ /**
89
+ * Cumulative sum within a time window. The SP maintains a running sum
90
+ * of `execution[of]` across all prior executions in the window; the
91
+ * current call is approved iff `running_sum + execution[of] <= bound`.
92
+ * Used by: amount_daily_max, spend_monthly_max, etc.
93
+ */
94
+ | {
95
+ kind: 'cumulative_sum';
96
+ of: string;
97
+ window: CumulativeWindow;
98
+ }
99
+ /**
100
+ * Cumulative count within a time window. Every qualifying execution
101
+ * counts as +1; the current call is approved iff
102
+ * `running_count + 1 <= bound`. No execution context field is read.
103
+ * Used by: write_daily_max, post_monthly_max, booking_daily_max, etc.
104
+ */
105
+ | {
106
+ kind: 'cumulative_count';
107
+ window: CumulativeWindow;
108
+ }
109
+ /**
110
+ * String bound restricted to a fixed set of allowed values. The bound's
111
+ * value must be one of `values` at attestation time. The gateway
112
+ * tool-proxy gates tool calls based on the stored bound (via the
113
+ * integration manifest's `boundField` + `requiredValue`). Not cumulated,
114
+ * not checked by the SP receipt route — it's a capability flag.
115
+ * Used by: read_access, delete_access, archive_access.
116
+ */
117
+ | {
118
+ kind: 'enum';
119
+ values: readonly string[];
120
+ };
56
121
  /**
57
122
  * Bounds field definition within a v0.4 profile.
123
+ *
124
+ * v0.4 adds the required `boundType` — an explicit declaration of how
125
+ * the bound is enforced. The older `constraint.enforceable` pattern is
126
+ * deprecated and superseded by `boundType`.
58
127
  */
59
128
  interface ProfileBoundsField {
60
129
  type: 'string' | 'number';
@@ -62,7 +131,16 @@ interface ProfileBoundsField {
62
131
  description?: string;
63
132
  displayName?: string;
64
133
  format?: 'email' | 'domain' | 'url' | 'currency';
134
+ /**
135
+ * v0.4 enforcement semantics. Required for all new profiles.
136
+ * Optional here only so the type stays backward compatible with v0.3
137
+ * profiles that predate the boundType convention — consumers must
138
+ * treat a missing boundType as an error when running in v0.4 mode.
139
+ */
140
+ boundType?: BoundType;
141
+ /** @deprecated v0.4: use boundType instead. */
65
142
  constraint?: FieldConstraint;
143
+ /** @deprecated v0.4: use boundType: { kind: 'enum', values: [...] }. */
66
144
  enum?: string[];
67
145
  }
68
146
  /**
@@ -502,4 +580,4 @@ declare function listProfiles(): string[];
502
580
  declare function getAllProfiles(): AgentProfile[];
503
581
  declare function clearProfiles(): void;
504
582
 
505
- export { type AgentBoundsParams, type AgentContextParams, type AgentFrameParams, type AgentProfile, type Attestation, type AttestationHeader, type AttestationPayload, type CumulativeFieldDef, type CumulativeWindow, type DeclaredFieldDef, type ExecutionContextFieldDef, type ExecutionLogEntry, type ExecutionLogQuery, type ExecutionMappingTransform, type ExecutionMappingValue, type ExecutionPath, type FieldConstraint, type GateQuestion, type GatekeeperError, type GatekeeperRequest, type GatekeeperResult, type ProfileBoundsField, type ProfileContextField, type ProfileFrameField, type ProfileToolGating, type ProfileToolGatingEntry, type ResolvedDomain, attestationId, canonicalBounds, canonicalContext, canonicalFrame, checkAttestationExpiry, clearProfiles, computeBoundsHash, computeContextHash, computeFrameHash, decodeAttestationBlob, encodeAttestationBlob, frameHash, getAllProfiles, getProfile, isV4Attestation, listProfiles, registerProfile, validateBoundsParams, validateContextParams, validateFrameParams, verify, verifyAttestation, verifyAttestationSignature, verifyAttestationV4, verifyBoundsHash, verifyContextHash, verifyFrameHash };
583
+ export { type AgentBoundsParams, type AgentContextParams, type AgentFrameParams, type AgentProfile, type Attestation, type AttestationHeader, type AttestationPayload, type BoundType, type CumulativeFieldDef, type CumulativeWindow, type DeclaredFieldDef, type ExecutionContextFieldDef, type ExecutionLogEntry, type ExecutionLogQuery, type ExecutionMappingTransform, type ExecutionMappingValue, type ExecutionPath, type FieldConstraint, type GateQuestion, type GatekeeperError, type GatekeeperRequest, type GatekeeperResult, type ProfileBoundsField, type ProfileContextField, type ProfileFrameField, type ProfileToolGating, type ProfileToolGatingEntry, type ResolvedDomain, attestationId, canonicalBounds, canonicalContext, canonicalFrame, checkAttestationExpiry, clearProfiles, computeBoundsHash, computeContextHash, computeFrameHash, decodeAttestationBlob, encodeAttestationBlob, frameHash, getAllProfiles, getProfile, isV4Attestation, listProfiles, registerProfile, validateBoundsParams, validateContextParams, validateFrameParams, verify, verifyAttestation, verifyAttestationSignature, verifyAttestationV4, verifyBoundsHash, verifyContextHash, verifyFrameHash };
package/dist/index.js CHANGED
@@ -435,20 +435,26 @@ async function verifyV4(request, profile, publicKeyHex, now, executionLog) {
435
435
  if (errors.length > 0) {
436
436
  return { approved: false, errors };
437
437
  }
438
- if (executionLog && profile.executionContextSchema?.fields) {
439
- const cumulativeErrors = resolveCumulativeFields(request, profile, executionLog, now);
440
- if (cumulativeErrors.length > 0) {
441
- return { approved: false, errors: cumulativeErrors };
442
- }
443
- }
444
- const boundsErrors = checkBoundsFromBoundsSchema(request, profile);
445
- if (boundsErrors.length > 0) {
446
- return { approved: false, errors: boundsErrors };
447
- }
448
- if (context && profile.contextSchema && Object.keys(profile.contextSchema.fields).length > 0) {
449
- const contextErrors = checkContextConstraints(context, request.execution, profile);
450
- if (contextErrors.length > 0) {
451
- return { approved: false, errors: contextErrors };
438
+ const v4BoundsErrors = checkBoundsV4(request, profile, executionLog, now);
439
+ if (v4BoundsErrors.length > 0) {
440
+ return { approved: false, errors: v4BoundsErrors };
441
+ }
442
+ if (profile.contextSchema && Object.keys(profile.contextSchema.fields).length > 0) {
443
+ const enforceableFields = Object.entries(profile.contextSchema.fields).filter(([, def]) => def.constraint?.enforceable?.length);
444
+ if (enforceableFields.length > 0 && !context) {
445
+ return {
446
+ approved: false,
447
+ errors: [{
448
+ code: "BOUND_EXCEEDED",
449
+ message: `Declared context required for constraint enforcement but not provided. Fields: ${enforceableFields.map(([n]) => n).join(", ")}`
450
+ }]
451
+ };
452
+ }
453
+ if (context) {
454
+ const contextErrors = checkContextConstraints(context, request.execution, profile);
455
+ if (contextErrors.length > 0) {
456
+ return { approved: false, errors: contextErrors };
457
+ }
452
458
  }
453
459
  }
454
460
  return { approved: true };
@@ -505,38 +511,115 @@ function checkBoundsFromFrameSchema(request, profile) {
505
511
  }
506
512
  return errors;
507
513
  }
508
- function checkBoundsFromBoundsSchema(request, profile) {
514
+ function checkBoundsV4(request, profile, executionLog, now) {
509
515
  const errors = [];
510
- const bounds = request.frame;
511
516
  if (!profile.boundsSchema) return errors;
517
+ const bounds = request.frame;
518
+ const profileId = String(bounds.profile ?? profile.id);
519
+ const path = bounds.path ? String(bounds.path) : "";
512
520
  for (const [fieldName, fieldDef] of Object.entries(profile.boundsSchema.fields)) {
513
- if (!fieldDef.constraint) continue;
514
- const constraint = fieldDef.constraint;
515
- for (const enforceType of constraint.enforceable) {
516
- if (enforceType === "max") {
517
- const execField = fieldName.replace(/_max$/, "");
518
- const boundValue = bounds[fieldName];
519
- const actualValue = request.execution[execField];
520
- if (actualValue === void 0) continue;
521
- if (typeof boundValue !== "number" || typeof actualValue !== "number") {
521
+ if (fieldName === "profile" || fieldName === "path") continue;
522
+ const boundValue = bounds[fieldName];
523
+ if (boundValue === void 0) continue;
524
+ const bt = fieldDef.boundType;
525
+ if (!bt) {
526
+ errors.push({
527
+ code: "BOUND_EXCEEDED",
528
+ field: fieldName,
529
+ message: `Profile ${profile.id} bound "${fieldName}" has no boundType \u2014 enforcement semantics undefined. Add boundType to the profile's boundsSchema.`,
530
+ bound: boundValue,
531
+ actual: boundValue
532
+ });
533
+ continue;
534
+ }
535
+ switch (bt.kind) {
536
+ case "per_transaction": {
537
+ const actual = request.execution[bt.of];
538
+ if (actual === void 0) continue;
539
+ if (typeof boundValue !== "number" || typeof actual !== "number") {
522
540
  errors.push({
523
541
  code: "BOUND_EXCEEDED",
524
- field: execField,
525
- message: `Bound check requires numeric values for "${execField}"`,
542
+ field: bt.of,
543
+ message: `Bound "${fieldName}" requires numeric values (bound=${boundValue}, actual=${actual})`,
526
544
  bound: boundValue,
527
- actual: actualValue
545
+ actual
528
546
  });
529
- continue;
547
+ break;
530
548
  }
531
- if (actualValue > boundValue) {
549
+ if (actual > boundValue) {
532
550
  errors.push({
533
551
  code: "BOUND_EXCEEDED",
534
- field: execField,
535
- message: `Value ${actualValue} exceeds authorized maximum of ${boundValue}`,
552
+ field: bt.of,
553
+ message: `Value ${actual} exceeds authorized maximum of ${boundValue} for ${fieldName}`,
536
554
  bound: boundValue,
537
- actual: actualValue
555
+ actual
556
+ });
557
+ }
558
+ break;
559
+ }
560
+ case "cumulative_sum": {
561
+ if (typeof boundValue !== "number") {
562
+ errors.push({
563
+ code: "CUMULATIVE_LIMIT_EXCEEDED",
564
+ field: bt.of,
565
+ message: `Cumulative bound "${fieldName}" must be numeric`,
566
+ bound: boundValue,
567
+ actual: 0
568
+ });
569
+ break;
570
+ }
571
+ if (!executionLog) break;
572
+ const runningTotal = executionLog.sumByWindow(profileId, path, bt.of, bt.window, now);
573
+ const currentRaw = request.execution[bt.of];
574
+ const current = typeof currentRaw === "number" ? currentRaw : currentRaw !== void 0 ? Number(currentRaw) : 0;
575
+ const total = runningTotal + current;
576
+ if (total > boundValue) {
577
+ errors.push({
578
+ code: "CUMULATIVE_LIMIT_EXCEEDED",
579
+ field: bt.of,
580
+ message: `Cumulative ${bt.window} sum of ${bt.of} (${total}) exceeds limit of ${boundValue} for ${fieldName}`,
581
+ bound: boundValue,
582
+ actual: total
583
+ });
584
+ }
585
+ break;
586
+ }
587
+ case "cumulative_count": {
588
+ if (typeof boundValue !== "number") {
589
+ errors.push({
590
+ code: "CUMULATIVE_LIMIT_EXCEEDED",
591
+ field: fieldName,
592
+ message: `Cumulative count bound "${fieldName}" must be numeric`,
593
+ bound: boundValue,
594
+ actual: 0
595
+ });
596
+ break;
597
+ }
598
+ if (!executionLog) break;
599
+ const runningCount = executionLog.sumByWindow(profileId, path, "_count", bt.window, now);
600
+ const total = runningCount + 1;
601
+ if (total > boundValue) {
602
+ errors.push({
603
+ code: "CUMULATIVE_LIMIT_EXCEEDED",
604
+ field: fieldName,
605
+ message: `Cumulative ${bt.window} count (${total}) exceeds limit of ${boundValue} for ${fieldName}`,
606
+ bound: boundValue,
607
+ actual: total
608
+ });
609
+ }
610
+ break;
611
+ }
612
+ case "enum": {
613
+ if (typeof boundValue === "string" && !bt.values.includes(boundValue)) {
614
+ errors.push({
615
+ code: "BOUND_EXCEEDED",
616
+ field: fieldName,
617
+ message: `Bound "${fieldName}"="${boundValue}" is not in allowed values [${bt.values.join(", ")}]`,
618
+ bound: boundValue,
619
+ actual: boundValue
538
620
  });
539
621
  }
622
+ break;
540
623
  }
541
624
  }
542
625
  }
package/dist/index.mjs CHANGED
@@ -373,20 +373,26 @@ async function verifyV4(request, profile, publicKeyHex, now, executionLog) {
373
373
  if (errors.length > 0) {
374
374
  return { approved: false, errors };
375
375
  }
376
- if (executionLog && profile.executionContextSchema?.fields) {
377
- const cumulativeErrors = resolveCumulativeFields(request, profile, executionLog, now);
378
- if (cumulativeErrors.length > 0) {
379
- return { approved: false, errors: cumulativeErrors };
380
- }
381
- }
382
- const boundsErrors = checkBoundsFromBoundsSchema(request, profile);
383
- if (boundsErrors.length > 0) {
384
- return { approved: false, errors: boundsErrors };
385
- }
386
- if (context && profile.contextSchema && Object.keys(profile.contextSchema.fields).length > 0) {
387
- const contextErrors = checkContextConstraints(context, request.execution, profile);
388
- if (contextErrors.length > 0) {
389
- return { approved: false, errors: contextErrors };
376
+ const v4BoundsErrors = checkBoundsV4(request, profile, executionLog, now);
377
+ if (v4BoundsErrors.length > 0) {
378
+ return { approved: false, errors: v4BoundsErrors };
379
+ }
380
+ if (profile.contextSchema && Object.keys(profile.contextSchema.fields).length > 0) {
381
+ const enforceableFields = Object.entries(profile.contextSchema.fields).filter(([, def]) => def.constraint?.enforceable?.length);
382
+ if (enforceableFields.length > 0 && !context) {
383
+ return {
384
+ approved: false,
385
+ errors: [{
386
+ code: "BOUND_EXCEEDED",
387
+ message: `Declared context required for constraint enforcement but not provided. Fields: ${enforceableFields.map(([n]) => n).join(", ")}`
388
+ }]
389
+ };
390
+ }
391
+ if (context) {
392
+ const contextErrors = checkContextConstraints(context, request.execution, profile);
393
+ if (contextErrors.length > 0) {
394
+ return { approved: false, errors: contextErrors };
395
+ }
390
396
  }
391
397
  }
392
398
  return { approved: true };
@@ -443,38 +449,115 @@ function checkBoundsFromFrameSchema(request, profile) {
443
449
  }
444
450
  return errors;
445
451
  }
446
- function checkBoundsFromBoundsSchema(request, profile) {
452
+ function checkBoundsV4(request, profile, executionLog, now) {
447
453
  const errors = [];
448
- const bounds = request.frame;
449
454
  if (!profile.boundsSchema) return errors;
455
+ const bounds = request.frame;
456
+ const profileId = String(bounds.profile ?? profile.id);
457
+ const path = bounds.path ? String(bounds.path) : "";
450
458
  for (const [fieldName, fieldDef] of Object.entries(profile.boundsSchema.fields)) {
451
- if (!fieldDef.constraint) continue;
452
- const constraint = fieldDef.constraint;
453
- for (const enforceType of constraint.enforceable) {
454
- if (enforceType === "max") {
455
- const execField = fieldName.replace(/_max$/, "");
456
- const boundValue = bounds[fieldName];
457
- const actualValue = request.execution[execField];
458
- if (actualValue === void 0) continue;
459
- if (typeof boundValue !== "number" || typeof actualValue !== "number") {
459
+ if (fieldName === "profile" || fieldName === "path") continue;
460
+ const boundValue = bounds[fieldName];
461
+ if (boundValue === void 0) continue;
462
+ const bt = fieldDef.boundType;
463
+ if (!bt) {
464
+ errors.push({
465
+ code: "BOUND_EXCEEDED",
466
+ field: fieldName,
467
+ message: `Profile ${profile.id} bound "${fieldName}" has no boundType \u2014 enforcement semantics undefined. Add boundType to the profile's boundsSchema.`,
468
+ bound: boundValue,
469
+ actual: boundValue
470
+ });
471
+ continue;
472
+ }
473
+ switch (bt.kind) {
474
+ case "per_transaction": {
475
+ const actual = request.execution[bt.of];
476
+ if (actual === void 0) continue;
477
+ if (typeof boundValue !== "number" || typeof actual !== "number") {
460
478
  errors.push({
461
479
  code: "BOUND_EXCEEDED",
462
- field: execField,
463
- message: `Bound check requires numeric values for "${execField}"`,
480
+ field: bt.of,
481
+ message: `Bound "${fieldName}" requires numeric values (bound=${boundValue}, actual=${actual})`,
464
482
  bound: boundValue,
465
- actual: actualValue
483
+ actual
466
484
  });
467
- continue;
485
+ break;
468
486
  }
469
- if (actualValue > boundValue) {
487
+ if (actual > boundValue) {
470
488
  errors.push({
471
489
  code: "BOUND_EXCEEDED",
472
- field: execField,
473
- message: `Value ${actualValue} exceeds authorized maximum of ${boundValue}`,
490
+ field: bt.of,
491
+ message: `Value ${actual} exceeds authorized maximum of ${boundValue} for ${fieldName}`,
474
492
  bound: boundValue,
475
- actual: actualValue
493
+ actual
494
+ });
495
+ }
496
+ break;
497
+ }
498
+ case "cumulative_sum": {
499
+ if (typeof boundValue !== "number") {
500
+ errors.push({
501
+ code: "CUMULATIVE_LIMIT_EXCEEDED",
502
+ field: bt.of,
503
+ message: `Cumulative bound "${fieldName}" must be numeric`,
504
+ bound: boundValue,
505
+ actual: 0
506
+ });
507
+ break;
508
+ }
509
+ if (!executionLog) break;
510
+ const runningTotal = executionLog.sumByWindow(profileId, path, bt.of, bt.window, now);
511
+ const currentRaw = request.execution[bt.of];
512
+ const current = typeof currentRaw === "number" ? currentRaw : currentRaw !== void 0 ? Number(currentRaw) : 0;
513
+ const total = runningTotal + current;
514
+ if (total > boundValue) {
515
+ errors.push({
516
+ code: "CUMULATIVE_LIMIT_EXCEEDED",
517
+ field: bt.of,
518
+ message: `Cumulative ${bt.window} sum of ${bt.of} (${total}) exceeds limit of ${boundValue} for ${fieldName}`,
519
+ bound: boundValue,
520
+ actual: total
521
+ });
522
+ }
523
+ break;
524
+ }
525
+ case "cumulative_count": {
526
+ if (typeof boundValue !== "number") {
527
+ errors.push({
528
+ code: "CUMULATIVE_LIMIT_EXCEEDED",
529
+ field: fieldName,
530
+ message: `Cumulative count bound "${fieldName}" must be numeric`,
531
+ bound: boundValue,
532
+ actual: 0
533
+ });
534
+ break;
535
+ }
536
+ if (!executionLog) break;
537
+ const runningCount = executionLog.sumByWindow(profileId, path, "_count", bt.window, now);
538
+ const total = runningCount + 1;
539
+ if (total > boundValue) {
540
+ errors.push({
541
+ code: "CUMULATIVE_LIMIT_EXCEEDED",
542
+ field: fieldName,
543
+ message: `Cumulative ${bt.window} count (${total}) exceeds limit of ${boundValue} for ${fieldName}`,
544
+ bound: boundValue,
545
+ actual: total
546
+ });
547
+ }
548
+ break;
549
+ }
550
+ case "enum": {
551
+ if (typeof boundValue === "string" && !bt.values.includes(boundValue)) {
552
+ errors.push({
553
+ code: "BOUND_EXCEEDED",
554
+ field: fieldName,
555
+ message: `Bound "${fieldName}"="${boundValue}" is not in allowed values [${bt.values.join(", ")}]`,
556
+ bound: boundValue,
557
+ actual: boundValue
476
558
  });
477
559
  }
560
+ break;
478
561
  }
479
562
  }
480
563
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanagencyp/hap-core",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "Core types, cryptographic primitives, and verification logic for the Human Agency Protocol",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/gatekeeper.ts CHANGED
@@ -264,25 +264,40 @@ async function verifyV4(
264
264
  return { approved: false, errors };
265
265
  }
266
266
 
267
- // Resolve cumulative fields from execution log
268
- if (executionLog && profile.executionContextSchema?.fields) {
269
- const cumulativeErrors = resolveCumulativeFields(request, profile, executionLog, now);
270
- if (cumulativeErrors.length > 0) {
271
- return { approved: false, errors: cumulativeErrors };
272
- }
267
+ // v0.4 bounds check — single pass that dispatches on each field's
268
+ // boundType.kind. Replaces the two-pass (resolveCumulativeFields +
269
+ // checkBoundsFromBoundsSchema) approach, which used name-pattern
270
+ // heuristics and silently failed when ctx field names didn't round-trip
271
+ // through `${fieldName}_max`.
272
+ const v4BoundsErrors = checkBoundsV4(request, profile, executionLog, now);
273
+ if (v4BoundsErrors.length > 0) {
274
+ return { approved: false, errors: v4BoundsErrors };
273
275
  }
274
276
 
275
- // Check bounds using boundsSchema
276
- const boundsErrors = checkBoundsFromBoundsSchema(request, profile);
277
- if (boundsErrors.length > 0) {
278
- return { approved: false, errors: boundsErrors };
279
- }
277
+ // Check context constraints using contextSchema.
278
+ // Fail closed: if the profile declares enforceable context constraints but
279
+ // no declared context was supplied, reject — the SP cannot enforce these
280
+ // (it only holds context_hash), so skipping the check would silently
281
+ // bypass scope restrictions like allowed_recipients / allowed_domains.
282
+ if (profile.contextSchema && Object.keys(profile.contextSchema.fields).length > 0) {
283
+ const enforceableFields = Object.entries(profile.contextSchema.fields)
284
+ .filter(([, def]) => def.constraint?.enforceable?.length);
285
+
286
+ if (enforceableFields.length > 0 && !context) {
287
+ return {
288
+ approved: false,
289
+ errors: [{
290
+ code: 'BOUND_EXCEEDED',
291
+ message: `Declared context required for constraint enforcement but not provided. Fields: ${enforceableFields.map(([n]) => n).join(', ')}`,
292
+ }],
293
+ };
294
+ }
280
295
 
281
- // Check context constraints using contextSchema
282
- if (context && profile.contextSchema && Object.keys(profile.contextSchema.fields).length > 0) {
283
- const contextErrors = checkContextConstraints(context, request.execution, profile);
284
- if (contextErrors.length > 0) {
285
- return { approved: false, errors: contextErrors };
296
+ if (context) {
297
+ const contextErrors = checkContextConstraints(context, request.execution, profile);
298
+ if (contextErrors.length > 0) {
299
+ return { approved: false, errors: contextErrors };
300
+ }
286
301
  }
287
302
  }
288
303
 
@@ -364,49 +379,170 @@ function checkBoundsFromFrameSchema(request: GatekeeperRequest, profile: AgentPr
364
379
  }
365
380
 
366
381
  /**
367
- * Check execution values against boundsSchema constraints (v0.4).
368
- * Convention: bounds field "X_max" → checks execution field "X".
382
+ * v0.4 bounds enforcement — dispatches on each bound field's declared
383
+ * `boundType.kind` with no name-pattern heuristics. This is the single
384
+ * source of truth for client-side bounds checking in v0.4.
385
+ *
386
+ * Why this replaces the old two-function approach:
387
+ *
388
+ * checkBoundsFromBoundsSchema (old) stripped "_max" from the bound name
389
+ * to guess the execution context field, which required a fixed naming
390
+ * convention (e.g., "amount_max" → "amount"). Profiles that used other
391
+ * conventions silently failed to enforce.
392
+ *
393
+ * resolveCumulativeFields (old) appended "_max" to the cumulative
394
+ * execution context field name to find its corresponding bound, which
395
+ * required ANOTHER fixed naming convention (e.g., "amount_daily" →
396
+ * "amount_daily_max"). Profiles where the ctx field added a "_count_"
397
+ * infix (write_count_daily → write_daily_max, not
398
+ * write_count_daily_max) silently failed too.
399
+ *
400
+ * The new function ignores ctx field names entirely and reads
401
+ * enforcement semantics from `fieldDef.boundType`:
402
+ *
403
+ * per_transaction — compares `execution[of] <= bound`
404
+ * cumulative_sum — sumByWindow(of, window) + execution[of] <= bound
405
+ * cumulative_count — sumByWindow('_count', window) + 1 <= bound
406
+ * enum — bound value must be in the allowed set (attest-time
407
+ * validation; not an execution-time check)
369
408
  */
370
- function checkBoundsFromBoundsSchema(request: GatekeeperRequest, profile: AgentProfile): GatekeeperError[] {
409
+ function checkBoundsV4(
410
+ request: GatekeeperRequest,
411
+ profile: AgentProfile,
412
+ executionLog: ExecutionLogQuery | undefined,
413
+ now: number,
414
+ ): GatekeeperError[] {
371
415
  const errors: GatekeeperError[] = [];
372
- const bounds = request.frame as AgentBoundsParams;
373
-
374
416
  if (!profile.boundsSchema) return errors;
375
417
 
376
- for (const [fieldName, fieldDef] of Object.entries(profile.boundsSchema.fields)) {
377
- if (!fieldDef.constraint) continue;
418
+ const bounds = request.frame as AgentBoundsParams;
419
+ const profileId = String(bounds.profile ?? profile.id);
420
+ const path = bounds.path ? String(bounds.path) : '';
378
421
 
379
- const constraint = fieldDef.constraint;
422
+ for (const [fieldName, fieldDef] of Object.entries(profile.boundsSchema.fields)) {
423
+ if (fieldName === 'profile' || fieldName === 'path') continue;
380
424
 
381
- for (const enforceType of constraint.enforceable) {
382
- if (enforceType === 'max') {
383
- // Convention: bounds field "X_max" maps to execution field "X"
384
- const execField = fieldName.replace(/_max$/, '');
385
- const boundValue = bounds[fieldName];
386
- const actualValue = request.execution[execField];
425
+ const boundValue = bounds[fieldName];
426
+ if (boundValue === undefined) continue;
387
427
 
388
- if (actualValue === undefined) continue;
428
+ const bt = fieldDef.boundType;
429
+ if (!bt) {
430
+ // v0.4 requires boundType on every non-metadata bound field. A
431
+ // missing boundType is a profile authoring bug — fail closed on
432
+ // enforcement rather than silently skipping (which is what caused
433
+ // the write_daily_max display bug in the first place).
434
+ errors.push({
435
+ code: 'BOUND_EXCEEDED',
436
+ field: fieldName,
437
+ message: `Profile ${profile.id} bound "${fieldName}" has no boundType — enforcement semantics undefined. Add boundType to the profile's boundsSchema.`,
438
+ bound: boundValue,
439
+ actual: boundValue,
440
+ });
441
+ continue;
442
+ }
389
443
 
390
- if (typeof boundValue !== 'number' || typeof actualValue !== 'number') {
444
+ switch (bt.kind) {
445
+ case 'per_transaction': {
446
+ const actual = request.execution[bt.of];
447
+ if (actual === undefined) continue;
448
+ if (typeof boundValue !== 'number' || typeof actual !== 'number') {
391
449
  errors.push({
392
450
  code: 'BOUND_EXCEEDED',
393
- field: execField,
394
- message: `Bound check requires numeric values for "${execField}"`,
451
+ field: bt.of,
452
+ message: `Bound "${fieldName}" requires numeric values (bound=${boundValue}, actual=${actual})`,
395
453
  bound: boundValue,
396
- actual: actualValue,
454
+ actual,
455
+ });
456
+ break;
457
+ }
458
+ if (actual > boundValue) {
459
+ errors.push({
460
+ code: 'BOUND_EXCEEDED',
461
+ field: bt.of,
462
+ message: `Value ${actual} exceeds authorized maximum of ${boundValue} for ${fieldName}`,
463
+ bound: boundValue,
464
+ actual,
397
465
  });
398
- continue;
399
466
  }
467
+ break;
468
+ }
400
469
 
401
- if (actualValue > boundValue) {
470
+ case 'cumulative_sum': {
471
+ if (typeof boundValue !== 'number') {
472
+ errors.push({
473
+ code: 'CUMULATIVE_LIMIT_EXCEEDED',
474
+ field: bt.of,
475
+ message: `Cumulative bound "${fieldName}" must be numeric`,
476
+ bound: boundValue,
477
+ actual: 0,
478
+ });
479
+ break;
480
+ }
481
+ if (!executionLog) break;
482
+
483
+ const runningTotal = executionLog.sumByWindow(profileId, path, bt.of, bt.window, now);
484
+ const currentRaw = request.execution[bt.of];
485
+ const current = typeof currentRaw === 'number'
486
+ ? currentRaw
487
+ : (currentRaw !== undefined ? Number(currentRaw) : 0);
488
+ const total = runningTotal + current;
489
+
490
+ if (total > boundValue) {
491
+ errors.push({
492
+ code: 'CUMULATIVE_LIMIT_EXCEEDED',
493
+ field: bt.of,
494
+ message: `Cumulative ${bt.window} sum of ${bt.of} (${total}) exceeds limit of ${boundValue} for ${fieldName}`,
495
+ bound: boundValue,
496
+ actual: total,
497
+ });
498
+ }
499
+ break;
500
+ }
501
+
502
+ case 'cumulative_count': {
503
+ if (typeof boundValue !== 'number') {
504
+ errors.push({
505
+ code: 'CUMULATIVE_LIMIT_EXCEEDED',
506
+ field: fieldName,
507
+ message: `Cumulative count bound "${fieldName}" must be numeric`,
508
+ bound: boundValue,
509
+ actual: 0,
510
+ });
511
+ break;
512
+ }
513
+ if (!executionLog) break;
514
+
515
+ const runningCount = executionLog.sumByWindow(profileId, path, '_count', bt.window, now);
516
+ const total = runningCount + 1;
517
+
518
+ if (total > boundValue) {
519
+ errors.push({
520
+ code: 'CUMULATIVE_LIMIT_EXCEEDED',
521
+ field: fieldName,
522
+ message: `Cumulative ${bt.window} count (${total}) exceeds limit of ${boundValue} for ${fieldName}`,
523
+ bound: boundValue,
524
+ actual: total,
525
+ });
526
+ }
527
+ break;
528
+ }
529
+
530
+ case 'enum': {
531
+ // Enum bounds are capability flags. The stored bound value must
532
+ // be in the allowed set (a profile authoring check, really
533
+ // belongs at attestation time). They are NOT compared against
534
+ // runtime execution here — tool-proxy gates tool calls against
535
+ // the manifest's required value before they reach the gatekeeper.
536
+ if (typeof boundValue === 'string' && !bt.values.includes(boundValue)) {
402
537
  errors.push({
403
538
  code: 'BOUND_EXCEEDED',
404
- field: execField,
405
- message: `Value ${actualValue} exceeds authorized maximum of ${boundValue}`,
539
+ field: fieldName,
540
+ message: `Bound "${fieldName}"="${boundValue}" is not in allowed values [${bt.values.join(', ')}]`,
406
541
  bound: boundValue,
407
- actual: actualValue,
542
+ actual: boundValue,
408
543
  });
409
544
  }
545
+ break;
410
546
  }
411
547
  }
412
548
  }
package/src/types.ts CHANGED
@@ -30,6 +30,16 @@ export interface AttestationPayload {
30
30
  execution_context_hash: string;
31
31
  resolved_domains: ResolvedDomain[];
32
32
  gate_content_hashes: Record<string, string>;
33
+ /**
34
+ * v0.4 — commitment mode chosen by the decision owner at attestation time.
35
+ * - 'automatic': agent may invoke bounded tools without per-action review.
36
+ * - 'review': every tool call requires an approved proposal before the
37
+ * receipt route will sign a receipt.
38
+ *
39
+ * Cryptographically bound in the signed payload so a compromised SP cannot
40
+ * silently flip 'review' → 'automatic'. Absent on v0.3 attestations.
41
+ */
42
+ commitment_mode?: 'automatic' | 'review';
33
43
  issued_at: number;
34
44
  expires_at: number;
35
45
  }
@@ -64,8 +74,55 @@ export interface ProfileFrameField {
64
74
  enum?: string[];
65
75
  }
66
76
 
77
+ /**
78
+ * Bound enforcement semantics — how a v0.4 bound is checked.
79
+ *
80
+ * Every bounds field in a v0.4 profile declares a `boundType` so the SP
81
+ * receipt route and hap-core gatekeeper can dispatch on it directly,
82
+ * without parsing field names or guessing conventions. This is the
83
+ * single source of truth for "what does this bound mean at enforcement
84
+ * time" — if a new kind is needed, add a variant here and update the
85
+ * dispatch sites.
86
+ *
87
+ * See `ProfileBoundsField.boundType`.
88
+ */
89
+ export type BoundType =
90
+ /**
91
+ * Per-transaction cap. The execution context field named in `of` must
92
+ * satisfy `execution[of] <= bound` for the current call. No cumulative
93
+ * tracking. Used by: amount_max, recipient_max, booking_duration_max, etc.
94
+ */
95
+ | { kind: 'per_transaction'; of: string }
96
+ /**
97
+ * Cumulative sum within a time window. The SP maintains a running sum
98
+ * of `execution[of]` across all prior executions in the window; the
99
+ * current call is approved iff `running_sum + execution[of] <= bound`.
100
+ * Used by: amount_daily_max, spend_monthly_max, etc.
101
+ */
102
+ | { kind: 'cumulative_sum'; of: string; window: CumulativeWindow }
103
+ /**
104
+ * Cumulative count within a time window. Every qualifying execution
105
+ * counts as +1; the current call is approved iff
106
+ * `running_count + 1 <= bound`. No execution context field is read.
107
+ * Used by: write_daily_max, post_monthly_max, booking_daily_max, etc.
108
+ */
109
+ | { kind: 'cumulative_count'; window: CumulativeWindow }
110
+ /**
111
+ * String bound restricted to a fixed set of allowed values. The bound's
112
+ * value must be one of `values` at attestation time. The gateway
113
+ * tool-proxy gates tool calls based on the stored bound (via the
114
+ * integration manifest's `boundField` + `requiredValue`). Not cumulated,
115
+ * not checked by the SP receipt route — it's a capability flag.
116
+ * Used by: read_access, delete_access, archive_access.
117
+ */
118
+ | { kind: 'enum'; values: readonly string[] };
119
+
67
120
  /**
68
121
  * Bounds field definition within a v0.4 profile.
122
+ *
123
+ * v0.4 adds the required `boundType` — an explicit declaration of how
124
+ * the bound is enforced. The older `constraint.enforceable` pattern is
125
+ * deprecated and superseded by `boundType`.
69
126
  */
70
127
  export interface ProfileBoundsField {
71
128
  type: 'string' | 'number';
@@ -73,7 +130,16 @@ export interface ProfileBoundsField {
73
130
  description?: string;
74
131
  displayName?: string;
75
132
  format?: 'email' | 'domain' | 'url' | 'currency';
133
+ /**
134
+ * v0.4 enforcement semantics. Required for all new profiles.
135
+ * Optional here only so the type stays backward compatible with v0.3
136
+ * profiles that predate the boundType convention — consumers must
137
+ * treat a missing boundType as an error when running in v0.4 mode.
138
+ */
139
+ boundType?: BoundType;
140
+ /** @deprecated v0.4: use boundType instead. */
76
141
  constraint?: FieldConstraint;
142
+ /** @deprecated v0.4: use boundType: { kind: 'enum', values: [...] }. */
77
143
  enum?: string[];
78
144
  }
79
145