@humanagencyp/hap-core 0.4.2 → 0.4.4

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,15 +435,9 @@ 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 };
438
+ const v4BoundsErrors = checkBoundsV4(request, profile, executionLog, now);
439
+ if (v4BoundsErrors.length > 0) {
440
+ return { approved: false, errors: v4BoundsErrors };
447
441
  }
448
442
  if (context && profile.contextSchema && Object.keys(profile.contextSchema.fields).length > 0) {
449
443
  const contextErrors = checkContextConstraints(context, request.execution, profile);
@@ -505,38 +499,115 @@ function checkBoundsFromFrameSchema(request, profile) {
505
499
  }
506
500
  return errors;
507
501
  }
508
- function checkBoundsFromBoundsSchema(request, profile) {
502
+ function checkBoundsV4(request, profile, executionLog, now) {
509
503
  const errors = [];
510
- const bounds = request.frame;
511
504
  if (!profile.boundsSchema) return errors;
505
+ const bounds = request.frame;
506
+ const profileId = String(bounds.profile ?? profile.id);
507
+ const path = bounds.path ? String(bounds.path) : "";
512
508
  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") {
509
+ if (fieldName === "profile" || fieldName === "path") continue;
510
+ const boundValue = bounds[fieldName];
511
+ if (boundValue === void 0) continue;
512
+ const bt = fieldDef.boundType;
513
+ if (!bt) {
514
+ errors.push({
515
+ code: "BOUND_EXCEEDED",
516
+ field: fieldName,
517
+ message: `Profile ${profile.id} bound "${fieldName}" has no boundType \u2014 enforcement semantics undefined. Add boundType to the profile's boundsSchema.`,
518
+ bound: boundValue,
519
+ actual: boundValue
520
+ });
521
+ continue;
522
+ }
523
+ switch (bt.kind) {
524
+ case "per_transaction": {
525
+ const actual = request.execution[bt.of];
526
+ if (actual === void 0) continue;
527
+ if (typeof boundValue !== "number" || typeof actual !== "number") {
522
528
  errors.push({
523
529
  code: "BOUND_EXCEEDED",
524
- field: execField,
525
- message: `Bound check requires numeric values for "${execField}"`,
530
+ field: bt.of,
531
+ message: `Bound "${fieldName}" requires numeric values (bound=${boundValue}, actual=${actual})`,
526
532
  bound: boundValue,
527
- actual: actualValue
533
+ actual
528
534
  });
529
- continue;
535
+ break;
530
536
  }
531
- if (actualValue > boundValue) {
537
+ if (actual > boundValue) {
532
538
  errors.push({
533
539
  code: "BOUND_EXCEEDED",
534
- field: execField,
535
- message: `Value ${actualValue} exceeds authorized maximum of ${boundValue}`,
540
+ field: bt.of,
541
+ message: `Value ${actual} exceeds authorized maximum of ${boundValue} for ${fieldName}`,
536
542
  bound: boundValue,
537
- actual: actualValue
543
+ actual
544
+ });
545
+ }
546
+ break;
547
+ }
548
+ case "cumulative_sum": {
549
+ if (typeof boundValue !== "number") {
550
+ errors.push({
551
+ code: "CUMULATIVE_LIMIT_EXCEEDED",
552
+ field: bt.of,
553
+ message: `Cumulative bound "${fieldName}" must be numeric`,
554
+ bound: boundValue,
555
+ actual: 0
556
+ });
557
+ break;
558
+ }
559
+ if (!executionLog) break;
560
+ const runningTotal = executionLog.sumByWindow(profileId, path, bt.of, bt.window, now);
561
+ const currentRaw = request.execution[bt.of];
562
+ const current = typeof currentRaw === "number" ? currentRaw : currentRaw !== void 0 ? Number(currentRaw) : 0;
563
+ const total = runningTotal + current;
564
+ if (total > boundValue) {
565
+ errors.push({
566
+ code: "CUMULATIVE_LIMIT_EXCEEDED",
567
+ field: bt.of,
568
+ message: `Cumulative ${bt.window} sum of ${bt.of} (${total}) exceeds limit of ${boundValue} for ${fieldName}`,
569
+ bound: boundValue,
570
+ actual: total
571
+ });
572
+ }
573
+ break;
574
+ }
575
+ case "cumulative_count": {
576
+ if (typeof boundValue !== "number") {
577
+ errors.push({
578
+ code: "CUMULATIVE_LIMIT_EXCEEDED",
579
+ field: fieldName,
580
+ message: `Cumulative count bound "${fieldName}" must be numeric`,
581
+ bound: boundValue,
582
+ actual: 0
583
+ });
584
+ break;
585
+ }
586
+ if (!executionLog) break;
587
+ const runningCount = executionLog.sumByWindow(profileId, path, "_count", bt.window, now);
588
+ const total = runningCount + 1;
589
+ if (total > boundValue) {
590
+ errors.push({
591
+ code: "CUMULATIVE_LIMIT_EXCEEDED",
592
+ field: fieldName,
593
+ message: `Cumulative ${bt.window} count (${total}) exceeds limit of ${boundValue} for ${fieldName}`,
594
+ bound: boundValue,
595
+ actual: total
596
+ });
597
+ }
598
+ break;
599
+ }
600
+ case "enum": {
601
+ if (typeof boundValue === "string" && !bt.values.includes(boundValue)) {
602
+ errors.push({
603
+ code: "BOUND_EXCEEDED",
604
+ field: fieldName,
605
+ message: `Bound "${fieldName}"="${boundValue}" is not in allowed values [${bt.values.join(", ")}]`,
606
+ bound: boundValue,
607
+ actual: boundValue
538
608
  });
539
609
  }
610
+ break;
540
611
  }
541
612
  }
542
613
  }
package/dist/index.mjs CHANGED
@@ -373,15 +373,9 @@ 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 };
376
+ const v4BoundsErrors = checkBoundsV4(request, profile, executionLog, now);
377
+ if (v4BoundsErrors.length > 0) {
378
+ return { approved: false, errors: v4BoundsErrors };
385
379
  }
386
380
  if (context && profile.contextSchema && Object.keys(profile.contextSchema.fields).length > 0) {
387
381
  const contextErrors = checkContextConstraints(context, request.execution, profile);
@@ -443,38 +437,115 @@ function checkBoundsFromFrameSchema(request, profile) {
443
437
  }
444
438
  return errors;
445
439
  }
446
- function checkBoundsFromBoundsSchema(request, profile) {
440
+ function checkBoundsV4(request, profile, executionLog, now) {
447
441
  const errors = [];
448
- const bounds = request.frame;
449
442
  if (!profile.boundsSchema) return errors;
443
+ const bounds = request.frame;
444
+ const profileId = String(bounds.profile ?? profile.id);
445
+ const path = bounds.path ? String(bounds.path) : "";
450
446
  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") {
447
+ if (fieldName === "profile" || fieldName === "path") continue;
448
+ const boundValue = bounds[fieldName];
449
+ if (boundValue === void 0) continue;
450
+ const bt = fieldDef.boundType;
451
+ if (!bt) {
452
+ errors.push({
453
+ code: "BOUND_EXCEEDED",
454
+ field: fieldName,
455
+ message: `Profile ${profile.id} bound "${fieldName}" has no boundType \u2014 enforcement semantics undefined. Add boundType to the profile's boundsSchema.`,
456
+ bound: boundValue,
457
+ actual: boundValue
458
+ });
459
+ continue;
460
+ }
461
+ switch (bt.kind) {
462
+ case "per_transaction": {
463
+ const actual = request.execution[bt.of];
464
+ if (actual === void 0) continue;
465
+ if (typeof boundValue !== "number" || typeof actual !== "number") {
460
466
  errors.push({
461
467
  code: "BOUND_EXCEEDED",
462
- field: execField,
463
- message: `Bound check requires numeric values for "${execField}"`,
468
+ field: bt.of,
469
+ message: `Bound "${fieldName}" requires numeric values (bound=${boundValue}, actual=${actual})`,
464
470
  bound: boundValue,
465
- actual: actualValue
471
+ actual
466
472
  });
467
- continue;
473
+ break;
468
474
  }
469
- if (actualValue > boundValue) {
475
+ if (actual > boundValue) {
470
476
  errors.push({
471
477
  code: "BOUND_EXCEEDED",
472
- field: execField,
473
- message: `Value ${actualValue} exceeds authorized maximum of ${boundValue}`,
478
+ field: bt.of,
479
+ message: `Value ${actual} exceeds authorized maximum of ${boundValue} for ${fieldName}`,
474
480
  bound: boundValue,
475
- actual: actualValue
481
+ actual
482
+ });
483
+ }
484
+ break;
485
+ }
486
+ case "cumulative_sum": {
487
+ if (typeof boundValue !== "number") {
488
+ errors.push({
489
+ code: "CUMULATIVE_LIMIT_EXCEEDED",
490
+ field: bt.of,
491
+ message: `Cumulative bound "${fieldName}" must be numeric`,
492
+ bound: boundValue,
493
+ actual: 0
494
+ });
495
+ break;
496
+ }
497
+ if (!executionLog) break;
498
+ const runningTotal = executionLog.sumByWindow(profileId, path, bt.of, bt.window, now);
499
+ const currentRaw = request.execution[bt.of];
500
+ const current = typeof currentRaw === "number" ? currentRaw : currentRaw !== void 0 ? Number(currentRaw) : 0;
501
+ const total = runningTotal + current;
502
+ if (total > boundValue) {
503
+ errors.push({
504
+ code: "CUMULATIVE_LIMIT_EXCEEDED",
505
+ field: bt.of,
506
+ message: `Cumulative ${bt.window} sum of ${bt.of} (${total}) exceeds limit of ${boundValue} for ${fieldName}`,
507
+ bound: boundValue,
508
+ actual: total
509
+ });
510
+ }
511
+ break;
512
+ }
513
+ case "cumulative_count": {
514
+ if (typeof boundValue !== "number") {
515
+ errors.push({
516
+ code: "CUMULATIVE_LIMIT_EXCEEDED",
517
+ field: fieldName,
518
+ message: `Cumulative count bound "${fieldName}" must be numeric`,
519
+ bound: boundValue,
520
+ actual: 0
521
+ });
522
+ break;
523
+ }
524
+ if (!executionLog) break;
525
+ const runningCount = executionLog.sumByWindow(profileId, path, "_count", bt.window, now);
526
+ const total = runningCount + 1;
527
+ if (total > boundValue) {
528
+ errors.push({
529
+ code: "CUMULATIVE_LIMIT_EXCEEDED",
530
+ field: fieldName,
531
+ message: `Cumulative ${bt.window} count (${total}) exceeds limit of ${boundValue} for ${fieldName}`,
532
+ bound: boundValue,
533
+ actual: total
534
+ });
535
+ }
536
+ break;
537
+ }
538
+ case "enum": {
539
+ if (typeof boundValue === "string" && !bt.values.includes(boundValue)) {
540
+ errors.push({
541
+ code: "BOUND_EXCEEDED",
542
+ field: fieldName,
543
+ message: `Bound "${fieldName}"="${boundValue}" is not in allowed values [${bt.values.join(", ")}]`,
544
+ bound: boundValue,
545
+ actual: boundValue
476
546
  });
477
547
  }
548
+ break;
478
549
  }
479
550
  }
480
551
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanagencyp/hap-core",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Core types, cryptographic primitives, and verification logic for the Human Agency Protocol",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -26,7 +26,10 @@
26
26
  "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
27
27
  "test": "vitest run",
28
28
  "test:watch": "vitest",
29
- "prepublishOnly": "pnpm build"
29
+ "prepublishOnly": "pnpm build",
30
+ "release:patch": "pnpm version patch && git push --follow-tags",
31
+ "release:minor": "pnpm version minor && git push --follow-tags",
32
+ "release:major": "pnpm version major && git push --follow-tags"
30
33
  },
31
34
  "dependencies": {
32
35
  "@noble/ed25519": "^2.1.0"
package/src/gatekeeper.ts CHANGED
@@ -264,18 +264,14 @@ 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
- }
273
- }
274
-
275
- // Check bounds using boundsSchema
276
- const boundsErrors = checkBoundsFromBoundsSchema(request, profile);
277
- if (boundsErrors.length > 0) {
278
- return { approved: false, errors: boundsErrors };
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 };
279
275
  }
280
276
 
281
277
  // Check context constraints using contextSchema
@@ -364,49 +360,170 @@ function checkBoundsFromFrameSchema(request: GatekeeperRequest, profile: AgentPr
364
360
  }
365
361
 
366
362
  /**
367
- * Check execution values against boundsSchema constraints (v0.4).
368
- * Convention: bounds field "X_max" → checks execution field "X".
363
+ * v0.4 bounds enforcement — dispatches on each bound field's declared
364
+ * `boundType.kind` with no name-pattern heuristics. This is the single
365
+ * source of truth for client-side bounds checking in v0.4.
366
+ *
367
+ * Why this replaces the old two-function approach:
368
+ *
369
+ * checkBoundsFromBoundsSchema (old) stripped "_max" from the bound name
370
+ * to guess the execution context field, which required a fixed naming
371
+ * convention (e.g., "amount_max" → "amount"). Profiles that used other
372
+ * conventions silently failed to enforce.
373
+ *
374
+ * resolveCumulativeFields (old) appended "_max" to the cumulative
375
+ * execution context field name to find its corresponding bound, which
376
+ * required ANOTHER fixed naming convention (e.g., "amount_daily" →
377
+ * "amount_daily_max"). Profiles where the ctx field added a "_count_"
378
+ * infix (write_count_daily → write_daily_max, not
379
+ * write_count_daily_max) silently failed too.
380
+ *
381
+ * The new function ignores ctx field names entirely and reads
382
+ * enforcement semantics from `fieldDef.boundType`:
383
+ *
384
+ * per_transaction — compares `execution[of] <= bound`
385
+ * cumulative_sum — sumByWindow(of, window) + execution[of] <= bound
386
+ * cumulative_count — sumByWindow('_count', window) + 1 <= bound
387
+ * enum — bound value must be in the allowed set (attest-time
388
+ * validation; not an execution-time check)
369
389
  */
370
- function checkBoundsFromBoundsSchema(request: GatekeeperRequest, profile: AgentProfile): GatekeeperError[] {
390
+ function checkBoundsV4(
391
+ request: GatekeeperRequest,
392
+ profile: AgentProfile,
393
+ executionLog: ExecutionLogQuery | undefined,
394
+ now: number,
395
+ ): GatekeeperError[] {
371
396
  const errors: GatekeeperError[] = [];
372
- const bounds = request.frame as AgentBoundsParams;
373
-
374
397
  if (!profile.boundsSchema) return errors;
375
398
 
376
- for (const [fieldName, fieldDef] of Object.entries(profile.boundsSchema.fields)) {
377
- if (!fieldDef.constraint) continue;
399
+ const bounds = request.frame as AgentBoundsParams;
400
+ const profileId = String(bounds.profile ?? profile.id);
401
+ const path = bounds.path ? String(bounds.path) : '';
378
402
 
379
- const constraint = fieldDef.constraint;
403
+ for (const [fieldName, fieldDef] of Object.entries(profile.boundsSchema.fields)) {
404
+ if (fieldName === 'profile' || fieldName === 'path') continue;
380
405
 
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];
406
+ const boundValue = bounds[fieldName];
407
+ if (boundValue === undefined) continue;
387
408
 
388
- if (actualValue === undefined) continue;
409
+ const bt = fieldDef.boundType;
410
+ if (!bt) {
411
+ // v0.4 requires boundType on every non-metadata bound field. A
412
+ // missing boundType is a profile authoring bug — fail closed on
413
+ // enforcement rather than silently skipping (which is what caused
414
+ // the write_daily_max display bug in the first place).
415
+ errors.push({
416
+ code: 'BOUND_EXCEEDED',
417
+ field: fieldName,
418
+ message: `Profile ${profile.id} bound "${fieldName}" has no boundType — enforcement semantics undefined. Add boundType to the profile's boundsSchema.`,
419
+ bound: boundValue,
420
+ actual: boundValue,
421
+ });
422
+ continue;
423
+ }
389
424
 
390
- if (typeof boundValue !== 'number' || typeof actualValue !== 'number') {
425
+ switch (bt.kind) {
426
+ case 'per_transaction': {
427
+ const actual = request.execution[bt.of];
428
+ if (actual === undefined) continue;
429
+ if (typeof boundValue !== 'number' || typeof actual !== 'number') {
391
430
  errors.push({
392
431
  code: 'BOUND_EXCEEDED',
393
- field: execField,
394
- message: `Bound check requires numeric values for "${execField}"`,
432
+ field: bt.of,
433
+ message: `Bound "${fieldName}" requires numeric values (bound=${boundValue}, actual=${actual})`,
395
434
  bound: boundValue,
396
- actual: actualValue,
435
+ actual,
436
+ });
437
+ break;
438
+ }
439
+ if (actual > boundValue) {
440
+ errors.push({
441
+ code: 'BOUND_EXCEEDED',
442
+ field: bt.of,
443
+ message: `Value ${actual} exceeds authorized maximum of ${boundValue} for ${fieldName}`,
444
+ bound: boundValue,
445
+ actual,
397
446
  });
398
- continue;
399
447
  }
448
+ break;
449
+ }
400
450
 
401
- if (actualValue > boundValue) {
451
+ case 'cumulative_sum': {
452
+ if (typeof boundValue !== 'number') {
453
+ errors.push({
454
+ code: 'CUMULATIVE_LIMIT_EXCEEDED',
455
+ field: bt.of,
456
+ message: `Cumulative bound "${fieldName}" must be numeric`,
457
+ bound: boundValue,
458
+ actual: 0,
459
+ });
460
+ break;
461
+ }
462
+ if (!executionLog) break;
463
+
464
+ const runningTotal = executionLog.sumByWindow(profileId, path, bt.of, bt.window, now);
465
+ const currentRaw = request.execution[bt.of];
466
+ const current = typeof currentRaw === 'number'
467
+ ? currentRaw
468
+ : (currentRaw !== undefined ? Number(currentRaw) : 0);
469
+ const total = runningTotal + current;
470
+
471
+ if (total > boundValue) {
472
+ errors.push({
473
+ code: 'CUMULATIVE_LIMIT_EXCEEDED',
474
+ field: bt.of,
475
+ message: `Cumulative ${bt.window} sum of ${bt.of} (${total}) exceeds limit of ${boundValue} for ${fieldName}`,
476
+ bound: boundValue,
477
+ actual: total,
478
+ });
479
+ }
480
+ break;
481
+ }
482
+
483
+ case 'cumulative_count': {
484
+ if (typeof boundValue !== 'number') {
485
+ errors.push({
486
+ code: 'CUMULATIVE_LIMIT_EXCEEDED',
487
+ field: fieldName,
488
+ message: `Cumulative count bound "${fieldName}" must be numeric`,
489
+ bound: boundValue,
490
+ actual: 0,
491
+ });
492
+ break;
493
+ }
494
+ if (!executionLog) break;
495
+
496
+ const runningCount = executionLog.sumByWindow(profileId, path, '_count', bt.window, now);
497
+ const total = runningCount + 1;
498
+
499
+ if (total > boundValue) {
500
+ errors.push({
501
+ code: 'CUMULATIVE_LIMIT_EXCEEDED',
502
+ field: fieldName,
503
+ message: `Cumulative ${bt.window} count (${total}) exceeds limit of ${boundValue} for ${fieldName}`,
504
+ bound: boundValue,
505
+ actual: total,
506
+ });
507
+ }
508
+ break;
509
+ }
510
+
511
+ case 'enum': {
512
+ // Enum bounds are capability flags. The stored bound value must
513
+ // be in the allowed set (a profile authoring check, really
514
+ // belongs at attestation time). They are NOT compared against
515
+ // runtime execution here — tool-proxy gates tool calls against
516
+ // the manifest's required value before they reach the gatekeeper.
517
+ if (typeof boundValue === 'string' && !bt.values.includes(boundValue)) {
402
518
  errors.push({
403
519
  code: 'BOUND_EXCEEDED',
404
- field: execField,
405
- message: `Value ${actualValue} exceeds authorized maximum of ${boundValue}`,
520
+ field: fieldName,
521
+ message: `Bound "${fieldName}"="${boundValue}" is not in allowed values [${bt.values.join(', ')}]`,
406
522
  bound: boundValue,
407
- actual: actualValue,
523
+ actual: boundValue,
408
524
  });
409
525
  }
526
+ break;
410
527
  }
411
528
  }
412
529
  }
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