@orangecheck/agent-core 2.5.0 → 2.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orangecheck/agent-core",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "OC Agent canonical messages, envelope formats (delegation/action/revocation), scope grammar, and verification. See https://github.com/orangecheck/oc-agent-protocol.",
5
5
  "keywords": [
6
6
  "bitcoin",
@@ -0,0 +1,82 @@
1
+ // SPEC §4.4: expires_at > issued_at and expires_at - issued_at ≤ 365 days, checked at
2
+ // §8.1 step 2 (E_MALFORMED) whatever the clock says. Every envelope here has an honest
3
+ // id, so without the window check each one would verify.
4
+
5
+ import { describe, expect, it } from 'vitest';
6
+
7
+ import { canonicalizeScopes, computeDelegationId } from './canonical.js';
8
+ import type { DelegationEnvelope } from './types.js';
9
+ import { verifyDelegation } from './verify.js';
10
+
11
+ const ISSUED = '2026-04-22T12:00:00Z';
12
+ const DAY = 86_400_000;
13
+ const iso = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
14
+
15
+ function delegation(issued_at: string, expires_at: string): DelegationEnvelope {
16
+ const principal = 'bc1qprincipal000000000000000000000000000000';
17
+ const agent = 'bc1qagent0000000000000000000000000000000000';
18
+ const scopes = canonicalizeScopes(['ln:send(max_sats<=1000)']);
19
+ const nonce = '0123456789abcdef0123456789abcdef';
20
+ const id = computeDelegationId({
21
+ principal,
22
+ agent,
23
+ scopes,
24
+ bond_sats: 0,
25
+ bond_attestation: 'none',
26
+ issued_at,
27
+ expires_at,
28
+ nonce,
29
+ });
30
+ return {
31
+ v: 1,
32
+ kind: 'agent-delegation',
33
+ id,
34
+ principal: { address: principal, alg: 'bip322' },
35
+ agent: { address: agent, alg: 'bip322' },
36
+ scopes,
37
+ bond: null,
38
+ issued_at,
39
+ expires_at,
40
+ nonce,
41
+ revocation: { holders: ['principal'], ref: null },
42
+ sig: { alg: 'bip322', pubkey: principal, value: 'AAAA' },
43
+ };
44
+ }
45
+
46
+ const verify = (env: DelegationEnvelope, now?: Date) =>
47
+ verifyDelegation({
48
+ envelope: env,
49
+ skipSignatureVerification: true,
50
+ skipRevocationCheck: true,
51
+ ...(now ? { now } : { skipTemporalCheck: true }),
52
+ });
53
+
54
+ describe('delegation window (SPEC §4.4)', () => {
55
+ const issuedMs = Date.parse(ISSUED);
56
+
57
+ it('accepts exactly 365 days', async () => {
58
+ expect((await verify(delegation(ISSUED, iso(issuedMs + 365 * DAY)))).ok).toBe(true);
59
+ });
60
+
61
+ it('rejects 365 days and one second with E_MALFORMED', async () => {
62
+ const r = await verify(delegation(ISSUED, iso(issuedMs + 365 * DAY + 1000)));
63
+ expect(r).toMatchObject({ ok: false, code: 'E_MALFORMED' });
64
+ if (!r.ok) expect(r.message).toBe('expires_at is more than 365 days after issued_at (SPEC §4.4)');
65
+ });
66
+
67
+ it('rejects 366 days even inside the window, so the clock cannot rescue it', async () => {
68
+ const r = await verify(delegation(ISSUED, iso(issuedMs + 366 * DAY)), new Date(issuedMs + DAY));
69
+ expect(r).toMatchObject({ ok: false, code: 'E_MALFORMED' });
70
+ });
71
+
72
+ it('rejects expires_at equal to issued_at with temporal checks skipped', async () => {
73
+ const r = await verify(delegation(ISSUED, ISSUED));
74
+ expect(r).toMatchObject({ ok: false, code: 'E_MALFORMED' });
75
+ if (!r.ok) expect(r.message).toBe('expires_at must be after issued_at');
76
+ });
77
+
78
+ it('rejects a timestamp that is shaped right but is no real time', async () => {
79
+ const r = await verify(delegation(ISSUED, '2026-13-01T00:00:00Z'));
80
+ expect(r).toMatchObject({ ok: false, code: 'E_MALFORMED' });
81
+ });
82
+ });
@@ -16,7 +16,7 @@ import {
16
16
  type FederationDelegationEnvelope,
17
17
  type FederationRevocationEnvelope,
18
18
  } from './federation.js';
19
- import { computeRevocationId } from './canonical.js';
19
+ import { computeDelegationId, computeRevocationId } from './canonical.js';
20
20
  import { verifyDelegation } from './verify.js';
21
21
  import type { DelegationEnvelope } from './types.js';
22
22
 
@@ -168,9 +168,29 @@ d('federation reject reasons (each invalid case fails for its named reason)', ()
168
168
  expect(r.ok).toBe(false);
169
169
  if (!r.ok) expect(r.code).toBe('E_THRESHOLD_MISMATCH');
170
170
  });
171
+ it('366-day window → E_MALFORMED (FEDERATION.md §5 bounds it as SPEC §4.4 does)', async () => {
172
+ const v = await load('v19-federation-delegation-3of5-valid.json');
173
+ const env = { ...v.expected.envelope } as FederationDelegationEnvelope;
174
+ env.expires_at = new Date(Date.parse(env.issued_at) + 366 * 86_400_000)
175
+ .toISOString()
176
+ .replace(/\.\d{3}Z$/, 'Z');
177
+ // Honest id for the longer window, so the window is the only fault.
178
+ env.id = computeDelegationId({
179
+ principal: `federation:${env.principal.descriptor_id}`,
180
+ agent: env.agent.address,
181
+ scopes: env.scopes,
182
+ bond_sats: env.bond?.sats ?? 0,
183
+ bond_attestation: env.bond?.attestation_id ?? 'none',
184
+ issued_at: env.issued_at,
185
+ expires_at: env.expires_at,
186
+ nonce: env.nonce,
187
+ });
188
+ const r = await verifyFederationDelegation({ envelope: env, ...opts });
189
+ expect(r).toMatchObject({ ok: false, code: 'E_MALFORMED' });
190
+ });
171
191
  });
172
192
 
173
- // FEDERATION.md §4 / SECURITY §7 item 7: a revoked federation delegation must
193
+ // FEDERATION.md §4 / SECURITY §5 item 7: a revoked federation delegation must
174
194
  // not verify, and skipping the revocation check has to be stated.
175
195
  d('federation delegation revocation check', () => {
176
196
  const base = { skipSignatureVerification: true, skipTemporalCheck: true } as const;
@@ -180,6 +200,8 @@ d('federation delegation revocation check', () => {
180
200
  const r = await verifyFederationDelegation({ envelope: dv.expected.envelope, ...base });
181
201
  expect(r.ok).toBe(false);
182
202
  expect(r.ok === false && r.message).toMatch(/requires `revocations`/);
203
+ expect(r.ok === false && r.message).toMatch(/"#t": \[delegation id\]/);
204
+ expect(r.ok === false && r.message).not.toMatch(/#delegation/);
183
205
  });
184
206
 
185
207
  it('verifies with an empty revocation list', async () => {
package/src/federation.ts CHANGED
@@ -32,6 +32,7 @@ import type {
32
32
  DelegationRevocationRef,
33
33
  RevocationEnvelope,
34
34
  } from './types.js';
35
+ import { delegationWindowError } from './verify.js';
35
36
 
36
37
  // ─────────────────────────────────────────────────────────────────────────────
37
38
  // Types (FEDERATION.md §2 / §3.2)
@@ -228,7 +229,7 @@ async function checkFederationQuorum(
228
229
  export interface VerifyFederationDelegationInput extends VerifyFederationBase {
229
230
  envelope: FederationDelegationEnvelope;
230
231
  /**
231
- * Revocations to check (kind-30085 by `#delegation`): federation revocations,
232
+ * Revocations to check (kind-30085 by `#t` with the delegation id, SPEC §9.4): federation revocations,
232
233
  * and agent self-revocations when `revocation.holders` includes "agent".
233
234
  * Required unless `skipRevocationCheck` states that revocation is not checked.
234
235
  */
@@ -251,6 +252,8 @@ export async function verifyFederationDelegation(
251
252
  if (!ISO_UTC.test(env.issued_at) || !ISO_UTC.test(env.expires_at)) {
252
253
  return fail('E_MALFORMED', 'issued_at / expires_at must be ISO 8601 UTC');
253
254
  }
255
+ const windowError = delegationWindowError(env.issued_at, env.expires_at);
256
+ if (windowError) return fail('E_MALFORMED', windowError);
254
257
  if (!/^[0-9a-f]{32}$/.test(env.nonce)) return fail('E_MALFORMED', 'nonce must be 32 hex chars');
255
258
 
256
259
  // Canonical scopes (identical rules to v1) must be sorted on the envelope.
@@ -290,7 +293,7 @@ export async function verifyFederationDelegation(
290
293
  if (!input.skipRevocationCheck && !input.revocations) {
291
294
  return fail(
292
295
  'E_MALFORMED',
293
- 'verifyFederationDelegation requires `revocations` (fetch kind-30085 by #delegation) ' +
296
+ 'verifyFederationDelegation requires `revocations` (query kind 30085 with "#t": [delegation id], SPEC §9.4) ' +
294
297
  'or an explicit skipRevocationCheck:true to state that revocation is not being checked'
295
298
  );
296
299
  }
@@ -305,7 +308,6 @@ export async function verifyFederationDelegation(
305
308
  const now = input.now ?? new Date();
306
309
  const issued = new Date(env.issued_at);
307
310
  const expires = new Date(env.expires_at);
308
- if (expires <= issued) return fail('E_MALFORMED', 'expires_at <= issued_at');
309
311
  if (now < issued) return fail('E_NOT_YET_VALID', `delegation not valid until ${env.issued_at}`);
310
312
  if (now >= expires) return fail('E_EXPIRED', `delegation expired at ${env.expires_at}`);
311
313
  }
@@ -189,8 +189,10 @@ async function buildPrivateEnvelopeWithKey(
189
189
  ],
190
190
  });
191
191
 
192
- const issued_at = '2026-04-22T12:00:00Z';
193
- const expires_at = '2099-04-22T12:00:00Z';
192
+ // Current window, inside SPEC §4.4's 365-day limit, so the temporal check passes.
193
+ const isoSeconds = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
194
+ const issued_at = isoSeconds(Date.now() - 86_400_000);
195
+ const expires_at = isoSeconds(Date.now() + 30 * 86_400_000);
194
196
  const nonce = hexEncode(randomBytesN(16));
195
197
 
196
198
  const canonInput = {
@@ -1,6 +1,6 @@
1
- // SECURITY §7 item 7: "Query revocation feeds (Nostr kind-30085 by
2
- // `#delegation`) for the cited delegation id before reporting OK, **unless the
3
- // caller explicitly opts out** of revocation checking."
1
+ // SECURITY §5 item 7: "Query revocation feeds (Nostr kind-30085 by `#t` with
2
+ // the cited delegation id) before reporting OK, **unless the caller explicitly
3
+ // opts out** of revocation checking."
4
4
  //
5
5
  // The default was inverted. verifyDelegation had NO revocations parameter at
6
6
  // all — a revoked delegation returned ok:true and a caller had no way to say
@@ -12,8 +12,9 @@
12
12
  // changed is that declining to is now a decision they have to state.
13
13
  import { describe, expect, it } from 'vitest';
14
14
 
15
- import { verifyDelegation } from './verify.js';
16
- import type { DelegationEnvelope } from './types.js';
15
+ import { computeActionId } from './canonical.js';
16
+ import { verifyAction, verifyDelegation } from './verify.js';
17
+ import type { ActionEnvelope, DelegationEnvelope } from './types.js';
17
18
 
18
19
  // Inlined rather than read from oc-agent-protocol/test-vectors: this test is
19
20
  // about the revocation guard, not vector conformance, and a relative path into
@@ -68,11 +69,46 @@ describe('verifyDelegation fails closed on revocation', () => {
68
69
  });
69
70
  expect(r.ok).toBe(true);
70
71
  });
72
+ });
73
+
74
+ // The refusal tells the caller how to fetch revocations. SPEC §9.4: relays index
75
+ // single-letter tags only and reject a `#delegation` filter (SPEC §10.4), so the
76
+ // message must name the `#t` query.
77
+ describe('the refusal names a query relays answer', () => {
78
+ const action: ActionEnvelope = (() => {
79
+ const canon = {
80
+ address: delegation.agent.address,
81
+ content_hash: 'sha256:' + '00'.repeat(32),
82
+ content_length: 0,
83
+ content_mime: 'text/plain',
84
+ signed_at: '2026-04-23T12:00:00Z',
85
+ delegation_id: delegation.id,
86
+ scope_exercised: delegation.scopes![0]!,
87
+ };
88
+ return {
89
+ v: 1,
90
+ kind: 'agent-action',
91
+ id: computeActionId(canon),
92
+ content: { hash: canon.content_hash, length: 0, mime: 'text/plain', ref: null },
93
+ signer: { address: canon.address, alg: 'bip322' },
94
+ signed_at: canon.signed_at,
95
+ delegation_id: delegation.id,
96
+ scope_exercised: canon.scope_exercised,
97
+ ots: null,
98
+ sig: { alg: 'bip322', pubkey: canon.address, value: 'AAAA' },
99
+ } as ActionEnvelope;
100
+ })();
71
101
 
72
- // An empty list and a skip both verify — but they mean different things, and
73
- // only one of them is a claim about the feed. That distinction is the point
74
- // of the change.
75
- it('the two are not the same statement', () => {
76
- expect('revocations: []').not.toBe('skipRevocationCheck: true');
102
+ it('verifyDelegation', async () => {
103
+ const r = await verifyDelegation({ envelope: delegation, skipSignatureVerification: true, now: AT });
104
+ expect(r.ok === false && r.message).toMatch(/"#t": \[delegation id\]/);
105
+ expect(r.ok === false && r.message).not.toMatch(/#delegation/);
106
+ });
107
+
108
+ it('verifyAction', async () => {
109
+ const r = await verifyAction({ action, delegation, skipSignatureVerification: true });
110
+ expect(r.ok === false && r.code).toBe('E_MALFORMED');
111
+ expect(r.ok === false && r.message).toMatch(/"#t": \[id of every link in the chain\]/);
112
+ expect(r.ok === false && r.message).not.toMatch(/#delegation/);
77
113
  });
78
114
  });
package/src/scope.test.ts CHANGED
@@ -257,3 +257,23 @@ describe('the adapter scopes are registered', () => {
257
257
  expect(isSubScope(parseScope('anthropic:tool(name=getWeather)'), granted)).toBe(false);
258
258
  });
259
259
  });
260
+
261
+ // SPEC §7.2 rule 2 (errata 1.6.2): values keep their case on every key, not only the
262
+ // four marked case-sensitive. Folding would break base58 addresses and widen grants.
263
+ describe('scope values keep their case (SPEC §7.2)', () => {
264
+ for (const s of [
265
+ 'lock:seal(recipient=1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2)',
266
+ 'mcp:invoke(server=https://mcp.example.com/Tools,tool=getWeather)',
267
+ 'http:request(method=GET,origin=https://api.example.com)',
268
+ ]) {
269
+ it(`canonical form of ${s} is unchanged`, () => {
270
+ expect(canonicalizeScopeString(s)).toBe(s);
271
+ });
272
+ }
273
+
274
+ it('a grant on a key not marked case-sensitive still matches case exactly', () => {
275
+ const granted = parseScope('mcp:invoke(tool=getWeather)');
276
+ expect(isSubScope(parseScope('mcp:invoke(tool=getWeather)'), granted)).toBe(true);
277
+ expect(isSubScope(parseScope('mcp:invoke(tool=GetWeather)'), granted)).toBe(false);
278
+ });
279
+ });
@@ -155,6 +155,12 @@ interface NegativeDelegationVector extends NegativeVectorBase {
155
155
  scope: string;
156
156
  why: string;
157
157
  }[];
158
+ // Present when the fault is in a well-formed, honestly-id'd envelope (v27).
159
+ expected: NegativeVectorBase['expected'] & {
160
+ canonical_message?: string;
161
+ id?: string;
162
+ envelope?: DelegationEnvelope;
163
+ };
158
164
  }
159
165
 
160
166
  interface NegativeSubdelegationVector extends NegativeVectorBase {
@@ -507,6 +513,21 @@ describe('oc-agent-protocol negative test vectors', () => {
507
513
  });
508
514
  expect(r.ok).toBe(false);
509
515
  if (!r.ok) expect(r.code).toBe(v.expected.error_code);
516
+ } else if (data.kind === 'delegation' && (data as NegativeDelegationVector).expected.envelope) {
517
+ // The envelope is honest (its id reconstructs from inputs), so the
518
+ // verifier's rejection is the declared fault and nothing else.
519
+ const v = data as NegativeDelegationVector;
520
+ const asPositive = { kind: 'delegation', inputs: v.inputs } as unknown as PositiveVector;
521
+ expect(reconstructCanonical(asPositive)).toBe(v.expected.canonical_message);
522
+ expect(reconstructId(asPositive)).toBe(v.expected.id);
523
+ const r = await verifyDelegation({
524
+ envelope: v.expected.envelope!,
525
+ skipSignatureVerification: true,
526
+ skipRevocationCheck: true,
527
+ skipTemporalCheck: true,
528
+ });
529
+ expect(r.ok).toBe(false);
530
+ if (!r.ok) expect(r.code).toBe(v.expected.error_code);
510
531
  } else if (data.kind === 'delegation') {
511
532
  // Delegation negative vectors target the §7.1 grammar parser
512
533
  // directly — that's where E_BAD_SCOPE_GRAMMAR fires per
package/src/verify.ts CHANGED
@@ -51,9 +51,9 @@ export interface VerifyBase {
51
51
  /**
52
52
  * Verify WITHOUT consulting revocation feeds. Required to be explicit.
53
53
  *
54
- * SECURITY §7 item 7: "Query revocation feeds (Nostr kind-30085 by
55
- * `#delegation`) for the cited delegation id before reporting OK, **unless
56
- * the caller explicitly opts out**." The default was inverted — revocations
54
+ * SECURITY §5 item 7: "Query revocation feeds (Nostr kind-30085 by `#t`
55
+ * with the cited delegation id) before reporting OK, **unless the caller
56
+ * explicitly opts out**." The default was inverted — revocations
57
57
  * were checked only when supplied, and omitting them skipped the check
58
58
  * silently, so "checked, clean" was indistinguishable from "never checked".
59
59
  *
@@ -63,7 +63,9 @@ export interface VerifyBase {
63
63
  skipRevocationCheck?: boolean;
64
64
  /**
65
65
  * Revocations to evaluate against the delegation (or, for an action, every
66
- * link in its chain). Fetch these from kind-30085 by `#delegation`.
66
+ * link in its chain). Fetch kind-30085 events with `"#t": [<delegation id>]`
67
+ * (SPEC §9.4); relays do not index the multi-letter `delegation` tag. Pass
68
+ * `[]` only when a relay answered with EOSE and none matched (SPEC §10.4).
67
69
  */
68
70
  revocations?: RevocationEnvelope[];
69
71
  }
@@ -222,15 +224,15 @@ export async function verifyDelegation(input: VerifyDelegationInput): Promise<Ve
222
224
  if (!ok) return err('E_BAD_SIG', 'BIP-322 signature did not verify');
223
225
  }
224
226
 
225
- // Revocation. SECURITY §7 item 7 — check unless the caller states otherwise.
227
+ // Revocation. SECURITY §5 item 7 — check unless the caller states otherwise.
226
228
  // Before this, verifyDelegation had NO revocations parameter at all: a
227
229
  // revoked delegation returned ok:true and a caller had no way to say
228
230
  // otherwise.
229
231
  if (!input.skipRevocationCheck && !input.revocations) {
230
232
  return err(
231
233
  'E_MALFORMED',
232
- 'verifyDelegation requires `revocations` (fetch kind-30085 by #delegation) or an ' +
233
- 'explicit skipRevocationCheck:true to state that revocation is not being checked',
234
+ 'verifyDelegation requires `revocations` (query kind 30085 with "#t": [delegation id], ' +
235
+ 'SPEC §9.4) or an explicit skipRevocationCheck:true to state that revocation is not being checked',
234
236
  );
235
237
  }
236
238
  if (input.revocations) {
@@ -252,7 +254,6 @@ export async function verifyDelegation(input: VerifyDelegationInput): Promise<Ve
252
254
  const now = input.now ?? new Date();
253
255
  const issued = new Date(env.issued_at);
254
256
  const expires = new Date(env.expires_at);
255
- if (expires <= issued) return err('E_MALFORMED', 'expires_at <= issued_at');
256
257
  if (now < issued) return err('E_NOT_YET_VALID', `delegation not valid until ${env.issued_at}`);
257
258
  if (now >= expires) return err('E_EXPIRED', `delegation expired at ${env.expires_at}`);
258
259
  }
@@ -452,8 +453,8 @@ export async function verifyAction(input: VerifyActionInput): Promise<VerifyActi
452
453
  if (!input.skipRevocationCheck && !input.revocations) {
453
454
  return err(
454
455
  'E_MALFORMED',
455
- 'verifyAction requires `revocations` (fetch kind-30085 by #delegation for every link ' +
456
- 'in the chain) or an explicit skipRevocationCheck:true',
456
+ 'verifyAction requires `revocations` (query kind 30085 with "#t": [id of every link ' +
457
+ 'in the chain], SPEC §9.4) or an explicit skipRevocationCheck:true',
457
458
  );
458
459
  }
459
460
  // Anchor heights only count once the proof is verified against the
@@ -670,12 +671,26 @@ function checkDelegationShape(env: DelegationEnvelope): VerifyDelegationResult |
670
671
  }
671
672
  if (!isIsoUtc(env.issued_at)) return err('E_MALFORMED', 'issued_at must be ISO 8601 UTC');
672
673
  if (!isIsoUtc(env.expires_at)) return err('E_MALFORMED', 'expires_at must be ISO 8601 UTC');
674
+ const windowError = delegationWindowError(env.issued_at, env.expires_at);
675
+ if (windowError) return err('E_MALFORMED', windowError);
673
676
  if (!/^[0-9a-f]{32}$/.test(env.nonce)) return err('E_MALFORMED', 'nonce must be 32 lowercase hex chars');
674
677
  if (env.sig?.alg !== 'bip322' || typeof env.sig.value !== 'string') return err('E_MALFORMED', 'sig invalid');
675
678
  if (env.sig.pubkey !== env.principal.address) return err('E_MALFORMED', 'sig.pubkey must equal principal.address');
676
679
  return null;
677
680
  }
678
681
 
682
+ const MAX_DELEGATION_MS = 365 * 86_400_000;
683
+
684
+ /** SPEC §4.4 window, checked at §8.1 step 2 whatever the clock says: issued < expires, at most 365 days. */
685
+ export function delegationWindowError(issuedAt: string, expiresAt: string): string | null {
686
+ const issued = Date.parse(issuedAt);
687
+ const expires = Date.parse(expiresAt);
688
+ if (Number.isNaN(issued) || Number.isNaN(expires)) return 'issued_at / expires_at must be real UTC times';
689
+ if (expires <= issued) return 'expires_at must be after issued_at';
690
+ if (expires - issued > MAX_DELEGATION_MS) return 'expires_at is more than 365 days after issued_at (SPEC §4.4)';
691
+ return null;
692
+ }
693
+
679
694
  function checkActionShape(a: ActionEnvelope): VerifyActionResult | null {
680
695
  if (a.kind !== 'agent-action') return err('E_MALFORMED', 'kind must be "agent-action"');
681
696
  if (!isHex64(a.id)) return err('E_MALFORMED', 'id must be 64 lowercase hex chars');