@synoi/gap 0.1.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/LICENSE +195 -0
- package/README.md +223 -0
- package/dist/canonicalize.d.ts +19 -0
- package/dist/canonicalize.d.ts.map +1 -0
- package/dist/canonicalize.js +36 -0
- package/dist/canonicalize.js.map +1 -0
- package/dist/capabilities.d.ts +605 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +53 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/cdro.d.ts +63 -0
- package/dist/cdro.d.ts.map +1 -0
- package/dist/cdro.js +16 -0
- package/dist/cdro.js.map +1 -0
- package/dist/channels.d.ts +107 -0
- package/dist/channels.d.ts.map +1 -0
- package/dist/channels.js +29 -0
- package/dist/channels.js.map +1 -0
- package/dist/constants.d.ts +32 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +36 -0
- package/dist/constants.js.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +35 -0
- package/dist/index.js.map +1 -0
- package/dist/oid.d.ts +28 -0
- package/dist/oid.d.ts.map +1 -0
- package/dist/oid.js +68 -0
- package/dist/oid.js.map +1 -0
- package/dist/receipts.d.ts +128 -0
- package/dist/receipts.d.ts.map +1 -0
- package/dist/receipts.js +14 -0
- package/dist/receipts.js.map +1 -0
- package/dist/revocations.d.ts +65 -0
- package/dist/revocations.d.ts.map +1 -0
- package/dist/revocations.js +22 -0
- package/dist/revocations.js.map +1 -0
- package/dist/validate.d.ts +59 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +835 -0
- package/dist/validate.js.map +1 -0
- package/dist/workflows.d.ts +186 -0
- package/dist/workflows.d.ts.map +1 -0
- package/dist/workflows.js +14 -0
- package/dist/workflows.js.map +1 -0
- package/package.json +55 -0
- package/src/canonicalize.ts +38 -0
- package/src/capabilities.ts +711 -0
- package/src/cdro.ts +92 -0
- package/src/channels.ts +183 -0
- package/src/constants.ts +46 -0
- package/src/index.ts +180 -0
- package/src/oid.ts +71 -0
- package/src/receipts.ts +169 -0
- package/src/revocations.ts +90 -0
- package/src/validate.ts +1008 -0
- package/src/workflows.ts +241 -0
package/src/validate.ts
ADDED
|
@@ -0,0 +1,1008 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* validate.ts -- hand-rolled runtime validators for GAP CDROs.
|
|
3
|
+
*
|
|
4
|
+
* Design: every validator returns `{ ok, errors }`. `ok` is true iff `errors`
|
|
5
|
+
* is empty. Validators are non-throwing. They check shape (type + required
|
|
6
|
+
* fields) without semantic validation (a grant with `expires_at_ms` in the
|
|
7
|
+
* past is "shape-valid" -- separate runtime check rejects it).
|
|
8
|
+
*
|
|
9
|
+
* No zod / no io-ts. The style mirrors synoi-mcp-server/src/tools.ts:
|
|
10
|
+
* minimal, predictable, and easy to debug.
|
|
11
|
+
*
|
|
12
|
+
* Round-trip property: any envelope produced by these types, run through
|
|
13
|
+
* JSON.stringify -> JSON.parse -> validate*, produces ok=true with the same
|
|
14
|
+
* top-level keys + values.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { GapCdroEnvelope, GapObjectType } from './cdro.js'
|
|
18
|
+
import { GAP_VERSION } from './cdro.js'
|
|
19
|
+
import type {
|
|
20
|
+
GapActorType,
|
|
21
|
+
Capability,
|
|
22
|
+
CapabilityDeclaration,
|
|
23
|
+
CapabilityDeclarationBody,
|
|
24
|
+
CapabilityGrant,
|
|
25
|
+
CapabilityGrantBody,
|
|
26
|
+
CapabilityInvocation,
|
|
27
|
+
CapabilityInvocationBody,
|
|
28
|
+
CapabilityPredicate,
|
|
29
|
+
GrantedCapabilityScope,
|
|
30
|
+
ConsentRecordBody,
|
|
31
|
+
CredentialKind,
|
|
32
|
+
DelegationStep,
|
|
33
|
+
IdentityBinding,
|
|
34
|
+
McpToolCallContext,
|
|
35
|
+
OrchestrationChainBody,
|
|
36
|
+
PipResponseBody,
|
|
37
|
+
TokenBudgetArgs,
|
|
38
|
+
ExternalPipArgs,
|
|
39
|
+
} from './capabilities.js'
|
|
40
|
+
import type { TokenConsumption } from './receipts.js'
|
|
41
|
+
import type {
|
|
42
|
+
ChannelEvent,
|
|
43
|
+
ChannelEventBody,
|
|
44
|
+
} from './channels.js'
|
|
45
|
+
import type {
|
|
46
|
+
StageTransition,
|
|
47
|
+
StageTransitionBody,
|
|
48
|
+
WorkflowDefinition,
|
|
49
|
+
WorkflowDefinitionBody,
|
|
50
|
+
WorkflowInstance,
|
|
51
|
+
WorkflowInstanceBody,
|
|
52
|
+
} from './workflows.js'
|
|
53
|
+
import type {
|
|
54
|
+
GapDecisionReceipt,
|
|
55
|
+
GapDecisionReceiptBody,
|
|
56
|
+
} from './receipts.js'
|
|
57
|
+
import type {
|
|
58
|
+
RevocationEvent,
|
|
59
|
+
RevocationEventBody,
|
|
60
|
+
} from './revocations.js'
|
|
61
|
+
|
|
62
|
+
// -- Result + small helpers --------------------------------------------------
|
|
63
|
+
|
|
64
|
+
export interface ValidationResult {
|
|
65
|
+
ok: boolean
|
|
66
|
+
errors: string[]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function ok(): ValidationResult { return { ok: true, errors: [] } }
|
|
70
|
+
function fail(...errors: string[]): ValidationResult { return { ok: false, errors } }
|
|
71
|
+
function merge(...results: ValidationResult[]): ValidationResult {
|
|
72
|
+
const errors: string[] = []
|
|
73
|
+
for (const r of results) errors.push(...r.errors)
|
|
74
|
+
return { ok: errors.length === 0, errors }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isObject(v: unknown): v is Record<string, unknown> {
|
|
78
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
79
|
+
}
|
|
80
|
+
function isString(v: unknown): v is string { return typeof v === 'string' }
|
|
81
|
+
function isNumber(v: unknown): v is number { return typeof v === 'number' && Number.isFinite(v) }
|
|
82
|
+
function isInteger(v: unknown): v is number { return Number.isInteger(v as number) }
|
|
83
|
+
function isBoolean(v: unknown): v is boolean { return typeof v === 'boolean' }
|
|
84
|
+
function isArray(v: unknown): v is unknown[] { return Array.isArray(v) }
|
|
85
|
+
|
|
86
|
+
function requireField(
|
|
87
|
+
parent: string,
|
|
88
|
+
obj: Record<string, unknown>,
|
|
89
|
+
key: string,
|
|
90
|
+
predicate: (v: unknown) => boolean,
|
|
91
|
+
typeName: string,
|
|
92
|
+
): ValidationResult {
|
|
93
|
+
if (!(key in obj)) return fail(`${parent}.${key}: missing required field`)
|
|
94
|
+
if (!predicate(obj[key])) return fail(`${parent}.${key}: expected ${typeName}`)
|
|
95
|
+
return ok()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function optionalField(
|
|
99
|
+
parent: string,
|
|
100
|
+
obj: Record<string, unknown>,
|
|
101
|
+
key: string,
|
|
102
|
+
predicate: (v: unknown) => boolean,
|
|
103
|
+
typeName: string,
|
|
104
|
+
): ValidationResult {
|
|
105
|
+
if (!(key in obj) || obj[key] === undefined) return ok()
|
|
106
|
+
if (!predicate(obj[key])) return fail(`${parent}.${key}: expected ${typeName}`)
|
|
107
|
+
return ok()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isOneOf<T extends string>(values: readonly T[]): (v: unknown) => v is T {
|
|
111
|
+
return (v: unknown): v is T => typeof v === 'string' && (values as readonly string[]).includes(v)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// -- Common envelope check ---------------------------------------------------
|
|
115
|
+
|
|
116
|
+
const GAP_OBJECT_TYPES: readonly GapObjectType[] = [
|
|
117
|
+
'gap:capability_declaration',
|
|
118
|
+
'gap:capability_grant',
|
|
119
|
+
'gap:capability_invocation',
|
|
120
|
+
'gap:workflow_definition',
|
|
121
|
+
'gap:workflow_instance',
|
|
122
|
+
'gap:stage_transition',
|
|
123
|
+
'gap:channel_event',
|
|
124
|
+
'gap:decision_receipt',
|
|
125
|
+
'gap:revocation_event',
|
|
126
|
+
'gap:federation_handshake', // reserved for GAP 1.1 - accepted but not required for any conformance tier
|
|
127
|
+
'gap:break_glass_token',
|
|
128
|
+
'gap:local_override_credential',
|
|
129
|
+
'gap:lca_root',
|
|
130
|
+
'gap:erasure_event',
|
|
131
|
+
// Item 1: Agent Delegation Chain
|
|
132
|
+
'gap:orchestration_chain',
|
|
133
|
+
// Item 4: Consent Version Chain
|
|
134
|
+
'gap:consent_record',
|
|
135
|
+
// Item 7: Signed PIP Response
|
|
136
|
+
'gap:pip_response',
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
function validateEnvelopeShape(x: unknown, expectedType: GapObjectType): ValidationResult {
|
|
140
|
+
if (!isObject(x)) return fail('envelope: expected object')
|
|
141
|
+
const errors: string[] = []
|
|
142
|
+
if (!isString(x['oid'])) errors.push('envelope.oid: expected string')
|
|
143
|
+
else if (!x['oid'].startsWith('sha256:')) errors.push('envelope.oid: expected "sha256:<hex>" prefix')
|
|
144
|
+
if (x['type'] !== expectedType) errors.push(`envelope.type: expected "${expectedType}"`)
|
|
145
|
+
else if (!(GAP_OBJECT_TYPES as readonly string[]).includes(x['type'] as string)) {
|
|
146
|
+
errors.push('envelope.type: unknown GAP object type')
|
|
147
|
+
}
|
|
148
|
+
if (x['gap_version'] !== GAP_VERSION) {
|
|
149
|
+
errors.push(`envelope.gap_version: expected "${GAP_VERSION}"`)
|
|
150
|
+
}
|
|
151
|
+
if (!isString(x['tenant_id'])) errors.push('envelope.tenant_id: expected string')
|
|
152
|
+
if (!isInteger(x['created_at_ms'])) errors.push('envelope.created_at_ms: expected integer')
|
|
153
|
+
if (!isString(x['created_by'])) errors.push('envelope.created_by: expected string')
|
|
154
|
+
if (!('body' in x) || !isObject(x['body'])) errors.push('envelope.body: expected object')
|
|
155
|
+
if ('signature' in x && x['signature'] !== undefined && !isString(x['signature'])) {
|
|
156
|
+
errors.push('envelope.signature: expected string')
|
|
157
|
+
}
|
|
158
|
+
if ('signature_key_id' in x && x['signature_key_id'] !== undefined && !isString(x['signature_key_id'])) {
|
|
159
|
+
errors.push('envelope.signature_key_id: expected string')
|
|
160
|
+
}
|
|
161
|
+
if ('supersedes' in x && x['supersedes'] !== undefined && !isString(x['supersedes'])) {
|
|
162
|
+
errors.push('envelope.supersedes: expected string')
|
|
163
|
+
}
|
|
164
|
+
return errors.length === 0 ? ok() : fail(...errors)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// -- Reusable inner-shape validators -----------------------------------------
|
|
168
|
+
|
|
169
|
+
const ACTOR_TYPES: readonly GapActorType[] = [
|
|
170
|
+
'skill', 'service', 'device', 'agent', 'mcp_server', 'gateway_subsystem', 'human_user',
|
|
171
|
+
]
|
|
172
|
+
const isActorType = isOneOf(ACTOR_TYPES)
|
|
173
|
+
|
|
174
|
+
function validatePredicate(parent: string, p: unknown): ValidationResult {
|
|
175
|
+
if (!isObject(p)) return fail(`${parent}: expected object`)
|
|
176
|
+
return merge(
|
|
177
|
+
requireField(parent, p, 'kind', isString, 'string'),
|
|
178
|
+
requireField(parent, p, 'args', isObject, 'object'),
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function validatePredicateArray(parent: string, arr: unknown): ValidationResult {
|
|
183
|
+
if (!isArray(arr)) return fail(`${parent}: expected array`)
|
|
184
|
+
const merged: ValidationResult[] = []
|
|
185
|
+
arr.forEach((p, i) => merged.push(validatePredicate(`${parent}[${i}]`, p)))
|
|
186
|
+
return merge(...merged)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function validateCapability(parent: string, c: unknown): ValidationResult {
|
|
190
|
+
if (!isObject(c)) return fail(`${parent}: expected object`)
|
|
191
|
+
const errors: ValidationResult[] = [
|
|
192
|
+
requireField(parent, c, 'capability', isString, 'string'),
|
|
193
|
+
]
|
|
194
|
+
if (c['scope'] !== undefined) errors.push(requireField(parent, c, 'scope', isObject, 'object'))
|
|
195
|
+
if (c['preconditions'] !== undefined) errors.push(validatePredicateArray(`${parent}.preconditions`, c['preconditions']))
|
|
196
|
+
if (c['safety_class'] !== undefined) {
|
|
197
|
+
errors.push(requireField(parent, c, 'safety_class', isOneOf(['A', 'B', 'C'] as const), '"A" | "B" | "C"'))
|
|
198
|
+
}
|
|
199
|
+
if (c['physical_safety'] !== undefined) errors.push(requireField(parent, c, 'physical_safety', isBoolean, 'boolean'))
|
|
200
|
+
if (c['require_signed_receipt'] !== undefined) errors.push(requireField(parent, c, 'require_signed_receipt', isBoolean, 'boolean'))
|
|
201
|
+
if (c['pii_args'] !== undefined) {
|
|
202
|
+
if (!isArray(c['pii_args']) || !c['pii_args'].every(isString)) {
|
|
203
|
+
errors.push(fail(`${parent}.pii_args: expected string[]`))
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (c['privilege_protected'] !== undefined) {
|
|
207
|
+
errors.push(requireField(parent, c, 'privilege_protected', isBoolean, 'boolean'))
|
|
208
|
+
}
|
|
209
|
+
return merge(...errors)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function validateScope(parent: string, s: unknown): ValidationResult {
|
|
213
|
+
if (!isObject(s)) return fail(`${parent}: expected object`)
|
|
214
|
+
const errors: ValidationResult[] = [
|
|
215
|
+
requireField(parent, s, 'capability', isString, 'string'),
|
|
216
|
+
]
|
|
217
|
+
if (s['capability_declaration_oid'] !== undefined) {
|
|
218
|
+
errors.push(requireField(parent, s, 'capability_declaration_oid', isString, 'string'))
|
|
219
|
+
}
|
|
220
|
+
if (s['scope_narrowing'] !== undefined) {
|
|
221
|
+
errors.push(requireField(parent, s, 'scope_narrowing', isObject, 'object'))
|
|
222
|
+
}
|
|
223
|
+
if (s['additional_preconditions'] !== undefined) {
|
|
224
|
+
errors.push(validatePredicateArray(`${parent}.additional_preconditions`, s['additional_preconditions']))
|
|
225
|
+
}
|
|
226
|
+
if (s['require_signed_receipt'] !== undefined) {
|
|
227
|
+
errors.push(requireField(parent, s, 'require_signed_receipt', isBoolean, 'boolean'))
|
|
228
|
+
}
|
|
229
|
+
return merge(...errors)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// -- Item 1: Delegation step validator ---------------------------------------
|
|
233
|
+
|
|
234
|
+
function validateDelegationStep(parent: string, s: unknown): ValidationResult {
|
|
235
|
+
if (!isObject(s)) return fail(`${parent}: expected object`)
|
|
236
|
+
return merge(
|
|
237
|
+
requireField(parent, s, 'step_index', (v) => isNumber(v) && Number.isInteger(v) && (v as number) >= 0, 'non-negative integer'),
|
|
238
|
+
requireField(parent, s, 'delegator_actor_oid', isString, 'string'),
|
|
239
|
+
requireField(parent, s, 'delegatee_actor_oid', isString, 'string'),
|
|
240
|
+
requireField(parent, s, 'grant_oid', isString, 'string'),
|
|
241
|
+
requireField(parent, s, 'delegated_at_ms', isInteger, 'integer'),
|
|
242
|
+
requireField(parent, s, 'step_signature', (v) => isString(v) && (v as string).length > 0, 'non-empty string'),
|
|
243
|
+
requireField(parent, s, 'step_signature_alg', (v) => isString(v) && (v as string).length > 0, 'non-empty string'),
|
|
244
|
+
optionalField(parent, s, 'prior_receipt_oid', isString, 'string'),
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* [DESIGN] Validates a gap:orchestration_chain body. Returns error
|
|
250
|
+
* 'delegation_depth_exceeded' when steps.length > 10.
|
|
251
|
+
*/
|
|
252
|
+
export function validateOrchestrationChainBody(x: unknown): ValidationResult {
|
|
253
|
+
if (!isObject(x)) return fail('body: expected object')
|
|
254
|
+
const errors: ValidationResult[] = [
|
|
255
|
+
requireField('body', x, 'root_actor_oid', isString, 'string'),
|
|
256
|
+
requireField('body', x, 'capability_name', isString, 'string'),
|
|
257
|
+
requireField('body', x, 'final_invocation_oid', isString, 'string'),
|
|
258
|
+
]
|
|
259
|
+
if (!isArray(x['steps'])) {
|
|
260
|
+
errors.push(fail('body.steps: expected array'))
|
|
261
|
+
} else {
|
|
262
|
+
if ((x['steps'] as unknown[]).length > 10) {
|
|
263
|
+
errors.push(fail('delegation_depth_exceeded: steps array exceeds maximum of 10 hops'))
|
|
264
|
+
}
|
|
265
|
+
;(x['steps'] as unknown[]).forEach((s, i) =>
|
|
266
|
+
errors.push(validateDelegationStep(`body.steps[${i}]`, s))
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
return merge(...errors)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// -- Item 2: MCP tool-call context validator ---------------------------------
|
|
273
|
+
|
|
274
|
+
function validateMcpToolCallContext(parent: string, m: unknown): ValidationResult {
|
|
275
|
+
if (!isObject(m)) return fail(`${parent}: expected object`)
|
|
276
|
+
const errors: ValidationResult[] = [
|
|
277
|
+
requireField(parent, m, 'server_id', (v) => isString(v) && (v as string).length > 0, 'non-empty string'),
|
|
278
|
+
requireField(parent, m, 'tool_name', isString, 'string'),
|
|
279
|
+
]
|
|
280
|
+
if (isString(m['tool_name']) && (m['tool_name'] as string).startsWith('gap:')) {
|
|
281
|
+
errors.push(fail(`${parent}.tool_name: MCP tool names MUST NOT start with 'gap:' (namespace reserved)`))
|
|
282
|
+
}
|
|
283
|
+
if (m['tool_schema_hash'] !== undefined) {
|
|
284
|
+
errors.push(optionalField(parent, m, 'tool_schema_hash', isString, 'string'))
|
|
285
|
+
}
|
|
286
|
+
return merge(...errors)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// -- Item 3: Token consumption validator -------------------------------------
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* [DESIGN] Validates a TokenConsumption object from a receipt body.
|
|
293
|
+
*/
|
|
294
|
+
export function validateTokenConsumption(x: unknown): ValidationResult {
|
|
295
|
+
if (!isObject(x)) return fail('token_consumption: expected object')
|
|
296
|
+
const isNonNegInt = (v: unknown): v is number =>
|
|
297
|
+
isNumber(v) && Number.isInteger(v) && (v as number) >= 0
|
|
298
|
+
return merge(
|
|
299
|
+
requireField('token_consumption', x, 'input_tokens', isNonNegInt, 'non-negative integer'),
|
|
300
|
+
requireField('token_consumption', x, 'output_tokens', isNonNegInt, 'non-negative integer'),
|
|
301
|
+
requireField('token_consumption', x, 'model', (v) => isString(v) && (v as string).length > 0, 'non-empty string'),
|
|
302
|
+
requireField('token_consumption', x, 'settled_at_ms', isInteger, 'integer'),
|
|
303
|
+
optionalField('token_consumption', x, 'cost_usd', isNumber, 'number'),
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// -- Item 4: Consent record validator ----------------------------------------
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* [DESIGN] Validates a gap:consent_record body. consented MUST be boolean;
|
|
311
|
+
* actor_oid and context are required.
|
|
312
|
+
*/
|
|
313
|
+
export function validateConsentRecordBody(x: unknown): ValidationResult {
|
|
314
|
+
if (!isObject(x)) return fail('body: expected object')
|
|
315
|
+
return merge(
|
|
316
|
+
requireField('body', x, 'actor_oid', (v) => isString(v) && (v as string).length > 0, 'non-empty string'),
|
|
317
|
+
requireField('body', x, 'tenant_id', isString, 'string'),
|
|
318
|
+
requireField('body', x, 'context', (v) => isString(v) && (v as string).length > 0, 'non-empty string'),
|
|
319
|
+
requireField('body', x, 'consented', isBoolean, 'boolean'),
|
|
320
|
+
requireField('body', x, 'consented_at_ms', isInteger, 'integer'),
|
|
321
|
+
optionalField('body', x, 'prior_consent_oid', isString, 'string'),
|
|
322
|
+
optionalField('body', x, 'expires_at_ms', isInteger, 'integer'),
|
|
323
|
+
optionalField('body', x, 'consent_text_hash', isString, 'string'),
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// -- Item 5: Identity binding validator --------------------------------------
|
|
328
|
+
|
|
329
|
+
const CREDENTIAL_KINDS: readonly CredentialKind[] = [
|
|
330
|
+
'piv_cac', 'x509', 'fido2', 'tpm_attestation',
|
|
331
|
+
'oidc_sub', 'spiffe_svid', 'wallet_address', 'professional_license',
|
|
332
|
+
]
|
|
333
|
+
const isCredentialKind = isOneOf(CREDENTIAL_KINDS)
|
|
334
|
+
|
|
335
|
+
function validateIdentityBinding(parent: string, b: unknown): ValidationResult {
|
|
336
|
+
if (!isObject(b)) return fail(`${parent}: expected object`)
|
|
337
|
+
return merge(
|
|
338
|
+
requireField(parent, b, 'credential_kind', isCredentialKind,
|
|
339
|
+
'"piv_cac" | "x509" | "fido2" | "tpm_attestation" | "oidc_sub" | "spiffe_svid" | "wallet_address" | "professional_license"'),
|
|
340
|
+
requireField(parent, b, 'credential_identifier', (v) => isString(v) && (v as string).length > 0, 'non-empty string'),
|
|
341
|
+
requireField(parent, b, 'binding_signature', (v) => isString(v) && (v as string).length > 0, 'non-empty string'),
|
|
342
|
+
requireField(parent, b, 'binding_alg', (v) => isString(v) && (v as string).length > 0, 'non-empty string'),
|
|
343
|
+
requireField(parent, b, 'bound_at_ms', isInteger, 'integer'),
|
|
344
|
+
optionalField(parent, b, 'issuer', isString, 'string'),
|
|
345
|
+
optionalField(parent, b, 'expires_at_ms', isInteger, 'integer'),
|
|
346
|
+
)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// -- Item 6: Compartment validator -------------------------------------------
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Validates a compartment label. Accepted values: 'UNCLASS', 'CUI', or a
|
|
353
|
+
* reverse-domain label (starts with a letter, contains at least one dot, no
|
|
354
|
+
* spaces, no leading 'gap:').
|
|
355
|
+
*/
|
|
356
|
+
function isValidCompartment(v: unknown): v is string {
|
|
357
|
+
if (!isString(v)) return false
|
|
358
|
+
const s = v as string
|
|
359
|
+
if (s === 'UNCLASS' || s === 'CUI') return true
|
|
360
|
+
// Reverse-domain: starts with letter, contains a dot, no spaces
|
|
361
|
+
return /^[a-zA-Z][a-zA-Z0-9_-]*(\.[a-zA-Z0-9_-]+)+$/.test(s)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// -- Item 7: PIP response validator ------------------------------------------
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* [DESIGN] Validates a gap:pip_response body.
|
|
368
|
+
*/
|
|
369
|
+
export function validatePipResponseBody(x: unknown): ValidationResult {
|
|
370
|
+
if (!isObject(x)) return fail('body: expected object')
|
|
371
|
+
return merge(
|
|
372
|
+
requireField('body', x, 'pip_endpoint', (v) => isString(v) && (v as string).length > 0, 'non-empty string'),
|
|
373
|
+
requireField('body', x, 'request_args_hash', (v) => isString(v) && (v as string).length > 0, 'non-empty string'),
|
|
374
|
+
requireField('body', x, 'response_body_hash', (v) => isString(v) && (v as string).length > 0, 'non-empty string'),
|
|
375
|
+
requireField('body', x, 'evaluated_at_ms', isInteger, 'integer'),
|
|
376
|
+
requireField('body', x, 'cache_ttl_ms', (v) => isInteger(v) && (v as number) >= 0, 'non-negative integer'),
|
|
377
|
+
optionalField('body', x, 'response_summary', isString, 'string'),
|
|
378
|
+
optionalField('body', x, 'pip_signature', isString, 'string'),
|
|
379
|
+
optionalField('body', x, 'pip_signature_alg', isString, 'string'),
|
|
380
|
+
)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// -- Body validators ---------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
export function validateCapabilityDeclarationBody(x: unknown): ValidationResult {
|
|
386
|
+
if (!isObject(x)) return fail('body: expected object')
|
|
387
|
+
const errors: ValidationResult[] = [
|
|
388
|
+
requireField('body', x, 'actor_type', isActorType, 'GapActorType'),
|
|
389
|
+
requireField('body', x, 'actor_id', isString, 'string'),
|
|
390
|
+
requireField('body', x, 'actor_name', isString, 'string'),
|
|
391
|
+
requireField('body', x, 'actor_version', isString, 'string'),
|
|
392
|
+
]
|
|
393
|
+
if (x['source_url'] !== undefined) errors.push(requireField('body', x, 'source_url', isString, 'string'))
|
|
394
|
+
if (x['parent_oid'] !== undefined) errors.push(requireField('body', x, 'parent_oid', isString, 'string'))
|
|
395
|
+
if (!isArray(x['capabilities'])) {
|
|
396
|
+
errors.push(fail('body.capabilities: expected array'))
|
|
397
|
+
} else {
|
|
398
|
+
x['capabilities'].forEach((c, i) => errors.push(validateCapability(`body.capabilities[${i}]`, c)))
|
|
399
|
+
}
|
|
400
|
+
if (x['human_summary'] !== undefined) errors.push(requireField('body', x, 'human_summary', isString, 'string'))
|
|
401
|
+
if (x['privacy_classification'] !== undefined) {
|
|
402
|
+
errors.push(requireField('body', x, 'privacy_classification',
|
|
403
|
+
isOneOf(['public', 'restricted', 'sensitive', 'phi', 'pii', 'financial', 'privileged'] as const),
|
|
404
|
+
'"public" | "restricted" | "sensitive" | "phi" | "pii" | "financial" | "privileged"'))
|
|
405
|
+
}
|
|
406
|
+
if (x['declared_limits'] !== undefined) {
|
|
407
|
+
if (!isObject(x['declared_limits'])) errors.push(fail('body.declared_limits: expected object'))
|
|
408
|
+
}
|
|
409
|
+
// C15: ephemeral actor lifecycle fields
|
|
410
|
+
if (x['actor_lifecycle'] !== undefined) {
|
|
411
|
+
errors.push(requireField('body', x, 'actor_lifecycle',
|
|
412
|
+
isOneOf(['persistent', 'ephemeral'] as const),
|
|
413
|
+
'"persistent" | "ephemeral"'))
|
|
414
|
+
}
|
|
415
|
+
if (x['actor_instance_id'] !== undefined) {
|
|
416
|
+
errors.push(requireField('body', x, 'actor_instance_id', isString, 'string'))
|
|
417
|
+
}
|
|
418
|
+
if (x['session_expires_at_ms'] !== undefined) {
|
|
419
|
+
errors.push(requireField('body', x, 'session_expires_at_ms', isInteger, 'integer'))
|
|
420
|
+
}
|
|
421
|
+
// Item 5: identity_binding
|
|
422
|
+
if (x['identity_binding'] !== undefined) {
|
|
423
|
+
errors.push(validateIdentityBinding('body.identity_binding', x['identity_binding']))
|
|
424
|
+
}
|
|
425
|
+
// Item 6: compartment
|
|
426
|
+
if (x['compartment'] !== undefined) {
|
|
427
|
+
if (!isValidCompartment(x['compartment'])) {
|
|
428
|
+
errors.push(fail('body.compartment: expected "UNCLASS", "CUI", or a reverse-domain label (e.g. "com.acme.project-alpha")'))
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return merge(...errors)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export function validateCapabilityGrantBody(x: unknown): ValidationResult {
|
|
435
|
+
if (!isObject(x)) return fail('body: expected object')
|
|
436
|
+
const errors: ValidationResult[] = []
|
|
437
|
+
// grantee
|
|
438
|
+
if (!isObject(x['grantee'])) {
|
|
439
|
+
errors.push(fail('body.grantee: expected object'))
|
|
440
|
+
} else {
|
|
441
|
+
const g = x['grantee']
|
|
442
|
+
errors.push(
|
|
443
|
+
requireField('body.grantee', g, 'actor_type', isActorType, 'GapActorType'),
|
|
444
|
+
requireField('body.grantee', g, 'actor_oid', isString, 'string'),
|
|
445
|
+
optionalField('body.grantee', g, 'actor_session_id', isString, 'string'),
|
|
446
|
+
)
|
|
447
|
+
}
|
|
448
|
+
// capability_scopes
|
|
449
|
+
if (!isArray(x['capability_scopes'])) {
|
|
450
|
+
errors.push(fail('body.capability_scopes: expected array'))
|
|
451
|
+
} else {
|
|
452
|
+
x['capability_scopes'].forEach((s, i) => errors.push(validateScope(`body.capability_scopes[${i}]`, s)))
|
|
453
|
+
}
|
|
454
|
+
errors.push(requireField('body', x, 'granted_at_ms', isInteger, 'integer'))
|
|
455
|
+
// expires_at_ms can be integer | null
|
|
456
|
+
if (!('expires_at_ms' in x)) {
|
|
457
|
+
errors.push(fail('body.expires_at_ms: missing required field'))
|
|
458
|
+
} else if (x['expires_at_ms'] !== null && !isInteger(x['expires_at_ms'])) {
|
|
459
|
+
errors.push(fail('body.expires_at_ms: expected integer | null'))
|
|
460
|
+
}
|
|
461
|
+
errors.push(requireField('body', x, 'granted_by', isString, 'string'))
|
|
462
|
+
if (x['reason'] !== undefined) errors.push(requireField('body', x, 'reason', isString, 'string'))
|
|
463
|
+
if (x['evidence_oids'] !== undefined) {
|
|
464
|
+
if (!isArray(x['evidence_oids']) || !x['evidence_oids'].every(isString)) {
|
|
465
|
+
errors.push(fail('body.evidence_oids: expected string[]'))
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (x['revocation_level_override'] !== undefined) {
|
|
469
|
+
if (x['revocation_level_override'] !== 1 && x['revocation_level_override'] !== 2 && x['revocation_level_override'] !== 3) {
|
|
470
|
+
errors.push(fail('body.revocation_level_override: expected 1 | 2 | 3'))
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
if (x['limits'] !== undefined) {
|
|
474
|
+
if (!isObject(x['limits'])) {
|
|
475
|
+
errors.push(fail('body.limits: expected object'))
|
|
476
|
+
} else {
|
|
477
|
+
const lim = x['limits']
|
|
478
|
+
if (lim['aggregate_limits'] !== undefined) {
|
|
479
|
+
if (!isArray(lim['aggregate_limits'])) {
|
|
480
|
+
errors.push(fail('body.limits.aggregate_limits: expected array'))
|
|
481
|
+
} else {
|
|
482
|
+
lim['aggregate_limits'].forEach((entry: unknown, i: number) => {
|
|
483
|
+
if (!isObject(entry)) {
|
|
484
|
+
errors.push(fail(`body.limits.aggregate_limits[${i}]: expected object`))
|
|
485
|
+
return
|
|
486
|
+
}
|
|
487
|
+
errors.push(
|
|
488
|
+
requireField(`body.limits.aggregate_limits[${i}]`, entry, 'key', isString, 'string'),
|
|
489
|
+
requireField(`body.limits.aggregate_limits[${i}]`, entry, 'max', (v) => isInteger(v) && (v as number) >= 0, 'integer >= 0'),
|
|
490
|
+
requireField(`body.limits.aggregate_limits[${i}]`, entry, 'window_seconds', (v) => isInteger(v) && (v as number) > 0, 'integer > 0'),
|
|
491
|
+
)
|
|
492
|
+
})
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
// C7: cross-grant aggregate limit group
|
|
496
|
+
if (lim['aggregate_limit_group'] !== undefined) {
|
|
497
|
+
errors.push(optionalField('body.limits', lim, 'aggregate_limit_group', isString, 'string'))
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
if (x['parent_grant_oid'] !== undefined) {
|
|
502
|
+
errors.push(requireField('body', x, 'parent_grant_oid', isString, 'string'))
|
|
503
|
+
}
|
|
504
|
+
if (x['max_delegation_depth'] !== undefined) {
|
|
505
|
+
if (!isNumber(x['max_delegation_depth']) || !Number.isInteger(x['max_delegation_depth']) || (x['max_delegation_depth'] as number) < 0) {
|
|
506
|
+
errors.push(fail('body.max_delegation_depth: expected non-negative integer'))
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
const isPositiveInteger = (v: unknown): v is number =>
|
|
510
|
+
isNumber(v) && Number.isInteger(v) && (v as number) > 0
|
|
511
|
+
if (x['timestamp_window_seconds'] !== undefined) {
|
|
512
|
+
if (!isPositiveInteger(x['timestamp_window_seconds'])) {
|
|
513
|
+
errors.push(fail('body.timestamp_window_seconds: expected positive integer'))
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
if (x['offline_grace_seconds'] !== undefined) {
|
|
517
|
+
if (!isNumber(x['offline_grace_seconds']) || !Number.isInteger(x['offline_grace_seconds']) || (x['offline_grace_seconds'] as number) < 0) {
|
|
518
|
+
errors.push(fail('body.offline_grace_seconds: expected non-negative integer'))
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (x['max_grant_offline_ttl_ms'] !== undefined) {
|
|
522
|
+
if (!isPositiveInteger(x['max_grant_offline_ttl_ms'])) {
|
|
523
|
+
errors.push(fail('body.max_grant_offline_ttl_ms: expected positive integer'))
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
if (x['max_revocation_bundle_age_ms'] !== undefined) {
|
|
527
|
+
if (!isPositiveInteger(x['max_revocation_bundle_age_ms'])) {
|
|
528
|
+
errors.push(fail('body.max_revocation_bundle_age_ms: expected positive integer'))
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
// -- Break-glass optional fields -------------------------------------------
|
|
532
|
+
if (x['break_glass'] !== undefined) {
|
|
533
|
+
errors.push(optionalField('body', x, 'break_glass', isBoolean, 'boolean'))
|
|
534
|
+
}
|
|
535
|
+
if (x['break_glass_ttl_ms'] !== undefined) {
|
|
536
|
+
if (!isPositiveInteger(x['break_glass_ttl_ms'])) {
|
|
537
|
+
errors.push(fail('body.break_glass_ttl_ms: expected positive integer'))
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
if (x['break_glass_max_invocations'] !== undefined) {
|
|
541
|
+
if (!isPositiveInteger(x['break_glass_max_invocations'])) {
|
|
542
|
+
errors.push(fail('body.break_glass_max_invocations: expected positive integer'))
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (x['break_glass_requires_reason'] !== undefined) {
|
|
546
|
+
errors.push(optionalField('body', x, 'break_glass_requires_reason', isBoolean, 'boolean'))
|
|
547
|
+
}
|
|
548
|
+
// Item 6: compartment
|
|
549
|
+
if (x['compartment'] !== undefined) {
|
|
550
|
+
if (!isValidCompartment(x['compartment'])) {
|
|
551
|
+
errors.push(fail('body.compartment: expected "UNCLASS", "CUI", or a reverse-domain label (e.g. "com.acme.project-alpha")'))
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return merge(...errors)
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export function validateCapabilityInvocationBody(x: unknown): ValidationResult {
|
|
558
|
+
if (!isObject(x)) return fail('body: expected object')
|
|
559
|
+
const errors: ValidationResult[] = []
|
|
560
|
+
if (!isObject(x['caller'])) {
|
|
561
|
+
errors.push(fail('body.caller: expected object'))
|
|
562
|
+
} else {
|
|
563
|
+
const c = x['caller']
|
|
564
|
+
errors.push(
|
|
565
|
+
requireField('body.caller', c, 'actor_type', isActorType, 'GapActorType'),
|
|
566
|
+
requireField('body.caller', c, 'actor_oid', isString, 'string'),
|
|
567
|
+
requireField('body.caller', c, 'grant_oid', isString, 'string'),
|
|
568
|
+
optionalField('body.caller', c, 'actor_session_id', isString, 'string'),
|
|
569
|
+
)
|
|
570
|
+
}
|
|
571
|
+
errors.push(
|
|
572
|
+
requireField('body', x, 'capability', isString, 'string'),
|
|
573
|
+
optionalField('body', x, 'capability_declaration_oid', isString, 'string'),
|
|
574
|
+
requireField('body', x, 'args', isObject, 'object'),
|
|
575
|
+
optionalField('body', x, 'invoked_at_ms', isInteger, 'integer'),
|
|
576
|
+
)
|
|
577
|
+
if (x['workflow_context'] !== undefined) {
|
|
578
|
+
if (!isObject(x['workflow_context'])) {
|
|
579
|
+
errors.push(fail('body.workflow_context: expected object'))
|
|
580
|
+
} else {
|
|
581
|
+
const wc = x['workflow_context']
|
|
582
|
+
errors.push(
|
|
583
|
+
requireField('body.workflow_context', wc, 'workflow_instance_oid', isString, 'string'),
|
|
584
|
+
requireField('body.workflow_context', wc, 'stage_id', isString, 'string'),
|
|
585
|
+
)
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
if (x['sla_hint'] !== undefined && !isObject(x['sla_hint'])) {
|
|
589
|
+
errors.push(fail('body.sla_hint: expected object'))
|
|
590
|
+
}
|
|
591
|
+
if (x['idempotency_key'] !== undefined) {
|
|
592
|
+
errors.push(requireField('body', x, 'idempotency_key', isString, 'string'))
|
|
593
|
+
}
|
|
594
|
+
if (x['client_event_ms'] !== undefined) {
|
|
595
|
+
errors.push(optionalField('body', x, 'client_event_ms', isInteger, 'integer'))
|
|
596
|
+
}
|
|
597
|
+
if (x['queued_at_ms'] !== undefined) {
|
|
598
|
+
errors.push(optionalField('body', x, 'queued_at_ms', isInteger, 'integer'))
|
|
599
|
+
}
|
|
600
|
+
// Item 1: delegation_chain -- max 10 steps
|
|
601
|
+
if (x['delegation_chain'] !== undefined) {
|
|
602
|
+
if (!isArray(x['delegation_chain'])) {
|
|
603
|
+
errors.push(fail('body.delegation_chain: expected array'))
|
|
604
|
+
} else {
|
|
605
|
+
if ((x['delegation_chain'] as unknown[]).length > 10) {
|
|
606
|
+
errors.push(fail('delegation_depth_exceeded: delegation_chain exceeds maximum of 10 hops'))
|
|
607
|
+
}
|
|
608
|
+
;(x['delegation_chain'] as unknown[]).forEach((s, i) =>
|
|
609
|
+
errors.push(validateDelegationStep(`body.delegation_chain[${i}]`, s))
|
|
610
|
+
)
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
// Item 2: mcp_tool_call
|
|
614
|
+
if (x['mcp_tool_call'] !== undefined) {
|
|
615
|
+
errors.push(validateMcpToolCallContext('body.mcp_tool_call', x['mcp_tool_call']))
|
|
616
|
+
}
|
|
617
|
+
// Item 6: compartment
|
|
618
|
+
if (x['compartment'] !== undefined) {
|
|
619
|
+
if (!isValidCompartment(x['compartment'])) {
|
|
620
|
+
errors.push(fail('body.compartment: expected "UNCLASS", "CUI", or a reverse-domain label (e.g. "com.acme.project-alpha")'))
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
return merge(...errors)
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
export function validateWorkflowDefinitionBody(x: unknown): ValidationResult {
|
|
627
|
+
if (!isObject(x)) return fail('body: expected object')
|
|
628
|
+
const errors: ValidationResult[] = [
|
|
629
|
+
requireField('body', x, 'workflow_id', isString, 'string'),
|
|
630
|
+
requireField('body', x, 'workflow_name', isString, 'string'),
|
|
631
|
+
requireField('body', x, 'workflow_version', isString, 'string'),
|
|
632
|
+
requireField('body', x, 'initial_stage_id', isString, 'string'),
|
|
633
|
+
requireField('body', x, 'max_total_duration_seconds', isInteger, 'integer'),
|
|
634
|
+
]
|
|
635
|
+
if (!isObject(x['trigger'])) {
|
|
636
|
+
errors.push(fail('body.trigger: expected object'))
|
|
637
|
+
} else {
|
|
638
|
+
errors.push(requireField('body.trigger', x['trigger'], 'kind',
|
|
639
|
+
isOneOf(['risk_policy', 'capability_invocation', 'explicit', 'schedule'] as const),
|
|
640
|
+
'WorkflowTriggerKind'))
|
|
641
|
+
}
|
|
642
|
+
if (!isArray(x['stages'])) {
|
|
643
|
+
errors.push(fail('body.stages: expected array'))
|
|
644
|
+
} else {
|
|
645
|
+
x['stages'].forEach((s, i) => {
|
|
646
|
+
if (!isObject(s)) {
|
|
647
|
+
errors.push(fail(`body.stages[${i}]: expected object`))
|
|
648
|
+
return
|
|
649
|
+
}
|
|
650
|
+
errors.push(requireField(`body.stages[${i}]`, s, 'stage_id', isString, 'string'))
|
|
651
|
+
if (s['authorized_approvers'] !== undefined) {
|
|
652
|
+
if (!isArray(s['authorized_approvers']) || !s['authorized_approvers'].every(isString)) {
|
|
653
|
+
errors.push(fail(`body.stages[${i}].authorized_approvers: expected string[]`))
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
})
|
|
657
|
+
}
|
|
658
|
+
if (!isArray(x['required_channels']) || !x['required_channels'].every(isString)) {
|
|
659
|
+
errors.push(fail('body.required_channels: expected string[]'))
|
|
660
|
+
}
|
|
661
|
+
if (x['optional_channels'] !== undefined) {
|
|
662
|
+
if (!isArray(x['optional_channels']) || !x['optional_channels'].every(isString)) {
|
|
663
|
+
errors.push(fail('body.optional_channels: expected string[]'))
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
if (x['description'] !== undefined) errors.push(requireField('body', x, 'description', isString, 'string'))
|
|
667
|
+
if (x['cleanup_stage_id'] !== undefined) errors.push(requireField('body', x, 'cleanup_stage_id', isString, 'string'))
|
|
668
|
+
return merge(...errors)
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
export function validateWorkflowInstanceBody(x: unknown): ValidationResult {
|
|
672
|
+
if (!isObject(x)) return fail('body: expected object')
|
|
673
|
+
const errors: ValidationResult[] = [
|
|
674
|
+
requireField('body', x, 'workflow_definition_oid', isString, 'string'),
|
|
675
|
+
requireField('body', x, 'workflow_id', isString, 'string'),
|
|
676
|
+
requireField('body', x, 'current_stage_id', isString, 'string'),
|
|
677
|
+
requireField('body', x, 'scope_variables', isObject, 'object'),
|
|
678
|
+
requireField('body', x, 'started_at_ms', isInteger, 'integer'),
|
|
679
|
+
requireField('body', x, 'last_transition_at_ms', isInteger, 'integer'),
|
|
680
|
+
]
|
|
681
|
+
if (!('terminated_at_ms' in x)) {
|
|
682
|
+
errors.push(fail('body.terminated_at_ms: missing required field'))
|
|
683
|
+
} else if (x['terminated_at_ms'] !== null && !isInteger(x['terminated_at_ms'])) {
|
|
684
|
+
errors.push(fail('body.terminated_at_ms: expected integer | null'))
|
|
685
|
+
}
|
|
686
|
+
if (!('terminal_outcome' in x)) {
|
|
687
|
+
errors.push(fail('body.terminal_outcome: missing required field'))
|
|
688
|
+
} else if (x['terminal_outcome'] !== null) {
|
|
689
|
+
if (!isString(x['terminal_outcome'])
|
|
690
|
+
|| !['approved', 'denied', 'timed_out', 'withdrawn', 'error'].includes(x['terminal_outcome'])) {
|
|
691
|
+
errors.push(fail('body.terminal_outcome: expected null | "approved" | "denied" | "timed_out" | "withdrawn" | "error"'))
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
if (!isObject(x['trigger_event'])) {
|
|
695
|
+
errors.push(fail('body.trigger_event: expected object'))
|
|
696
|
+
} else {
|
|
697
|
+
errors.push(
|
|
698
|
+
requireField('body.trigger_event', x['trigger_event'], 'kind',
|
|
699
|
+
isOneOf(['risk_policy', 'capability_invocation', 'explicit', 'schedule'] as const),
|
|
700
|
+
'WorkflowTriggerKind'),
|
|
701
|
+
requireField('body.trigger_event', x['trigger_event'], 'source_actor_oid', isString, 'string'),
|
|
702
|
+
)
|
|
703
|
+
}
|
|
704
|
+
if (!isArray(x['active_channel_listeners'])) {
|
|
705
|
+
errors.push(fail('body.active_channel_listeners: expected array'))
|
|
706
|
+
}
|
|
707
|
+
if (!isArray(x['transition_oids']) || !x['transition_oids'].every(isString)) {
|
|
708
|
+
errors.push(fail('body.transition_oids: expected string[]'))
|
|
709
|
+
}
|
|
710
|
+
return merge(...errors)
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
export function validateStageTransitionBody(x: unknown): ValidationResult {
|
|
714
|
+
if (!isObject(x)) return fail('body: expected object')
|
|
715
|
+
const errors: ValidationResult[] = [
|
|
716
|
+
requireField('body', x, 'workflow_instance_oid', isString, 'string'),
|
|
717
|
+
requireField('body', x, 'from_stage_id', isString, 'string'),
|
|
718
|
+
requireField('body', x, 'to_stage_id', isString, 'string'),
|
|
719
|
+
requireField('body', x, 'trigger_reason',
|
|
720
|
+
isOneOf([
|
|
721
|
+
'listen_matched', 'timeout', 'action_completed', 'action_failed',
|
|
722
|
+
'precondition_passed', 'precondition_failed',
|
|
723
|
+
'invocation_succeeded', 'invocation_failed',
|
|
724
|
+
'external_signal', 'cleanup',
|
|
725
|
+
] as const),
|
|
726
|
+
'StageTransitionReason'),
|
|
727
|
+
requireField('body', x, 'bind_outputs', isObject, 'object'),
|
|
728
|
+
requireField('body', x, 'transitioned_at_ms', isInteger, 'integer'),
|
|
729
|
+
]
|
|
730
|
+
if (!('previous_transition_oid' in x)) {
|
|
731
|
+
errors.push(fail('body.previous_transition_oid: missing required field'))
|
|
732
|
+
} else if (x['previous_transition_oid'] !== null && !isString(x['previous_transition_oid'])) {
|
|
733
|
+
errors.push(fail('body.previous_transition_oid: expected string | null'))
|
|
734
|
+
}
|
|
735
|
+
if (x['triggering_event_oid'] !== undefined) {
|
|
736
|
+
errors.push(requireField('body', x, 'triggering_event_oid', isString, 'string'))
|
|
737
|
+
}
|
|
738
|
+
if (x['triggering_invocation_oid'] !== undefined) {
|
|
739
|
+
errors.push(requireField('body', x, 'triggering_invocation_oid', isString, 'string'))
|
|
740
|
+
}
|
|
741
|
+
return merge(...errors)
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
export function validateChannelEventBody(x: unknown): ValidationResult {
|
|
745
|
+
if (!isObject(x)) return fail('body: expected object')
|
|
746
|
+
const errors: ValidationResult[] = [
|
|
747
|
+
requireField('body', x, 'channel', isString, 'string'),
|
|
748
|
+
requireField('body', x, 'event_kind', isString, 'string'),
|
|
749
|
+
requireField('body', x, 'payload', isObject, 'object'),
|
|
750
|
+
requireField('body', x, 'observed_at_ms', isInteger, 'integer'),
|
|
751
|
+
]
|
|
752
|
+
if (x['workflow_instance_oid'] !== undefined) {
|
|
753
|
+
errors.push(requireField('body', x, 'workflow_instance_oid', isString, 'string'))
|
|
754
|
+
}
|
|
755
|
+
if (x['stage_id'] !== undefined) {
|
|
756
|
+
errors.push(requireField('body', x, 'stage_id', isString, 'string'))
|
|
757
|
+
}
|
|
758
|
+
return merge(...errors)
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
export function validateGapDecisionReceiptBody(x: unknown): ValidationResult {
|
|
762
|
+
if (!isObject(x)) return fail('body: expected object')
|
|
763
|
+
const errors: ValidationResult[] = [
|
|
764
|
+
requireField('body', x, 'subject_kind',
|
|
765
|
+
isOneOf([
|
|
766
|
+
'capability_invocation', 'stage_transition', 'grant_issued', 'grant_revoked',
|
|
767
|
+
'workflow_started', 'workflow_terminated', 'revocation_initiated',
|
|
768
|
+
'revocation_effective', 'federation_handshake', /* reserved for GAP 1.1 */ 'provisional_block',
|
|
769
|
+
] as const),
|
|
770
|
+
'DecisionSubjectKind'),
|
|
771
|
+
requireField('body', x, 'subject_oid', isString, 'string'),
|
|
772
|
+
requireField('body', x, 'status',
|
|
773
|
+
isOneOf(['ok', 'denied', 'failed', 'deferred', 'timed_out', 'pending'] as const),
|
|
774
|
+
'DecisionStatus'),
|
|
775
|
+
requireField('body', x, 'initiated_at_ms', isInteger, 'integer'),
|
|
776
|
+
requireField('body', x, 'resolved_at_ms', isInteger, 'integer'),
|
|
777
|
+
]
|
|
778
|
+
if (!isObject(x['initiator'])) {
|
|
779
|
+
errors.push(fail('body.initiator: expected object'))
|
|
780
|
+
} else {
|
|
781
|
+
errors.push(
|
|
782
|
+
requireField('body.initiator', x['initiator'], 'actor_oid', isString, 'string'),
|
|
783
|
+
requireField('body.initiator', x['initiator'], 'actor_type', isActorType, 'GapActorType'),
|
|
784
|
+
)
|
|
785
|
+
}
|
|
786
|
+
if (x['detail'] !== undefined) errors.push(requireField('body', x, 'detail', isString, 'string'))
|
|
787
|
+
if (x['capability_grant_oids'] !== undefined) {
|
|
788
|
+
if (!isArray(x['capability_grant_oids']) || !x['capability_grant_oids'].every(isString)) {
|
|
789
|
+
errors.push(fail('body.capability_grant_oids: expected string[]'))
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
if (x['channel_event_oids'] !== undefined) {
|
|
793
|
+
if (!isArray(x['channel_event_oids']) || !x['channel_event_oids'].every(isString)) {
|
|
794
|
+
errors.push(fail('body.channel_event_oids: expected string[]'))
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
if (x['compliance_tags'] !== undefined) {
|
|
798
|
+
if (!isArray(x['compliance_tags']) || !x['compliance_tags'].every(isString)) {
|
|
799
|
+
errors.push(fail('body.compliance_tags: expected string[]'))
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
if (x['signer_identity'] !== undefined) {
|
|
803
|
+
if (!isObject(x['signer_identity'])) {
|
|
804
|
+
errors.push(fail('body.signer_identity: expected object'))
|
|
805
|
+
} else {
|
|
806
|
+
const si = x['signer_identity']
|
|
807
|
+
errors.push(requireField('body.signer_identity', si, 'display_name', isString, 'string'))
|
|
808
|
+
errors.push(optionalField('body.signer_identity', si, 'role', isString, 'string'))
|
|
809
|
+
errors.push(optionalField('body.signer_identity', si, 'credential_id', isString, 'string'))
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
// C8: sub-millisecond sequence fields
|
|
813
|
+
if (x['sequence_number'] !== undefined) {
|
|
814
|
+
errors.push(optionalField('body', x, 'sequence_number',
|
|
815
|
+
(v): v is number => isNumber(v) && Number.isInteger(v) && (v as number) >= 0,
|
|
816
|
+
'non-negative integer'))
|
|
817
|
+
}
|
|
818
|
+
if (x['decided_at_ns'] !== undefined) {
|
|
819
|
+
errors.push(optionalField('body', x, 'decided_at_ns', isInteger, 'integer'))
|
|
820
|
+
}
|
|
821
|
+
// Item 3: token_consumption
|
|
822
|
+
if (x['token_consumption'] !== undefined) {
|
|
823
|
+
errors.push(validateTokenConsumption(x['token_consumption']))
|
|
824
|
+
}
|
|
825
|
+
return merge(...errors)
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
export function validateRevocationEventBody(x: unknown): ValidationResult {
|
|
829
|
+
if (!isObject(x)) return fail('body: expected object')
|
|
830
|
+
const errors: ValidationResult[] = [
|
|
831
|
+
requireField('body', x, 'target_kind',
|
|
832
|
+
isOneOf([
|
|
833
|
+
'capability_declaration', 'capability_grant',
|
|
834
|
+
'workflow_definition', 'workflow_instance', 'skill',
|
|
835
|
+
] as const),
|
|
836
|
+
'RevocationTargetKind'),
|
|
837
|
+
requireField('body', x, 'target_oid', isString, 'string'),
|
|
838
|
+
requireField('body', x, 'reason', isString, 'string'),
|
|
839
|
+
requireField('body', x, 'provisional', isBoolean, 'boolean'),
|
|
840
|
+
]
|
|
841
|
+
if (x['required_level'] !== 1 && x['required_level'] !== 2 && x['required_level'] !== 3) {
|
|
842
|
+
errors.push(fail('body.required_level: expected 1 | 2 | 3'))
|
|
843
|
+
}
|
|
844
|
+
if (!isArray(x['approvers'])) {
|
|
845
|
+
errors.push(fail('body.approvers: expected array'))
|
|
846
|
+
} else {
|
|
847
|
+
x['approvers'].forEach((a, i) => {
|
|
848
|
+
if (!isObject(a)) {
|
|
849
|
+
errors.push(fail(`body.approvers[${i}]: expected object`))
|
|
850
|
+
return
|
|
851
|
+
}
|
|
852
|
+
errors.push(
|
|
853
|
+
requireField(`body.approvers[${i}]`, a, 'actor_oid', isString, 'string'),
|
|
854
|
+
requireField(`body.approvers[${i}]`, a, 'approved_at_ms', isInteger, 'integer'),
|
|
855
|
+
requireField(`body.approvers[${i}]`, a, 'cooling_off_satisfied', isBoolean, 'boolean'),
|
|
856
|
+
)
|
|
857
|
+
})
|
|
858
|
+
}
|
|
859
|
+
if (!('effective_at_ms' in x)) {
|
|
860
|
+
errors.push(fail('body.effective_at_ms: missing required field'))
|
|
861
|
+
} else if (x['effective_at_ms'] !== null && !isNumber(x['effective_at_ms'])) {
|
|
862
|
+
errors.push(fail('body.effective_at_ms: expected number | null'))
|
|
863
|
+
}
|
|
864
|
+
if (x['evidence_oids'] !== undefined) {
|
|
865
|
+
if (!isArray(x['evidence_oids']) || !x['evidence_oids'].every(isString)) {
|
|
866
|
+
errors.push(fail('body.evidence_oids: expected string[]'))
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
if (x['provisional_block_policy'] !== undefined) {
|
|
870
|
+
if (!isObject(x['provisional_block_policy'])) {
|
|
871
|
+
errors.push(fail('body.provisional_block_policy: expected object'))
|
|
872
|
+
} else {
|
|
873
|
+
const pbp = x['provisional_block_policy']
|
|
874
|
+
if (pbp['on_expiry_without_quorum'] !== 'renew' && pbp['on_expiry_without_quorum'] !== 'revert') {
|
|
875
|
+
errors.push(fail('body.provisional_block_policy.on_expiry_without_quorum: expected "renew" | "revert"'))
|
|
876
|
+
}
|
|
877
|
+
// M-5: optional TTL override; minimum 1 hour
|
|
878
|
+
if (pbp['provisional_block_ttl_ms'] !== undefined) {
|
|
879
|
+
const minTtl = 3_600_000
|
|
880
|
+
if (!isInteger(pbp['provisional_block_ttl_ms']) || (pbp['provisional_block_ttl_ms'] as number) < minTtl) {
|
|
881
|
+
errors.push(fail('body.provisional_block_policy.provisional_block_ttl_ms: expected integer >= 3600000 (1 hour)'))
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
if (x['min_approvers'] !== undefined) {
|
|
887
|
+
const isPositiveInteger = (v: unknown): v is number =>
|
|
888
|
+
isNumber(v) && Number.isInteger(v) && (v as number) > 0
|
|
889
|
+
if (!isPositiveInteger(x['min_approvers'])) {
|
|
890
|
+
errors.push(fail('body.min_approvers: expected positive integer'))
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
return merge(...errors)
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// -- Full-envelope validators (envelope + body) ------------------------------
|
|
897
|
+
|
|
898
|
+
export function validateCapabilityDeclaration(x: unknown): ValidationResult {
|
|
899
|
+
const env = validateEnvelopeShape(x, 'gap:capability_declaration')
|
|
900
|
+
if (!env.ok) return env
|
|
901
|
+
return merge(env, validateCapabilityDeclarationBody((x as GapCdroEnvelope<unknown>).body))
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
export function validateCapabilityGrant(x: unknown): ValidationResult {
|
|
905
|
+
const env = validateEnvelopeShape(x, 'gap:capability_grant')
|
|
906
|
+
if (!env.ok) return env
|
|
907
|
+
return merge(env, validateCapabilityGrantBody((x as GapCdroEnvelope<unknown>).body))
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
export function validateCapabilityInvocation(x: unknown): ValidationResult {
|
|
911
|
+
const env = validateEnvelopeShape(x, 'gap:capability_invocation')
|
|
912
|
+
if (!env.ok) return env
|
|
913
|
+
return merge(env, validateCapabilityInvocationBody((x as GapCdroEnvelope<unknown>).body))
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
export function validateWorkflowDefinition(x: unknown): ValidationResult {
|
|
917
|
+
const env = validateEnvelopeShape(x, 'gap:workflow_definition')
|
|
918
|
+
if (!env.ok) return env
|
|
919
|
+
return merge(env, validateWorkflowDefinitionBody((x as GapCdroEnvelope<unknown>).body))
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
export function validateWorkflowInstance(x: unknown): ValidationResult {
|
|
923
|
+
const env = validateEnvelopeShape(x, 'gap:workflow_instance')
|
|
924
|
+
if (!env.ok) return env
|
|
925
|
+
return merge(env, validateWorkflowInstanceBody((x as GapCdroEnvelope<unknown>).body))
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
export function validateStageTransition(x: unknown): ValidationResult {
|
|
929
|
+
const env = validateEnvelopeShape(x, 'gap:stage_transition')
|
|
930
|
+
if (!env.ok) return env
|
|
931
|
+
return merge(env, validateStageTransitionBody((x as GapCdroEnvelope<unknown>).body))
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
export function validateChannelEvent(x: unknown): ValidationResult {
|
|
935
|
+
const env = validateEnvelopeShape(x, 'gap:channel_event')
|
|
936
|
+
if (!env.ok) return env
|
|
937
|
+
return merge(env, validateChannelEventBody((x as GapCdroEnvelope<unknown>).body))
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
export function validateGapDecisionReceipt(x: unknown): ValidationResult {
|
|
941
|
+
const env = validateEnvelopeShape(x, 'gap:decision_receipt')
|
|
942
|
+
if (!env.ok) return env
|
|
943
|
+
return merge(env, validateGapDecisionReceiptBody((x as GapCdroEnvelope<unknown>).body))
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
export function validateRevocationEvent(x: unknown): ValidationResult {
|
|
947
|
+
const env = validateEnvelopeShape(x, 'gap:revocation_event')
|
|
948
|
+
if (!env.ok) return env
|
|
949
|
+
return merge(env, validateRevocationEventBody((x as GapCdroEnvelope<unknown>).body))
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// -- Full-envelope validators for new CDRO types (Items 1, 4, 7) -------------
|
|
953
|
+
|
|
954
|
+
export function validateOrchestrationChain(x: unknown): ValidationResult {
|
|
955
|
+
const env = validateEnvelopeShape(x, 'gap:orchestration_chain')
|
|
956
|
+
if (!env.ok) return env
|
|
957
|
+
return merge(env, validateOrchestrationChainBody((x as GapCdroEnvelope<unknown>).body))
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
export function validateConsentRecord(x: unknown): ValidationResult {
|
|
961
|
+
const env = validateEnvelopeShape(x, 'gap:consent_record')
|
|
962
|
+
if (!env.ok) return env
|
|
963
|
+
return merge(env, validateConsentRecordBody((x as GapCdroEnvelope<unknown>).body))
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
export function validatePipResponse(x: unknown): ValidationResult {
|
|
967
|
+
const env = validateEnvelopeShape(x, 'gap:pip_response')
|
|
968
|
+
if (!env.ok) return env
|
|
969
|
+
return merge(env, validatePipResponseBody((x as GapCdroEnvelope<unknown>).body))
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// -- Note on imports ---------------------------------------------------------
|
|
973
|
+
// The CapabilityDeclaration / CapabilityGrant / ... type imports above are
|
|
974
|
+
// used only at the .d.ts level (validators take `unknown`); the underscore
|
|
975
|
+
// guards below silence "unused" lint without changing emit.
|
|
976
|
+
type _Unused =
|
|
977
|
+
| CapabilityDeclaration
|
|
978
|
+
| CapabilityDeclarationBody
|
|
979
|
+
| CapabilityGrant
|
|
980
|
+
| CapabilityGrantBody
|
|
981
|
+
| CapabilityInvocation
|
|
982
|
+
| CapabilityInvocationBody
|
|
983
|
+
| WorkflowDefinition
|
|
984
|
+
| WorkflowDefinitionBody
|
|
985
|
+
| WorkflowInstance
|
|
986
|
+
| WorkflowInstanceBody
|
|
987
|
+
| StageTransition
|
|
988
|
+
| StageTransitionBody
|
|
989
|
+
| ChannelEvent
|
|
990
|
+
| ChannelEventBody
|
|
991
|
+
| GapDecisionReceipt
|
|
992
|
+
| GapDecisionReceiptBody
|
|
993
|
+
| RevocationEvent
|
|
994
|
+
| RevocationEventBody
|
|
995
|
+
| Capability
|
|
996
|
+
| CapabilityPredicate
|
|
997
|
+
| GrantedCapabilityScope
|
|
998
|
+
// New types from Items 1-7
|
|
999
|
+
| ConsentRecordBody
|
|
1000
|
+
| CredentialKind
|
|
1001
|
+
| DelegationStep
|
|
1002
|
+
| IdentityBinding
|
|
1003
|
+
| McpToolCallContext
|
|
1004
|
+
| OrchestrationChainBody
|
|
1005
|
+
| PipResponseBody
|
|
1006
|
+
| TokenBudgetArgs
|
|
1007
|
+
| ExternalPipArgs
|
|
1008
|
+
| TokenConsumption
|