@humanagencyp/hap-core 0.6.2 → 0.8.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.
package/dist/index.d.mts CHANGED
@@ -113,6 +113,27 @@ interface Attestation {
113
113
  interface FieldConstraint {
114
114
  type: 'number' | 'string';
115
115
  enforceable: Array<'max' | 'enum' | 'subset'>;
116
+ /**
117
+ * Action types for which this dimension MUST be present in the execution
118
+ * context. When the grant constrains this field and the action is one of
119
+ * these, an absent value is a denial rather than a skipped check.
120
+ *
121
+ * Without it, a constraint is only enforced against calls that happen to
122
+ * expose the value. Gmail's `send_message` is the live case: pass `raw` and
123
+ * `to` is never populated, so `allowed_recipients` had nothing to compare and
124
+ * the check passed — a send to an unverified recipient looked authorized.
125
+ * `send_draft` has the same shape: it transmits, and the Gatekeeper cannot
126
+ * see to whom.
127
+ *
128
+ * Keyed on `action_type` because "does this action engage recipients?" is a
129
+ * property of the action, not of the field name — deleting a draft has no
130
+ * recipients and must keep passing, while sending one must not. This keeps
131
+ * the engine profile-agnostic: it compares declared strings and knows nothing
132
+ * about email, calendars or payments.
133
+ *
134
+ * Omitted → previous behaviour: absence skips the check.
135
+ */
136
+ requiredFor?: string[];
116
137
  }
117
138
  /**
118
139
  * Frame field definition within a profile.
@@ -210,6 +231,21 @@ interface ProfileBoundsField {
210
231
  * treat a missing boundType as an error when running in v0.4 mode.
211
232
  */
212
233
  boundType?: BoundType;
234
+ /**
235
+ * Which execution action types this bound governs, e.g. `["write"]`.
236
+ *
237
+ * When present, this is authoritative. When absent, enforcement falls back to
238
+ * inferring the action type from the FIELD NAME (`send_daily_max` → `send`),
239
+ * which is a convention rather than a contract: a profile that names a bound
240
+ * after the domain concept instead of the action — calendar's
241
+ * `booking_daily_max` against an action type of `write` — matches nothing and
242
+ * is skipped, so a limit the user set is never applied and nothing says so.
243
+ *
244
+ * Declaring it removes the guess. Deliberately additive: profiles migrate one
245
+ * at a time, and every remaining fallback is logged, so the cases still
246
+ * relying on the naming convention become visible instead of staying silent.
247
+ */
248
+ appliesTo?: string[];
213
249
  /**
214
250
  * The measurement dimension of the bound's value. UI renders the unit
215
251
  * inline next to the input (e.g. `4 min`, `100 EUR`). Independent from
@@ -474,6 +510,18 @@ interface GatekeeperRequest {
474
510
  execution: Record<string, string | number>;
475
511
  /** v0.4: context parameters (currency, action_type, etc.) */
476
512
  context?: AgentContextParams;
513
+ /**
514
+ * Authorization path used to scope cumulative lookups in the execution log.
515
+ *
516
+ * Deliberately OUTSIDE `frame`: the frame is hashed and validated against the
517
+ * profile's boundsSchema, so an extra key there is rejected as an unknown
518
+ * field and breaks attestation verification outright. Cumulative checks
519
+ * previously read the path from the frame, where callers cannot legally put
520
+ * it — so it resolved to "" while the log stored real paths, no entry ever
521
+ * matched, and every running total read zero. The local gate existed but
522
+ * could never fire.
523
+ */
524
+ path?: string;
477
525
  }
478
526
  /**
479
527
  * Structured error from Gatekeeper verification.
package/dist/index.d.ts CHANGED
@@ -113,6 +113,27 @@ interface Attestation {
113
113
  interface FieldConstraint {
114
114
  type: 'number' | 'string';
115
115
  enforceable: Array<'max' | 'enum' | 'subset'>;
116
+ /**
117
+ * Action types for which this dimension MUST be present in the execution
118
+ * context. When the grant constrains this field and the action is one of
119
+ * these, an absent value is a denial rather than a skipped check.
120
+ *
121
+ * Without it, a constraint is only enforced against calls that happen to
122
+ * expose the value. Gmail's `send_message` is the live case: pass `raw` and
123
+ * `to` is never populated, so `allowed_recipients` had nothing to compare and
124
+ * the check passed — a send to an unverified recipient looked authorized.
125
+ * `send_draft` has the same shape: it transmits, and the Gatekeeper cannot
126
+ * see to whom.
127
+ *
128
+ * Keyed on `action_type` because "does this action engage recipients?" is a
129
+ * property of the action, not of the field name — deleting a draft has no
130
+ * recipients and must keep passing, while sending one must not. This keeps
131
+ * the engine profile-agnostic: it compares declared strings and knows nothing
132
+ * about email, calendars or payments.
133
+ *
134
+ * Omitted → previous behaviour: absence skips the check.
135
+ */
136
+ requiredFor?: string[];
116
137
  }
117
138
  /**
118
139
  * Frame field definition within a profile.
@@ -210,6 +231,21 @@ interface ProfileBoundsField {
210
231
  * treat a missing boundType as an error when running in v0.4 mode.
211
232
  */
212
233
  boundType?: BoundType;
234
+ /**
235
+ * Which execution action types this bound governs, e.g. `["write"]`.
236
+ *
237
+ * When present, this is authoritative. When absent, enforcement falls back to
238
+ * inferring the action type from the FIELD NAME (`send_daily_max` → `send`),
239
+ * which is a convention rather than a contract: a profile that names a bound
240
+ * after the domain concept instead of the action — calendar's
241
+ * `booking_daily_max` against an action type of `write` — matches nothing and
242
+ * is skipped, so a limit the user set is never applied and nothing says so.
243
+ *
244
+ * Declaring it removes the guess. Deliberately additive: profiles migrate one
245
+ * at a time, and every remaining fallback is logged, so the cases still
246
+ * relying on the naming convention become visible instead of staying silent.
247
+ */
248
+ appliesTo?: string[];
213
249
  /**
214
250
  * The measurement dimension of the bound's value. UI renders the unit
215
251
  * inline next to the input (e.g. `4 min`, `100 EUR`). Independent from
@@ -474,6 +510,18 @@ interface GatekeeperRequest {
474
510
  execution: Record<string, string | number>;
475
511
  /** v0.4: context parameters (currency, action_type, etc.) */
476
512
  context?: AgentContextParams;
513
+ /**
514
+ * Authorization path used to scope cumulative lookups in the execution log.
515
+ *
516
+ * Deliberately OUTSIDE `frame`: the frame is hashed and validated against the
517
+ * profile's boundsSchema, so an extra key there is rejected as an unknown
518
+ * field and breaks attestation verification outright. Cumulative checks
519
+ * previously read the path from the frame, where callers cannot legally put
520
+ * it — so it resolved to "" while the log stored real paths, no entry ever
521
+ * matched, and every running total read zero. The local gate existed but
522
+ * could never fire.
523
+ */
524
+ path?: string;
477
525
  }
478
526
  /**
479
527
  * Structured error from Gatekeeper verification.
package/dist/index.js CHANGED
@@ -626,12 +626,23 @@ function checkBoundsFromFrameSchema(request, profile) {
626
626
  }
627
627
  return errors;
628
628
  }
629
+ function boundGovernsAction(fieldName, fieldDef, bt, actionType) {
630
+ if (fieldDef.appliesTo) {
631
+ return actionType ? fieldDef.appliesTo.includes(actionType) : true;
632
+ }
633
+ if (bt.kind !== "cumulative_count" || !actionType) return true;
634
+ const prefix = fieldName.replace(/_(?:daily|monthly|weekly)_max$/, "");
635
+ const namedForAnAction = prefix !== fieldName;
636
+ const matches = prefix === actionType || prefix.startsWith("transaction");
637
+ return !namedForAnAction || matches;
638
+ }
629
639
  function checkBoundsV4(request, profile, executionLog, now) {
630
640
  const errors = [];
631
641
  if (!profile.boundsSchema) return errors;
632
642
  const bounds = request.frame;
633
643
  const profileId = String(bounds.profile ?? profile.id);
634
- const path = bounds.path ? String(bounds.path) : "";
644
+ const actionType = typeof request.execution.action_type === "string" ? request.execution.action_type : void 0;
645
+ const path = request.path ?? (bounds.path ? String(bounds.path) : "");
635
646
  for (const [fieldName, fieldDef] of Object.entries(profile.boundsSchema.fields)) {
636
647
  if (fieldName === "profile" || fieldName === "path") continue;
637
648
  const boundValue = bounds[fieldName];
@@ -647,6 +658,7 @@ function checkBoundsV4(request, profile, executionLog, now) {
647
658
  });
648
659
  continue;
649
660
  }
661
+ if (!boundGovernsAction(fieldName, fieldDef, bt, actionType)) continue;
650
662
  switch (bt.kind) {
651
663
  case "per_transaction": {
652
664
  const actual = request.execution[bt.of];
@@ -745,6 +757,23 @@ function checkContextConstraints(context, execution, profile) {
745
757
  if (!profile.contextSchema) return errors;
746
758
  for (const [fieldName, fieldDef] of Object.entries(profile.contextSchema.fields)) {
747
759
  if (!fieldDef.constraint) continue;
760
+ const requiredFor = fieldDef.constraint.requiredFor;
761
+ if (requiredFor?.length) {
762
+ const boundValue = context[fieldName];
763
+ const actualValue = execution[fieldName];
764
+ const actionType = execution["action_type"];
765
+ const constrained = boundValue !== void 0 && boundValue !== "";
766
+ const engaged = actionType !== void 0 && requiredFor.includes(String(actionType));
767
+ if (constrained && engaged && (actualValue === void 0 || actualValue === "")) {
768
+ errors.push({
769
+ code: "BOUND_EXCEEDED",
770
+ field: fieldName,
771
+ message: `Authorization limits "${fieldName}" to [${boundValue}], but this "${String(actionType)}" call exposes no ${fieldName} to check against it. Refusing: the call cannot be shown to stay in scope.`,
772
+ bound: boundValue
773
+ });
774
+ continue;
775
+ }
776
+ }
748
777
  for (const enforceType of fieldDef.constraint.enforceable) {
749
778
  if (enforceType === "enum") {
750
779
  const boundValue = context[fieldName];
package/dist/index.mjs CHANGED
@@ -555,12 +555,23 @@ function checkBoundsFromFrameSchema(request, profile) {
555
555
  }
556
556
  return errors;
557
557
  }
558
+ function boundGovernsAction(fieldName, fieldDef, bt, actionType) {
559
+ if (fieldDef.appliesTo) {
560
+ return actionType ? fieldDef.appliesTo.includes(actionType) : true;
561
+ }
562
+ if (bt.kind !== "cumulative_count" || !actionType) return true;
563
+ const prefix = fieldName.replace(/_(?:daily|monthly|weekly)_max$/, "");
564
+ const namedForAnAction = prefix !== fieldName;
565
+ const matches = prefix === actionType || prefix.startsWith("transaction");
566
+ return !namedForAnAction || matches;
567
+ }
558
568
  function checkBoundsV4(request, profile, executionLog, now) {
559
569
  const errors = [];
560
570
  if (!profile.boundsSchema) return errors;
561
571
  const bounds = request.frame;
562
572
  const profileId = String(bounds.profile ?? profile.id);
563
- const path = bounds.path ? String(bounds.path) : "";
573
+ const actionType = typeof request.execution.action_type === "string" ? request.execution.action_type : void 0;
574
+ const path = request.path ?? (bounds.path ? String(bounds.path) : "");
564
575
  for (const [fieldName, fieldDef] of Object.entries(profile.boundsSchema.fields)) {
565
576
  if (fieldName === "profile" || fieldName === "path") continue;
566
577
  const boundValue = bounds[fieldName];
@@ -576,6 +587,7 @@ function checkBoundsV4(request, profile, executionLog, now) {
576
587
  });
577
588
  continue;
578
589
  }
590
+ if (!boundGovernsAction(fieldName, fieldDef, bt, actionType)) continue;
579
591
  switch (bt.kind) {
580
592
  case "per_transaction": {
581
593
  const actual = request.execution[bt.of];
@@ -674,6 +686,23 @@ function checkContextConstraints(context, execution, profile) {
674
686
  if (!profile.contextSchema) return errors;
675
687
  for (const [fieldName, fieldDef] of Object.entries(profile.contextSchema.fields)) {
676
688
  if (!fieldDef.constraint) continue;
689
+ const requiredFor = fieldDef.constraint.requiredFor;
690
+ if (requiredFor?.length) {
691
+ const boundValue = context[fieldName];
692
+ const actualValue = execution[fieldName];
693
+ const actionType = execution["action_type"];
694
+ const constrained = boundValue !== void 0 && boundValue !== "";
695
+ const engaged = actionType !== void 0 && requiredFor.includes(String(actionType));
696
+ if (constrained && engaged && (actualValue === void 0 || actualValue === "")) {
697
+ errors.push({
698
+ code: "BOUND_EXCEEDED",
699
+ field: fieldName,
700
+ message: `Authorization limits "${fieldName}" to [${boundValue}], but this "${String(actionType)}" call exposes no ${fieldName} to check against it. Refusing: the call cannot be shown to stay in scope.`,
701
+ bound: boundValue
702
+ });
703
+ continue;
704
+ }
705
+ }
677
706
  for (const enforceType of fieldDef.constraint.enforceable) {
678
707
  if (enforceType === "enum") {
679
708
  const boundValue = context[fieldName];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanagencyp/hap-core",
3
- "version": "0.6.2",
3
+ "version": "0.8.0",
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
@@ -40,6 +40,8 @@ import type {
40
40
  AgentContextParams,
41
41
  ExecutionLogQuery,
42
42
  CumulativeFieldDef,
43
+ ProfileBoundsField,
44
+ BoundType,
43
45
  } from './types';
44
46
 
45
47
  /**
@@ -406,6 +408,41 @@ function checkBoundsFromFrameSchema(request: GatekeeperRequest, profile: AgentPr
406
408
  * enum — bound value must be in the allowed set (attest-time
407
409
  * validation; not an execution-time check)
408
410
  */
411
+ /**
412
+ * Does this bound govern the action being attempted?
413
+ *
414
+ * Cumulative totals are scoped by (profileId, path) only, so without this every
415
+ * cumulative_count bound on a profile sees the same running count. On customers
416
+ * — which declares both write_daily_max and delete_daily_max — the smaller
417
+ * delete limit would count writes and block them.
418
+ *
419
+ * Preferred answer is the profile's declared `appliesTo`. Absent that, fall back
420
+ * to the field-name convention (`delete_daily_max` → `delete`), matching how the
421
+ * Authority Server selects bounds so the two enforcement points cannot disagree.
422
+ *
423
+ * Fails CLOSED: when the action type is unknown, or the bound's name implies no
424
+ * particular action, the bound is enforced.
425
+ */
426
+ function boundGovernsAction(
427
+ fieldName: string,
428
+ fieldDef: ProfileBoundsField,
429
+ bt: BoundType,
430
+ actionType: string | undefined,
431
+ ): boolean {
432
+ if (fieldDef.appliesTo) {
433
+ return actionType ? fieldDef.appliesTo.includes(actionType) : true;
434
+ }
435
+
436
+ // The name convention only ever disambiguated count bounds; sums are governed
437
+ // by their `of` field and stay unfiltered, as at the Authority Server.
438
+ if (bt.kind !== 'cumulative_count' || !actionType) return true;
439
+
440
+ const prefix = fieldName.replace(/_(?:daily|monthly|weekly)_max$/, '');
441
+ const namedForAnAction = prefix !== fieldName;
442
+ const matches = prefix === actionType || prefix.startsWith('transaction');
443
+ return !namedForAnAction || matches;
444
+ }
445
+
409
446
  function checkBoundsV4(
410
447
  request: GatekeeperRequest,
411
448
  profile: AgentProfile,
@@ -417,7 +454,14 @@ function checkBoundsV4(
417
454
 
418
455
  const bounds = request.frame as AgentBoundsParams;
419
456
  const profileId = String(bounds.profile ?? profile.id);
420
- const path = bounds.path ? String(bounds.path) : '';
457
+ const actionType = typeof request.execution.action_type === 'string'
458
+ ? request.execution.action_type
459
+ : undefined;
460
+ // Prefer the explicit request path. `frame.path` is retained only as a
461
+ // fallback for callers that legitimately declare `path` in their profile's
462
+ // boundsSchema; for every shipped profile it is absent, which is exactly why
463
+ // this silently resolved to "" and disabled the cumulative gate entirely.
464
+ const path = request.path ?? (bounds.path ? String(bounds.path) : '');
421
465
 
422
466
  for (const [fieldName, fieldDef] of Object.entries(profile.boundsSchema.fields)) {
423
467
  if (fieldName === 'profile' || fieldName === 'path') continue;
@@ -441,6 +485,8 @@ function checkBoundsV4(
441
485
  continue;
442
486
  }
443
487
 
488
+ if (!boundGovernsAction(fieldName, fieldDef, bt, actionType)) continue;
489
+
444
490
  switch (bt.kind) {
445
491
  case 'per_transaction': {
446
492
  const actual = request.execution[bt.of];
@@ -566,6 +612,34 @@ function checkContextConstraints(
566
612
  for (const [fieldName, fieldDef] of Object.entries(profile.contextSchema.fields)) {
567
613
  if (!fieldDef.constraint) continue;
568
614
 
615
+ // A constrained dimension that the call does not expose cannot be checked.
616
+ // Skipping is only safe when the action does not engage that dimension —
617
+ // deleting a draft has no recipients. When it DOES engage it, silence must
618
+ // not read as compliance: an absent value means the Gatekeeper cannot show
619
+ // the call is in scope, and an unverifiable send is exactly what the scope
620
+ // exists to prevent. The profile declares which actions engage the field.
621
+ const requiredFor = fieldDef.constraint.requiredFor;
622
+ if (requiredFor?.length) {
623
+ const boundValue = context[fieldName];
624
+ const actualValue = execution[fieldName];
625
+ const actionType = execution['action_type'];
626
+ const constrained = boundValue !== undefined && boundValue !== '';
627
+ const engaged = actionType !== undefined && requiredFor.includes(String(actionType));
628
+
629
+ if (constrained && engaged && (actualValue === undefined || actualValue === '')) {
630
+ errors.push({
631
+ code: 'BOUND_EXCEEDED',
632
+ field: fieldName,
633
+ message:
634
+ `Authorization limits "${fieldName}" to [${boundValue}], but this ` +
635
+ `"${String(actionType)}" call exposes no ${fieldName} to check against it. ` +
636
+ `Refusing: the call cannot be shown to stay in scope.`,
637
+ bound: boundValue,
638
+ });
639
+ continue; // nothing further to compare for this field
640
+ }
641
+ }
642
+
569
643
  for (const enforceType of fieldDef.constraint.enforceable) {
570
644
  if (enforceType === 'enum') {
571
645
  // The context field value is the allowed value; execution must match
package/src/types.ts CHANGED
@@ -121,6 +121,27 @@ export interface Attestation {
121
121
  export interface FieldConstraint {
122
122
  type: 'number' | 'string';
123
123
  enforceable: Array<'max' | 'enum' | 'subset'>;
124
+ /**
125
+ * Action types for which this dimension MUST be present in the execution
126
+ * context. When the grant constrains this field and the action is one of
127
+ * these, an absent value is a denial rather than a skipped check.
128
+ *
129
+ * Without it, a constraint is only enforced against calls that happen to
130
+ * expose the value. Gmail's `send_message` is the live case: pass `raw` and
131
+ * `to` is never populated, so `allowed_recipients` had nothing to compare and
132
+ * the check passed — a send to an unverified recipient looked authorized.
133
+ * `send_draft` has the same shape: it transmits, and the Gatekeeper cannot
134
+ * see to whom.
135
+ *
136
+ * Keyed on `action_type` because "does this action engage recipients?" is a
137
+ * property of the action, not of the field name — deleting a draft has no
138
+ * recipients and must keep passing, while sending one must not. This keeps
139
+ * the engine profile-agnostic: it compares declared strings and knows nothing
140
+ * about email, calendars or payments.
141
+ *
142
+ * Omitted → previous behaviour: absence skips the check.
143
+ */
144
+ requiredFor?: string[];
124
145
  }
125
146
 
126
147
  /**
@@ -213,6 +234,21 @@ export interface ProfileBoundsField {
213
234
  * treat a missing boundType as an error when running in v0.4 mode.
214
235
  */
215
236
  boundType?: BoundType;
237
+ /**
238
+ * Which execution action types this bound governs, e.g. `["write"]`.
239
+ *
240
+ * When present, this is authoritative. When absent, enforcement falls back to
241
+ * inferring the action type from the FIELD NAME (`send_daily_max` → `send`),
242
+ * which is a convention rather than a contract: a profile that names a bound
243
+ * after the domain concept instead of the action — calendar's
244
+ * `booking_daily_max` against an action type of `write` — matches nothing and
245
+ * is skipped, so a limit the user set is never applied and nothing says so.
246
+ *
247
+ * Declaring it removes the guess. Deliberately additive: profiles migrate one
248
+ * at a time, and every remaining fallback is logged, so the cases still
249
+ * relying on the naming convention become visible instead of staying silent.
250
+ */
251
+ appliesTo?: string[];
216
252
  /**
217
253
  * The measurement dimension of the bound's value. UI renders the unit
218
254
  * inline next to the input (e.g. `4 min`, `100 EUR`). Independent from
@@ -502,6 +538,18 @@ export interface GatekeeperRequest {
502
538
  execution: Record<string, string | number>;
503
539
  /** v0.4: context parameters (currency, action_type, etc.) */
504
540
  context?: AgentContextParams;
541
+ /**
542
+ * Authorization path used to scope cumulative lookups in the execution log.
543
+ *
544
+ * Deliberately OUTSIDE `frame`: the frame is hashed and validated against the
545
+ * profile's boundsSchema, so an extra key there is rejected as an unknown
546
+ * field and breaks attestation verification outright. Cumulative checks
547
+ * previously read the path from the frame, where callers cannot legally put
548
+ * it — so it resolved to "" while the log stored real paths, no entry ever
549
+ * matched, and every running total read zero. The local gate existed but
550
+ * could never fire.
551
+ */
552
+ path?: string;
505
553
  }
506
554
 
507
555
  /**