@orangecheck/agent-core 2.5.1 → 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.1",
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,6 +168,26 @@ 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
193
  // FEDERATION.md §4 / SECURITY §5 item 7: a revoked federation delegation must
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)
@@ -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.
@@ -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 = {
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
@@ -254,7 +254,6 @@ export async function verifyDelegation(input: VerifyDelegationInput): Promise<Ve
254
254
  const now = input.now ?? new Date();
255
255
  const issued = new Date(env.issued_at);
256
256
  const expires = new Date(env.expires_at);
257
- if (expires <= issued) return err('E_MALFORMED', 'expires_at <= issued_at');
258
257
  if (now < issued) return err('E_NOT_YET_VALID', `delegation not valid until ${env.issued_at}`);
259
258
  if (now >= expires) return err('E_EXPIRED', `delegation expired at ${env.expires_at}`);
260
259
  }
@@ -672,12 +671,26 @@ function checkDelegationShape(env: DelegationEnvelope): VerifyDelegationResult |
672
671
  }
673
672
  if (!isIsoUtc(env.issued_at)) return err('E_MALFORMED', 'issued_at must be ISO 8601 UTC');
674
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);
675
676
  if (!/^[0-9a-f]{32}$/.test(env.nonce)) return err('E_MALFORMED', 'nonce must be 32 lowercase hex chars');
676
677
  if (env.sig?.alg !== 'bip322' || typeof env.sig.value !== 'string') return err('E_MALFORMED', 'sig invalid');
677
678
  if (env.sig.pubkey !== env.principal.address) return err('E_MALFORMED', 'sig.pubkey must equal principal.address');
678
679
  return null;
679
680
  }
680
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
+
681
694
  function checkActionShape(a: ActionEnvelope): VerifyActionResult | null {
682
695
  if (a.kind !== 'agent-action') return err('E_MALFORMED', 'kind must be "agent-action"');
683
696
  if (!isHex64(a.id)) return err('E_MALFORMED', 'id must be 64 lowercase hex chars');