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