@orangecheck/agent-core 2.5.1 → 2.7.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.
@@ -106,7 +106,7 @@ describe('the refusal names a query relays answer', () => {
106
106
  });
107
107
 
108
108
  it('verifyAction', async () => {
109
- const r = await verifyAction({ action, delegation, skipSignatureVerification: true });
109
+ const r = await verifyAction({ action, delegation, skipSignatureVerification: true, now: AT });
110
110
  expect(r.ok === false && r.code).toBe('E_MALFORMED');
111
111
  expect(r.ok === false && r.message).toMatch(/"#t": \[id of every link in the chain\]/);
112
112
  expect(r.ok === false && r.message).not.toMatch(/#delegation/);
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
+ });
@@ -18,7 +18,13 @@ import {
18
18
  subdelegationCanonicalMessage,
19
19
  } from './canonical.js';
20
20
  import { parseScope, ScopeParseError } from './scope.js';
21
- import { verifyAction, verifyDelegation, verifyRevocation, verifySubdelegation } from './verify.js';
21
+ import {
22
+ verifyAction,
23
+ verifyActionAsOfSignedAt,
24
+ verifyDelegation,
25
+ verifyRevocation,
26
+ verifySubdelegation,
27
+ } from './verify.js';
22
28
  import type {
23
29
  ActionEnvelope,
24
30
  AgentErrorCode,
@@ -127,6 +133,12 @@ interface NegativeActionVector extends NegativeVectorBase {
127
133
  scope_exercised: string;
128
134
  content_ref?: string | null;
129
135
  ots?: unknown;
136
+ /** Present on a live vector (§8.1 steps 6–7, §8.3 step 11a): the time of verification. */
137
+ verified_at?: string;
138
+ /** The freshness bound a live vector assumes, in seconds. */
139
+ max_skew_seconds?: number;
140
+ /** Revocations of the cited delegation, as the inputs of each envelope. */
141
+ revocations?: { address: string; reason: string; signed_at: string }[];
130
142
  };
131
143
  }
132
144
 
@@ -155,6 +167,12 @@ interface NegativeDelegationVector extends NegativeVectorBase {
155
167
  scope: string;
156
168
  why: string;
157
169
  }[];
170
+ // Present when the fault is in a well-formed, honestly-id'd envelope (v27).
171
+ expected: NegativeVectorBase['expected'] & {
172
+ canonical_message?: string;
173
+ id?: string;
174
+ envelope?: DelegationEnvelope;
175
+ };
158
176
  }
159
177
 
160
178
  interface NegativeSubdelegationVector extends NegativeVectorBase {
@@ -273,7 +291,8 @@ describe('oc-agent-protocol test vectors', () => {
273
291
  if (!delegation) {
274
292
  throw new Error(`action vector ${name} references missing delegation ${data.inputs.delegation_id}`);
275
293
  }
276
- const r = await verifyAction({
294
+ // A positive vector is the envelope as of its own signed_at.
295
+ const r = await verifyActionAsOfSignedAt({
277
296
  skipRevocationCheck: true,
278
297
  action: data.expected.envelope,
279
298
  delegation,
@@ -455,12 +474,34 @@ describe('oc-agent-protocol negative test vectors', () => {
455
474
  ots: null,
456
475
  sig: { alg: 'bip322', pubkey: v.inputs.address, value: 'AAAA' },
457
476
  };
458
- const r = await verifyAction({
459
- skipRevocationCheck: true,
460
- action,
461
- delegation,
462
- skipSignatureVerification: true,
463
- });
477
+ const revocations: RevocationEnvelope[] = (v.inputs.revocations ?? []).map((rv) => ({
478
+ v: 1,
479
+ kind: 'agent-revocation',
480
+ id: computeRevocationId({ ...rv, delegation_id: v.inputs.delegation_id }),
481
+ delegation_id: v.inputs.delegation_id,
482
+ signer: { address: rv.address, alg: 'bip322' },
483
+ reason: rv.reason,
484
+ signed_at: rv.signed_at,
485
+ ots: null,
486
+ sig: { alg: 'bip322', pubkey: rv.address, value: 'AAAA' },
487
+ }));
488
+ // A vector with verified_at is checked live at that time; one
489
+ // without is an audit of the envelope as of its signed_at.
490
+ const r = v.inputs.verified_at
491
+ ? await verifyAction({
492
+ action,
493
+ delegation,
494
+ skipSignatureVerification: true,
495
+ revocations,
496
+ now: new Date(v.inputs.verified_at),
497
+ maxSkewMs: (v.inputs.max_skew_seconds ?? 120) * 1000,
498
+ })
499
+ : await verifyActionAsOfSignedAt({
500
+ skipRevocationCheck: true,
501
+ action,
502
+ delegation,
503
+ skipSignatureVerification: true,
504
+ });
464
505
  expect(r.ok).toBe(false);
465
506
  if (!r.ok) {
466
507
  // v07 specifically allows either E_OUT_OF_WINDOW or E_EXPIRED
@@ -507,6 +548,21 @@ describe('oc-agent-protocol negative test vectors', () => {
507
548
  });
508
549
  expect(r.ok).toBe(false);
509
550
  if (!r.ok) expect(r.code).toBe(v.expected.error_code);
551
+ } else if (data.kind === 'delegation' && (data as NegativeDelegationVector).expected.envelope) {
552
+ // The envelope is honest (its id reconstructs from inputs), so the
553
+ // verifier's rejection is the declared fault and nothing else.
554
+ const v = data as NegativeDelegationVector;
555
+ const asPositive = { kind: 'delegation', inputs: v.inputs } as unknown as PositiveVector;
556
+ expect(reconstructCanonical(asPositive)).toBe(v.expected.canonical_message);
557
+ expect(reconstructId(asPositive)).toBe(v.expected.id);
558
+ const r = await verifyDelegation({
559
+ envelope: v.expected.envelope!,
560
+ skipSignatureVerification: true,
561
+ skipRevocationCheck: true,
562
+ skipTemporalCheck: true,
563
+ });
564
+ expect(r.ok).toBe(false);
565
+ if (!r.ok) expect(r.code).toBe(v.expected.error_code);
510
566
  } else if (data.kind === 'delegation') {
511
567
  // Delegation negative vectors target the §7.1 grammar parser
512
568
  // directly — that's where E_BAD_SCOPE_GRAMMAR fires per
package/src/types.ts CHANGED
@@ -237,6 +237,7 @@ export type AgentErrorCode =
237
237
  | 'E_NOT_YET_VALID'
238
238
  | 'E_EXPIRED'
239
239
  | 'E_REVOKED'
240
+ | 'E_STALE'
240
241
  | 'E_DELEGATION_MISMATCH'
241
242
  | 'E_AGENT_MISMATCH'
242
243
  | 'E_OUT_OF_WINDOW'
@@ -298,6 +299,14 @@ export interface VerifyActionOkExtra {
298
299
  | { status: 'pending' }
299
300
  | { status: 'claimed'; blockHeight: number; blockHash: string; verified: false }
300
301
  | { status: 'confirmed'; blockHeight: number; blockHash: string; verified: true };
302
+ /**
303
+ * The time the verdict holds for. `now`: verifyAction, checked live.
304
+ * `signed_at`: verifyActionAsOfSignedAt, checked as of the time the agent
305
+ * claims; `anchored` is false when only the agent says so.
306
+ */
307
+ asOf:
308
+ | { basis: 'now'; now: string }
309
+ | { basis: 'signed_at'; signedAt: string; anchored: boolean };
301
310
  }
302
311
 
303
312
  export type VerifyActionResult =
package/src/verify.ts CHANGED
@@ -40,6 +40,9 @@ import {
40
40
  /** Default maximum chain depth, per SUB-DELEGATION.md §2.1. */
41
41
  export const DEFAULT_MAX_CHAIN_DEPTH = 5;
42
42
 
43
+ /** How far a live action's `signed_at` may lie from the verifier's clock, either way (SPEC §8.3 step 11a). */
44
+ export const DEFAULT_MAX_SKEW_MS = 120_000;
45
+
43
46
  // ─────────────────────────────────────────────────────────────────────────────
44
47
  // Shared options
45
48
  // ─────────────────────────────────────────────────────────────────────────────
@@ -254,7 +257,6 @@ export async function verifyDelegation(input: VerifyDelegationInput): Promise<Ve
254
257
  const now = input.now ?? new Date();
255
258
  const issued = new Date(env.issued_at);
256
259
  const expires = new Date(env.expires_at);
257
- if (expires <= issued) return err('E_MALFORMED', 'expires_at <= issued_at');
258
260
  if (now < issued) return err('E_NOT_YET_VALID', `delegation not valid until ${env.issued_at}`);
259
261
  if (now >= expires) return err('E_EXPIRED', `delegation expired at ${env.expires_at}`);
260
262
  }
@@ -325,9 +327,51 @@ export interface VerifyActionInput extends VerifyBase {
325
327
  device_id: string;
326
328
  secretKey: Uint8Array;
327
329
  };
330
+ /**
331
+ * The time of verification; defaults to new Date(). Every link of the
332
+ * chain must be in force at `now`, and a revocation in effect at or
333
+ * before `now` refuses the action, whatever `signed_at` it claims
334
+ * (SPEC §8.1 steps 6–7).
335
+ */
336
+ now?: Date;
337
+ /**
338
+ * How far `signed_at` may lie from `now`, either way, before the action
339
+ * is refused with E_STALE. Default DEFAULT_MAX_SKEW_MS (120 s). The agent
340
+ * chooses `signed_at`, so this bound is what makes it mean "just now".
341
+ */
342
+ maxSkewMs?: number;
328
343
  }
329
344
 
345
+ /** An audit of a past action: no `now`, no freshness bound. See verifyActionAsOfSignedAt. */
346
+ export type VerifyActionAsOfSignedAtInput = Omit<VerifyActionInput, 'now' | 'maxSkewMs'>;
347
+
348
+ /**
349
+ * Verify an action live, as of `now` (SPEC §8). This is the check for a
350
+ * server deciding whether to act on it. Replay is the caller's to stop: keep
351
+ * the ids of accepted actions (createReplayGuard) for as long as they could
352
+ * pass the freshness bound.
353
+ */
330
354
  export async function verifyAction(input: VerifyActionInput): Promise<VerifyActionResult> {
355
+ return verifyActionAt(input, input.now ?? new Date());
356
+ }
357
+
358
+ /**
359
+ * Verify a past action as of its own `signed_at`, for an audit (SPEC §8.6).
360
+ * The agent chose that time, so the verdict holds only if the claim is true:
361
+ * `asOf.anchored` is false unless a verified OTS anchor fixes it. Never use
362
+ * this to decide whether to act on an action now.
363
+ */
364
+ export async function verifyActionAsOfSignedAt(
365
+ input: VerifyActionAsOfSignedAtInput
366
+ ): Promise<VerifyActionResult> {
367
+ return verifyActionAt(input, null);
368
+ }
369
+
370
+ /** `now` null: the audit form, checked as of the action's own signed_at. */
371
+ async function verifyActionAt(
372
+ input: VerifyActionInput,
373
+ now: Date | null
374
+ ): Promise<VerifyActionResult> {
331
375
  const a = input.action;
332
376
  const d = input.delegation;
333
377
 
@@ -428,6 +472,19 @@ export async function verifyAction(input: VerifyActionInput): Promise<VerifyActi
428
472
  return err('E_OUT_OF_WINDOW', `action.signed_at ${a.signed_at} is outside leaf window [${leaf.issued_at}, ${leaf.expires_at})`);
429
473
  }
430
474
 
475
+ // 4b. Live: every link is in force now, not only at the claimed signed_at.
476
+ if (now) {
477
+ const t = now.getTime();
478
+ for (const link of [rootHydrated, ...hydratedChain]) {
479
+ if (t < new Date(link.issued_at).getTime()) {
480
+ return err('E_NOT_YET_VALID', `${link.id} is not valid until ${link.issued_at}`);
481
+ }
482
+ if (t >= new Date(link.expires_at).getTime()) {
483
+ return err('E_EXPIRED', `${link.id} expired at ${link.expires_at}`);
484
+ }
485
+ }
486
+ }
487
+
431
488
  // 5. Scope containment — against the leaf's granted set.
432
489
  // Leaf is post-hydration, so `scopes` is guaranteed populated. The
433
490
  // runtime guard satisfies the type checker and would only fire if a
@@ -490,10 +547,27 @@ export async function verifyAction(input: VerifyActionInput): Promise<VerifyActi
490
547
  if (revocationPrecedes(rev, revAnchor, a, actionAnchor)) {
491
548
  return err('E_REVOKED', `chain link ${link.id} was revoked by ${rev.id} before action was signed`);
492
549
  }
550
+ // Live: a revocation already in effect refuses the action, however
551
+ // early its signed_at claims to be.
552
+ if (now && (revAnchor !== null || new Date(rev.signed_at).getTime() <= now.getTime())) {
553
+ return err('E_REVOKED', `chain link ${link.id} was revoked by ${rev.id}`);
554
+ }
493
555
  }
494
556
  }
495
557
  }
496
558
 
559
+ // 6b. Live: signed_at is the agent's claim; only a bound on it makes the
560
+ // action recent.
561
+ if (now) {
562
+ const maxSkew = input.maxSkewMs ?? DEFAULT_MAX_SKEW_MS;
563
+ if (Math.abs(now.getTime() - signed) > maxSkew) {
564
+ return err(
565
+ 'E_STALE',
566
+ `action.signed_at ${a.signed_at} is more than ${maxSkew} ms from the time of verification ${now.toISOString()}`
567
+ );
568
+ }
569
+ }
570
+
497
571
  // 7. Content check.
498
572
  if (input.content) {
499
573
  const actualHash = 'sha256:' + hexEncode(sha256(input.content));
@@ -530,6 +604,31 @@ export async function verifyAction(input: VerifyActionInput): Promise<VerifyActi
530
604
  chain: hydratedChain,
531
605
  scopeExercised: exercised,
532
606
  anchor,
607
+ asOf: now
608
+ ? { basis: 'now', now: now.toISOString() }
609
+ : { basis: 'signed_at', signedAt: a.signed_at, anchored: actionAnchor !== null },
610
+ };
611
+ }
612
+
613
+ /**
614
+ * Remembers the ids of accepted actions, so one signed action is acted on
615
+ * once. An action passes the freshness bound for `maxSkewMs` either side of
616
+ * its signed_at, so its id is kept until signed_at + maxSkewMs. In-memory and
617
+ * per process: a server on several instances keeps the same record in a
618
+ * shared store. Two identical calls signed in the same second share an id.
619
+ */
620
+ export function createReplayGuard(maxSkewMs: number = DEFAULT_MAX_SKEW_MS): {
621
+ firstUse(action: Pick<ActionEnvelope, 'id' | 'signed_at'>, now?: Date): boolean;
622
+ } {
623
+ const keepUntil = new Map<string, number>();
624
+ return {
625
+ firstUse(action, now = new Date()) {
626
+ const t = now.getTime();
627
+ for (const [id, until] of keepUntil) if (until <= t) keepUntil.delete(id);
628
+ if (keepUntil.has(action.id)) return false;
629
+ keepUntil.set(action.id, new Date(action.signed_at).getTime() + maxSkewMs);
630
+ return true;
631
+ },
533
632
  };
534
633
  }
535
634
 
@@ -672,12 +771,26 @@ function checkDelegationShape(env: DelegationEnvelope): VerifyDelegationResult |
672
771
  }
673
772
  if (!isIsoUtc(env.issued_at)) return err('E_MALFORMED', 'issued_at must be ISO 8601 UTC');
674
773
  if (!isIsoUtc(env.expires_at)) return err('E_MALFORMED', 'expires_at must be ISO 8601 UTC');
774
+ const windowError = delegationWindowError(env.issued_at, env.expires_at);
775
+ if (windowError) return err('E_MALFORMED', windowError);
675
776
  if (!/^[0-9a-f]{32}$/.test(env.nonce)) return err('E_MALFORMED', 'nonce must be 32 lowercase hex chars');
676
777
  if (env.sig?.alg !== 'bip322' || typeof env.sig.value !== 'string') return err('E_MALFORMED', 'sig invalid');
677
778
  if (env.sig.pubkey !== env.principal.address) return err('E_MALFORMED', 'sig.pubkey must equal principal.address');
678
779
  return null;
679
780
  }
680
781
 
782
+ const MAX_DELEGATION_MS = 365 * 86_400_000;
783
+
784
+ /** SPEC §4.4 window, checked at §8.1 step 2 whatever the clock says: issued < expires, at most 365 days. */
785
+ export function delegationWindowError(issuedAt: string, expiresAt: string): string | null {
786
+ const issued = Date.parse(issuedAt);
787
+ const expires = Date.parse(expiresAt);
788
+ if (Number.isNaN(issued) || Number.isNaN(expires)) return 'issued_at / expires_at must be real UTC times';
789
+ if (expires <= issued) return 'expires_at must be after issued_at';
790
+ if (expires - issued > MAX_DELEGATION_MS) return 'expires_at is more than 365 days after issued_at (SPEC §4.4)';
791
+ return null;
792
+ }
793
+
681
794
  function checkActionShape(a: ActionEnvelope): VerifyActionResult | null {
682
795
  if (a.kind !== 'agent-action') return err('E_MALFORMED', 'kind must be "agent-action"');
683
796
  if (!isHex64(a.id)) return err('E_MALFORMED', 'id must be 64 lowercase hex chars');