@orangecheck/agent-core 0.1.0 → 0.2.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.
@@ -12,27 +12,38 @@ import {
12
12
  computeActionId,
13
13
  computeDelegationId,
14
14
  computeRevocationId,
15
+ computeSubdelegationId,
15
16
  delegationCanonicalMessage,
16
17
  revocationCanonicalMessage,
18
+ subdelegationCanonicalMessage,
17
19
  } from './canonical.js';
18
- import { verifyAction, verifyDelegation, verifyRevocation } from './verify.js';
20
+ import { parseScope, ScopeParseError } from './scope.js';
21
+ import { verifyAction, verifyDelegation, verifyRevocation, verifySubdelegation } from './verify.js';
19
22
  import type {
20
23
  ActionEnvelope,
24
+ AgentErrorCode,
21
25
  DelegationEnvelope,
22
26
  RevocationEnvelope,
27
+ SubdelegationEnvelope,
23
28
  } from './types.js';
24
29
 
25
30
  const __dirname = dirname(fileURLToPath(import.meta.url));
26
- const VECTORS_DIR = resolve(__dirname, '..', '..', '..', 'oc-agent-protocol', 'test-vectors');
31
+ const VECTORS_DIR =
32
+ process.env.OC_AGENT_VECTORS_DIR ??
33
+ resolve(__dirname, '..', '..', '..', 'oc-agent-protocol', 'test-vectors');
27
34
 
28
35
  interface BaseVector {
29
36
  description: string;
30
- kind: 'delegation' | 'action' | 'revocation';
37
+ kind: 'delegation' | 'action' | 'revocation' | 'subdelegation';
31
38
  expected: {
32
39
  canonical_message: string;
33
40
  canonical_message_bytes_len: number;
34
41
  id: string;
35
- envelope: DelegationEnvelope | ActionEnvelope | RevocationEnvelope;
42
+ envelope:
43
+ | DelegationEnvelope
44
+ | ActionEnvelope
45
+ | RevocationEnvelope
46
+ | SubdelegationEnvelope;
36
47
  };
37
48
  }
38
49
 
@@ -75,7 +86,106 @@ interface RevocationVector extends BaseVector {
75
86
  expected: BaseVector['expected'] & { envelope: RevocationEnvelope };
76
87
  }
77
88
 
78
- type Vector = DelegationVector | ActionVector | RevocationVector;
89
+ interface SubdelegationVector extends BaseVector {
90
+ kind: 'subdelegation';
91
+ inputs: {
92
+ parent_id: string;
93
+ principal: string;
94
+ agent: string;
95
+ scopes: string[];
96
+ issued_at: string;
97
+ expires_at: string;
98
+ nonce: string;
99
+ };
100
+ expected: BaseVector['expected'] & { envelope: SubdelegationEnvelope };
101
+ }
102
+
103
+ // Negative vectors carry "negative": true at the top level and document an
104
+ // expected verifier rejection (SPEC §11 error code) instead of a canonical-
105
+ // message round-trip.
106
+ interface NegativeVectorBase {
107
+ description: string;
108
+ kind: 'delegation' | 'action' | 'revocation';
109
+ negative: true;
110
+ expected: {
111
+ verification_outcome: 'REJECT';
112
+ error_code: AgentErrorCode;
113
+ spec_reference: string;
114
+ rejection_reason: string;
115
+ };
116
+ }
117
+
118
+ interface NegativeActionVector extends NegativeVectorBase {
119
+ kind: 'action';
120
+ inputs: {
121
+ address: string;
122
+ content_hash: string;
123
+ content_length: number;
124
+ content_mime: string;
125
+ signed_at: string;
126
+ delegation_id: string;
127
+ scope_exercised: string;
128
+ content_ref?: string | null;
129
+ ots?: unknown;
130
+ };
131
+ }
132
+
133
+ interface NegativeRevocationVector extends NegativeVectorBase {
134
+ kind: 'revocation';
135
+ inputs: {
136
+ address: string;
137
+ delegation_id: string;
138
+ reason: string;
139
+ signed_at: string;
140
+ };
141
+ }
142
+
143
+ interface NegativeDelegationVector extends NegativeVectorBase {
144
+ kind: 'delegation';
145
+ inputs: {
146
+ principal: string;
147
+ agent: string;
148
+ scopes: string[];
149
+ bond: { sats: number; attestation_id: string } | null;
150
+ issued_at: string;
151
+ expires_at: string;
152
+ nonce: string;
153
+ };
154
+ additional_malformed_examples_for_implementer_smoke_tests?: {
155
+ scope: string;
156
+ why: string;
157
+ }[];
158
+ }
159
+
160
+ interface NegativeSubdelegationVector extends NegativeVectorBase {
161
+ kind: 'subdelegation';
162
+ inputs: {
163
+ parent_id: string;
164
+ principal: string;
165
+ agent: string;
166
+ scopes: string[];
167
+ issued_at: string;
168
+ expires_at: string;
169
+ nonce: string;
170
+ };
171
+ }
172
+
173
+ type NegativeVector =
174
+ | NegativeActionVector
175
+ | NegativeRevocationVector
176
+ | NegativeDelegationVector
177
+ | NegativeSubdelegationVector;
178
+
179
+ type Vector =
180
+ | DelegationVector
181
+ | ActionVector
182
+ | RevocationVector
183
+ | SubdelegationVector
184
+ | NegativeVector;
185
+
186
+ function isNegative(v: Vector): v is NegativeVector {
187
+ return 'negative' in v && v.negative === true;
188
+ }
79
189
 
80
190
  async function loadVectors(): Promise<{ name: string; data: Vector }[]> {
81
191
  try {
@@ -100,16 +210,22 @@ describe('oc-agent-protocol test vectors', () => {
100
210
  return;
101
211
  }
102
212
 
103
- // First pass: the vectors can cross-reference each other (action cites delegation id).
104
- // We'll need the envelopes keyed for the verifyAction tests.
213
+ // First pass: the vectors can cross-reference each other (action cites delegation id;
214
+ // subdelegation cites parent id which may be a root or another sub).
215
+ // Key both kinds so chain walking works.
105
216
  const delegationEnvelopes = new Map<string, DelegationEnvelope>();
217
+ const subdelegationEnvelopes = new Map<string, SubdelegationEnvelope>();
106
218
  for (const { data } of vectors) {
219
+ if (isNegative(data)) continue;
107
220
  if (data.kind === 'delegation') {
108
221
  delegationEnvelopes.set(data.expected.id, data.expected.envelope);
222
+ } else if (data.kind === 'subdelegation') {
223
+ subdelegationEnvelopes.set(data.expected.id, data.expected.envelope);
109
224
  }
110
225
  }
111
226
 
112
227
  for (const { name, data } of vectors) {
228
+ if (isNegative(data)) continue;
113
229
  it(`${name} — canonical message reconstructs byte-identical`, () => {
114
230
  const msg = reconstructCanonical(data);
115
231
  expect(msg).toBe(data.expected.canonical_message);
@@ -143,7 +259,7 @@ describe('oc-agent-protocol test vectors', () => {
143
259
  skipSignatureVerification: true,
144
260
  });
145
261
  expect(r.ok).toBe(true);
146
- } else {
262
+ } else if (data.kind === 'revocation') {
147
263
  const delegation = delegationEnvelopes.get(data.inputs.delegation_id);
148
264
  if (!delegation) {
149
265
  throw new Error(`revocation vector ${name} references missing delegation ${data.inputs.delegation_id}`);
@@ -154,12 +270,36 @@ describe('oc-agent-protocol test vectors', () => {
154
270
  skipSignatureVerification: true,
155
271
  });
156
272
  expect(r.ok).toBe(true);
273
+ } else {
274
+ // subdelegation — parent may be a root delegation or another subdelegation.
275
+ const parentId = data.inputs.parent_id;
276
+ const parent =
277
+ delegationEnvelopes.get(parentId) ??
278
+ subdelegationEnvelopes.get(parentId);
279
+ if (!parent) {
280
+ throw new Error(
281
+ `subdelegation vector ${name} references missing parent ${parentId}`
282
+ );
283
+ }
284
+ const r = await verifySubdelegation({
285
+ envelope: data.expected.envelope,
286
+ parent,
287
+ skipSignatureVerification: true,
288
+ skipTemporalCheck: true,
289
+ });
290
+ expect(r.ok).toBe(true);
157
291
  }
158
292
  });
159
293
  }
160
294
  });
161
295
 
162
- function reconstructCanonical(v: Vector): string {
296
+ type PositiveVector =
297
+ | DelegationVector
298
+ | ActionVector
299
+ | RevocationVector
300
+ | SubdelegationVector;
301
+
302
+ function reconstructCanonical(v: PositiveVector): string {
163
303
  if (v.kind === 'delegation') {
164
304
  // Canonicalize each scope (constraints sorted by key) then sort the list.
165
305
  const canonical = canonicalizeScopes(v.inputs.scopes);
@@ -177,10 +317,23 @@ function reconstructCanonical(v: Vector): string {
177
317
  if (v.kind === 'action') {
178
318
  return actionCanonicalMessage(v.inputs);
179
319
  }
180
- return revocationCanonicalMessage(v.inputs);
320
+ if (v.kind === 'revocation') {
321
+ return revocationCanonicalMessage(v.inputs);
322
+ }
323
+ // subdelegation
324
+ const canonical = canonicalizeScopes(v.inputs.scopes);
325
+ return subdelegationCanonicalMessage({
326
+ parent_id: v.inputs.parent_id,
327
+ principal: v.inputs.principal,
328
+ agent: v.inputs.agent,
329
+ scopes: canonical,
330
+ issued_at: v.inputs.issued_at,
331
+ expires_at: v.inputs.expires_at,
332
+ nonce: v.inputs.nonce,
333
+ });
181
334
  }
182
335
 
183
- function reconstructId(v: Vector): string {
336
+ function reconstructId(v: PositiveVector): string {
184
337
  if (v.kind === 'delegation') {
185
338
  const canonical = canonicalizeScopes(v.inputs.scopes);
186
339
  return computeDelegationId({
@@ -195,5 +348,198 @@ function reconstructId(v: Vector): string {
195
348
  });
196
349
  }
197
350
  if (v.kind === 'action') return computeActionId(v.inputs);
198
- return computeRevocationId(v.inputs);
351
+ if (v.kind === 'revocation') return computeRevocationId(v.inputs);
352
+ // subdelegation
353
+ const canonical = canonicalizeScopes(v.inputs.scopes);
354
+ return computeSubdelegationId({
355
+ parent_id: v.inputs.parent_id,
356
+ principal: v.inputs.principal,
357
+ agent: v.inputs.agent,
358
+ scopes: canonical,
359
+ issued_at: v.inputs.issued_at,
360
+ expires_at: v.inputs.expires_at,
361
+ nonce: v.inputs.nonce,
362
+ });
199
363
  }
364
+
365
+ // ─────────────────────────────────────────────────────────────────────────────
366
+ // Negative vectors — verifier MUST reject with the declared error code.
367
+ // ─────────────────────────────────────────────────────────────────────────────
368
+
369
+ describe('oc-agent-protocol negative test vectors', () => {
370
+ const negatives = vectors.filter(({ data }) => isNegative(data)) as {
371
+ name: string;
372
+ data: NegativeVector;
373
+ }[];
374
+
375
+ if (negatives.length === 0) {
376
+ it.skip('(no negative vectors found)', () => {});
377
+ return;
378
+ }
379
+
380
+ // Need positive delegation envelopes to feed verifyAction / verifyRevocation
381
+ // for the cross-referenced negative vectors.
382
+ const delegationEnvelopes = new Map<string, DelegationEnvelope>();
383
+ for (const { data } of vectors) {
384
+ if (!isNegative(data) && data.kind === 'delegation') {
385
+ delegationEnvelopes.set(data.expected.id, data.expected.envelope);
386
+ }
387
+ }
388
+
389
+ for (const { name, data } of negatives) {
390
+ it(`${name} — verifier rejects with ${data.expected.error_code}`, async () => {
391
+ if (data.kind === 'action') {
392
+ const v = data as NegativeActionVector;
393
+ const delegation = delegationEnvelopes.get(v.inputs.delegation_id);
394
+ if (!delegation) {
395
+ throw new Error(
396
+ `negative action vector ${name} cites missing delegation ${v.inputs.delegation_id}`
397
+ );
398
+ }
399
+ // Build a syntactically well-formed action envelope from the
400
+ // inputs (the rejection here is semantic — scope / window —
401
+ // not structural). The id MUST be the real sha256(canonical)
402
+ // or the verifier will short-circuit with E_BAD_ID before
403
+ // reaching the rule we're actually testing.
404
+ const realId = computeActionId({
405
+ address: v.inputs.address,
406
+ content_hash: v.inputs.content_hash,
407
+ content_length: v.inputs.content_length,
408
+ content_mime: v.inputs.content_mime,
409
+ signed_at: v.inputs.signed_at,
410
+ delegation_id: v.inputs.delegation_id,
411
+ scope_exercised: v.inputs.scope_exercised,
412
+ });
413
+ const action: ActionEnvelope = {
414
+ v: 1,
415
+ kind: 'agent-action',
416
+ id: realId,
417
+ content: {
418
+ hash: v.inputs.content_hash,
419
+ length: v.inputs.content_length,
420
+ mime: v.inputs.content_mime,
421
+ ref: v.inputs.content_ref ?? null,
422
+ },
423
+ signer: { address: v.inputs.address, alg: 'bip322' },
424
+ signed_at: v.inputs.signed_at,
425
+ delegation_id: v.inputs.delegation_id,
426
+ scope_exercised: v.inputs.scope_exercised,
427
+ ots: null,
428
+ sig: { alg: 'bip322', pubkey: v.inputs.address, value: 'AAAA' },
429
+ };
430
+ const r = await verifyAction({
431
+ action,
432
+ delegation,
433
+ skipSignatureVerification: true,
434
+ });
435
+ expect(r.ok).toBe(false);
436
+ if (!r.ok) {
437
+ // v07 specifically allows either E_OUT_OF_WINDOW or E_EXPIRED
438
+ // since both fire for an action signed past the delegation's
439
+ // expires_at; harness_assertion documents this.
440
+ if (
441
+ v.expected.error_code === 'E_OUT_OF_WINDOW' &&
442
+ r.code === 'E_EXPIRED'
443
+ ) {
444
+ // accept the alternate code
445
+ } else {
446
+ expect(r.code).toBe(v.expected.error_code);
447
+ }
448
+ }
449
+ } else if (data.kind === 'revocation') {
450
+ const v = data as NegativeRevocationVector;
451
+ const delegation = delegationEnvelopes.get(v.inputs.delegation_id);
452
+ if (!delegation) {
453
+ throw new Error(
454
+ `negative revocation vector ${name} cites missing delegation ${v.inputs.delegation_id}`
455
+ );
456
+ }
457
+ const realRevId = computeRevocationId({
458
+ address: v.inputs.address,
459
+ delegation_id: v.inputs.delegation_id,
460
+ reason: v.inputs.reason,
461
+ signed_at: v.inputs.signed_at,
462
+ });
463
+ const revocation: RevocationEnvelope = {
464
+ v: 1,
465
+ kind: 'agent-revocation',
466
+ id: realRevId,
467
+ delegation_id: v.inputs.delegation_id,
468
+ signer: { address: v.inputs.address, alg: 'bip322' },
469
+ reason: v.inputs.reason,
470
+ signed_at: v.inputs.signed_at,
471
+ ots: null,
472
+ sig: { alg: 'bip322', pubkey: v.inputs.address, value: 'AAAA' },
473
+ };
474
+ const r = await verifyRevocation({
475
+ envelope: revocation,
476
+ delegation,
477
+ skipSignatureVerification: true,
478
+ });
479
+ expect(r.ok).toBe(false);
480
+ if (!r.ok) expect(r.code).toBe(v.expected.error_code);
481
+ } else if (data.kind === 'delegation') {
482
+ // Delegation negative vectors target the §7.1 grammar parser
483
+ // directly — that's where E_BAD_SCOPE_GRAMMAR fires per
484
+ // SPEC §8.1 step 4. Asserting parseScope throws is the
485
+ // right level of rigor.
486
+ const v = data as NegativeDelegationVector;
487
+ expect(v.expected.error_code).toBe('E_BAD_SCOPE_GRAMMAR');
488
+ for (const s of v.inputs.scopes) {
489
+ expect(() => parseScope(s)).toThrow(ScopeParseError);
490
+ }
491
+ for (const ex of v.additional_malformed_examples_for_implementer_smoke_tests ??
492
+ []) {
493
+ expect(
494
+ () => parseScope(ex.scope),
495
+ `additional malformed example: ${ex.why}`
496
+ ).toThrow(ScopeParseError);
497
+ }
498
+ } else if (data.kind === 'subdelegation') {
499
+ // v12, v13, v14 — chain-link checks via verifySubdelegation.
500
+ // Build a structurally valid subdelegation envelope from inputs
501
+ // (the rejection here is semantic — scope / window / linkage —
502
+ // not structural) and feed it to verifySubdelegation against the
503
+ // root delegation vector.
504
+ const v = data as NegativeSubdelegationVector;
505
+ const parent = delegationEnvelopes.get(v.inputs.parent_id);
506
+ if (!parent) {
507
+ throw new Error(
508
+ `negative subdelegation vector ${name} references missing parent ${v.inputs.parent_id}`
509
+ );
510
+ }
511
+ const realId = computeSubdelegationId({
512
+ parent_id: v.inputs.parent_id,
513
+ principal: v.inputs.principal,
514
+ agent: v.inputs.agent,
515
+ scopes: canonicalizeScopes(v.inputs.scopes),
516
+ issued_at: v.inputs.issued_at,
517
+ expires_at: v.inputs.expires_at,
518
+ nonce: v.inputs.nonce,
519
+ });
520
+ const sub: SubdelegationEnvelope = {
521
+ v: 1,
522
+ kind: 'agent-subdelegation',
523
+ id: realId,
524
+ parent_id: v.inputs.parent_id,
525
+ principal: { address: v.inputs.principal, alg: 'bip322' },
526
+ agent: { address: v.inputs.agent, alg: 'bip322' },
527
+ scopes: v.inputs.scopes,
528
+ issued_at: v.inputs.issued_at,
529
+ expires_at: v.inputs.expires_at,
530
+ nonce: v.inputs.nonce,
531
+ revocation: { holders: ['principal'], ref: null },
532
+ sig: { alg: 'bip322', pubkey: v.inputs.principal, value: 'AAAA' },
533
+ };
534
+ const r = await verifySubdelegation({
535
+ envelope: sub,
536
+ parent,
537
+ skipSignatureVerification: true,
538
+ skipTemporalCheck: true,
539
+ });
540
+ expect(r.ok).toBe(false);
541
+ if (!r.ok) expect(r.code).toBe(v.expected.error_code);
542
+ }
543
+ });
544
+ }
545
+ });
package/src/types.ts CHANGED
@@ -2,7 +2,11 @@
2
2
 
3
3
  export const ENVELOPE_VERSION = 1 as const;
4
4
 
5
- export type EnvelopeKind = 'agent-delegation' | 'agent-action' | 'agent-revocation';
5
+ export type EnvelopeKind =
6
+ | 'agent-delegation'
7
+ | 'agent-action'
8
+ | 'agent-revocation'
9
+ | 'agent-subdelegation';
6
10
 
7
11
  // ─────────────────────────────────────────────────────────────────────────────
8
12
  // Shared building blocks
@@ -111,6 +115,41 @@ export interface ActionCanonicalInput {
111
115
  scope_exercised: string;
112
116
  }
113
117
 
118
+ // ─────────────────────────────────────────────────────────────────────────────
119
+ // Sub-delegation (SUB-DELEGATION.md, v1.1)
120
+ // ─────────────────────────────────────────────────────────────────────────────
121
+
122
+ export interface SubdelegationEnvelope {
123
+ v: typeof ENVELOPE_VERSION;
124
+ kind: 'agent-subdelegation';
125
+ id: string; // 64-hex sha256(canonical_message)
126
+ parent_id: string; // 64-hex; the immediate parent envelope's id
127
+ /** The sub-principal — equal to parent.agent.address. */
128
+ principal: ActorRef;
129
+ /** The recipient sub-agent. */
130
+ agent: ActorRef;
131
+ /** Each scope MUST be a sub-scope of some scope on the parent. */
132
+ scopes: string[];
133
+ issued_at: string; // ISO 8601 UTC; >= parent.issued_at
134
+ expires_at: string; // ISO 8601 UTC; <= parent.expires_at
135
+ nonce: string; // 32-hex random
136
+ revocation: DelegationRevocationRef;
137
+ sig: Signature;
138
+ }
139
+
140
+ export interface SubdelegationCanonicalInput {
141
+ parent_id: string;
142
+ principal: string;
143
+ agent: string;
144
+ scopes: string[]; // pre-canonicalized, pre-sorted
145
+ issued_at: string;
146
+ expires_at: string;
147
+ nonce: string;
148
+ }
149
+
150
+ /** Either a root or a sub envelope; chain links walk up to a root delegation. */
151
+ export type ChainLink = DelegationEnvelope | SubdelegationEnvelope;
152
+
114
153
  // ─────────────────────────────────────────────────────────────────────────────
115
154
  // Revocation (SPEC §9)
116
155
  // ─────────────────────────────────────────────────────────────────────────────
@@ -157,7 +196,11 @@ export type AgentErrorCode =
157
196
  | 'E_BOND_UNMET'
158
197
  | 'E_BOND_UNVERIFIED'
159
198
  | 'E_REVOKER_UNAUTHORIZED'
160
- | 'E_CALENDAR_UNREACHABLE';
199
+ | 'E_CALENDAR_UNREACHABLE'
200
+ | 'E_SUBDELEGATION_DEPTH_EXCEEDED'
201
+ | 'E_SUBDELEGATION_PRINCIPAL_MISMATCH'
202
+ | 'E_SUBDELEGATION_EXPIRES_EXTENDED'
203
+ | 'E_SUBDELEGATION_SCOPE_ESCALATED';
161
204
 
162
205
  export interface VerifyOk<T> {
163
206
  ok: true;
@@ -174,9 +217,17 @@ export interface VerifyErr {
174
217
 
175
218
  export type VerifyDelegationResult = VerifyOk<DelegationEnvelope> | VerifyErr;
176
219
  export type VerifyRevocationResult = VerifyOk<RevocationEnvelope> | VerifyErr;
220
+ export type VerifySubdelegationResult = VerifyOk<SubdelegationEnvelope> | VerifyErr;
177
221
 
178
222
  export interface VerifyActionOkExtra {
223
+ /** The ROOT delegation rooting the authority chain. */
179
224
  delegation: DelegationEnvelope;
225
+ /**
226
+ * The sub-delegation chain `[S_1, …, S_leaf]`. Empty when the action cites
227
+ * the root directly. The action's authority leaf is
228
+ * `chain[chain.length - 1] ?? delegation`.
229
+ */
230
+ chain: SubdelegationEnvelope[];
180
231
  scopeExercised: string;
181
232
  anchor:
182
233
  | { status: 'none' }