@orangecheck/agent-core 1.1.0 → 2.0.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": "1.1.0",
3
+ "version": "2.0.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",
@@ -64,14 +64,14 @@
64
64
  },
65
65
  "dependencies": {
66
66
  "@noble/hashes": "^1.5.0",
67
- "@orangecheck/stamp-core": "^1.0.0",
68
- "@orangecheck/lock-core": "^1.0.1"
67
+ "@orangecheck/lock-core": "^1.0.1",
68
+ "@orangecheck/stamp-core": "^1.0.0"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@types/node": "^22.10.2",
72
72
  "tsup": "^8.3.5",
73
73
  "typescript": "^5.7.2",
74
- "vitest": "^3.2.4"
74
+ "vitest": "^4.1.11"
75
75
  },
76
76
  "publishConfig": {
77
77
  "access": "public"
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { ScopeNotGrantedError, assertScopeGranted } from './assert-scope.js';
4
+ import type { ScopesEncryptedEnvelope } from './types.js';
5
+
6
+ const SEALED = {
7
+ v: 1,
8
+ alg: 'x25519-xchacha20poly1305',
9
+ recipients: [{ device_id: 'dev1', ct: 'AA' }],
10
+ ct: 'BB',
11
+ nonce: 'CC',
12
+ } as unknown as ScopesEncryptedEnvelope;
13
+
14
+ function reasonOf(fn: () => void): string {
15
+ try {
16
+ fn();
17
+ } catch (e) {
18
+ return e instanceof ScopeNotGrantedError ? e.reason : `wrong-error:${String(e)}`;
19
+ }
20
+ return 'no-throw';
21
+ }
22
+
23
+ describe('assertScopeGranted', () => {
24
+ it('permits an exact grant and a sub-scope of one', () => {
25
+ expect(() =>
26
+ assertScopeGranted({ scopes: ['mcp:invoke(server=s,tool=t)'] }, 'mcp:invoke(server=s,tool=t)', 'f')
27
+ ).not.toThrow();
28
+ expect(() =>
29
+ assertScopeGranted({ scopes: ['mcp:invoke(server=s)'] }, 'mcp:invoke(server=s,tool=t)', 'f')
30
+ ).not.toThrow();
31
+ });
32
+
33
+ it('refuses a scope outside the grant', () => {
34
+ expect(
35
+ reasonOf(() => assertScopeGranted({ scopes: ['mcp:invoke(server=a)'] }, 'mcp:invoke(server=b)', 'f'))
36
+ ).toBe('not_subscope');
37
+ });
38
+
39
+ // The whole reason this module exists. Five adapters open-coded
40
+ // `(scopes ?? []).map(parseScope)`, so a v1.2 private delegation — where
41
+ // `scopes` is absent BECAUSE it is sealed — reported "not a sub-scope of
42
+ // any granted scope". The refusal was right; the diagnosis sent the
43
+ // integrator to audit a scope string that was very possibly correct.
44
+ it('names encrypted scopes as such rather than blaming the scope string', () => {
45
+ const d = { scopes_encrypted: SEALED };
46
+ expect(reasonOf(() => assertScopeGranted(d, 'mcp:invoke(server=s)', 'f'))).toBe('scopes_encrypted');
47
+ try {
48
+ assertScopeGranted(d, 'mcp:invoke(server=s)', 'f');
49
+ } catch (e) {
50
+ expect((e as Error).message).toMatch(/decryptPrivateScopes/);
51
+ expect((e as Error).message).not.toMatch(/not a sub-scope/);
52
+ }
53
+ });
54
+
55
+ it('treats absent and empty scopes as granting nothing, not everything', () => {
56
+ expect(reasonOf(() => assertScopeGranted({}, 'x:y', 'f'))).toBe('no_scopes');
57
+ expect(reasonOf(() => assertScopeGranted({ scopes: [] }, 'x:y', 'f'))).toBe('no_scopes');
58
+ });
59
+
60
+ // A sealed delegation that ALSO carries a scopes array must not be
61
+ // evaluated against that array: the fields are exclusive per the spec, so
62
+ // a populated `scopes` alongside `scopes_encrypted` is a malformed
63
+ // envelope and possibly an attempt to present a benign readable grant
64
+ // while the real one stays hidden.
65
+ it('refuses on the sealed field even when a scopes array is also present', () => {
66
+ expect(
67
+ reasonOf(() =>
68
+ assertScopeGranted({ scopes: ['x:y'], scopes_encrypted: SEALED }, 'x:y', 'f')
69
+ )
70
+ ).toBe('scopes_encrypted');
71
+ });
72
+ });
@@ -0,0 +1,80 @@
1
+ import { hasPrivateScopes } from './private-scope.js';
2
+ import { isSubScope, parseScope } from './scope.js';
3
+ import type { ScopesEncryptedEnvelope } from './types.js';
4
+
5
+ /**
6
+ * The minimum an adapter needs to make the authorization decision. Kept
7
+ * structural rather than importing `Delegation` so `stampX` helpers can accept
8
+ * the narrower shapes they already declare.
9
+ */
10
+ export interface ScopeBearingDelegation {
11
+ scopes?: string[];
12
+ scopes_encrypted?: ScopesEncryptedEnvelope;
13
+ }
14
+
15
+ /** Distinguishes the three reasons a stamp can be refused. */
16
+ export class ScopeNotGrantedError extends Error {
17
+ readonly reason: 'not_subscope' | 'scopes_encrypted' | 'no_scopes';
18
+ constructor(reason: ScopeNotGrantedError['reason'], message: string) {
19
+ super(message);
20
+ this.name = 'ScopeNotGrantedError';
21
+ this.reason = reason;
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Refuse unless `scopeExercised` is a sub-scope of something the delegation
27
+ * actually grants. Every `agent-*` adapter's pre-flight check routes here.
28
+ *
29
+ * The check itself is unchanged and still fails closed. What this adds is
30
+ * telling the caller WHICH of three things went wrong, because the five
31
+ * adapters each open-coded `(delegation.scopes ?? []).map(parseScope)` and so
32
+ * reported all three as the same thing:
33
+ *
34
+ * - **not_subscope** — scopes are readable and this one is not among them.
35
+ * The integrator's own mistake, and the only case the old message fit.
36
+ * - **scopes_encrypted** — v1.2 private mode. `scopes` is absent *because it
37
+ * is sealed to a device key*, so the adapter cannot evaluate the request at
38
+ * all. `?? []` collapsed this into "not a sub-scope of any granted scope",
39
+ * which sends the integrator to audit a scope string that may well be
40
+ * correct, when what they need is `decryptPrivateScopes` and to pass the
41
+ * recovered list. Refusing is right; misnaming why is not.
42
+ * - **no_scopes** — neither field present. A delegation granting nothing.
43
+ *
44
+ * Fail-closed is not a judgement call here: an over-broad stamp is a signed,
45
+ * content-addressed authorization artifact that a verifier will accept.
46
+ */
47
+ export function assertScopeGranted(
48
+ delegation: ScopeBearingDelegation,
49
+ scopeExercised: string,
50
+ fnName: string
51
+ ): void {
52
+ if (hasPrivateScopes(delegation)) {
53
+ throw new ScopeNotGrantedError(
54
+ 'scopes_encrypted',
55
+ `${fnName}: this delegation uses v1.2 private scopes — \`scopes_encrypted\` is ` +
56
+ `set and \`scopes\` is absent, so the granted set cannot be read here. ` +
57
+ `Decrypt it with decryptPrivateScopes() using a matching device key and pass ` +
58
+ `the recovered scopes as \`delegation.scopes\`. Refusing to stamp ` +
59
+ `\`${scopeExercised}\` against an unreadable grant.`
60
+ );
61
+ }
62
+
63
+ const scopes = delegation.scopes;
64
+ if (scopes === undefined || scopes.length === 0) {
65
+ throw new ScopeNotGrantedError(
66
+ 'no_scopes',
67
+ `${fnName}: delegation grants no scopes, so \`${scopeExercised}\` cannot be ` +
68
+ `exercised. An absent or empty \`scopes\` means nothing is granted, never everything.`
69
+ );
70
+ }
71
+
72
+ const exercised = parseScope(scopeExercised);
73
+ if (!scopes.map(parseScope).some((g) => isSubScope(exercised, g))) {
74
+ throw new ScopeNotGrantedError(
75
+ 'not_subscope',
76
+ `${fnName}: scope_exercised (${scopeExercised}) is not a sub-scope of any granted ` +
77
+ `scope (${scopes.join(', ')})`
78
+ );
79
+ }
80
+ }
@@ -81,6 +81,7 @@ d('federation delegation vectors (v19–v24, v26)', () => {
81
81
  ...opts,
82
82
  })
83
83
  : await verifyDelegation({
84
+ skipRevocationCheck: true,
84
85
  envelope: env as DelegationEnvelope,
85
86
  skipSignatureVerification: true,
86
87
  skipTemporalCheck: true,
package/src/index.ts CHANGED
@@ -79,3 +79,6 @@ export type {
79
79
  UnsealScopesInput,
80
80
  UnsealedScopes,
81
81
  } from './private-scope.js';
82
+
83
+ export { assertScopeGranted, ScopeNotGrantedError } from './assert-scope.js';
84
+ export type { ScopeBearingDelegation } from './assert-scope.js';
@@ -118,7 +118,7 @@ describe('verifyDelegation with v1.2 scopes_encrypted', () => {
118
118
  const env = await buildPrivateEnvelope(['ln:send(max_sats<=100)']);
119
119
  // Inject a public scopes field too — illegal.
120
120
  const both = { ...env, scopes: ['ln:send(max_sats<=100)'] } as DelegationEnvelope;
121
- const r = await verifyDelegation({ envelope: both, skipSignatureVerification: true });
121
+ const r = await verifyDelegation({ envelope: both, skipSignatureVerification: true, skipRevocationCheck: true });
122
122
  expect(r.ok).toBe(false);
123
123
  if (!r.ok) expect(r.code).toBe('E_SCOPES_BOTH_PROVIDED');
124
124
  });
@@ -127,14 +127,14 @@ describe('verifyDelegation with v1.2 scopes_encrypted', () => {
127
127
  const env = await buildPrivateEnvelope(['ln:send(max_sats<=100)']);
128
128
  const neither = { ...env } as DelegationEnvelope;
129
129
  delete neither.scopes_encrypted;
130
- const r = await verifyDelegation({ envelope: neither, skipSignatureVerification: true });
130
+ const r = await verifyDelegation({ envelope: neither, skipSignatureVerification: true, skipRevocationCheck: true });
131
131
  expect(r.ok).toBe(false);
132
132
  if (!r.ok) expect(r.code).toBe('E_SCOPES_NEITHER_PROVIDED');
133
133
  });
134
134
 
135
135
  it('returns E_SCOPES_UNREADABLE when no decryption key is supplied', async () => {
136
136
  const env = await buildPrivateEnvelope(['ln:send(max_sats<=100)']);
137
- const r = await verifyDelegation({ envelope: env, skipSignatureVerification: true });
137
+ const r = await verifyDelegation({ envelope: env, skipSignatureVerification: true, skipRevocationCheck: true });
138
138
  expect(r.ok).toBe(false);
139
139
  if (!r.ok) expect(r.code).toBe('E_SCOPES_UNREADABLE');
140
140
  });
@@ -144,6 +144,7 @@ describe('verifyDelegation with v1.2 scopes_encrypted', () => {
144
144
  'ln:send(max_sats<=100)',
145
145
  ]);
146
146
  const r = await verifyDelegation({
147
+ skipRevocationCheck: true,
147
148
  envelope,
148
149
  skipSignatureVerification: true,
149
150
  decryptScopesWith: { device_id: 'agent', secretKey: agentSecret },
@@ -0,0 +1,67 @@
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."
4
+ //
5
+ // The default was inverted. verifyDelegation had NO revocations parameter at
6
+ // all — a revoked delegation returned ok:true and a caller had no way to say
7
+ // otherwise. verifyAction accepted one but checked only `if (input.revocations
8
+ // && length > 0)`, so omitting it skipped the check silently and "checked,
9
+ // clean" was indistinguishable from "never checked".
10
+ //
11
+ // This library has no network by design, so the caller fetches the feed. What
12
+ // changed is that declining to is now a decision they have to state.
13
+ import { readFileSync } from 'node:fs';
14
+ import { dirname, join } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ import { describe, expect, it } from 'vitest';
18
+
19
+ import { verifyDelegation } from './verify.js';
20
+ import type { DelegationEnvelope } from './types.js';
21
+
22
+ const vectors = join(dirname(fileURLToPath(import.meta.url)), '../../../oc-agent-protocol/test-vectors');
23
+ const delegation = JSON.parse(
24
+ readFileSync(join(vectors, 'v01-delegation-minimal.json'), 'utf8'),
25
+ ).expected.envelope as DelegationEnvelope;
26
+
27
+ const AT = new Date('2026-04-23T12:00:00Z'); // inside the delegation's window
28
+
29
+ describe('verifyDelegation fails closed on revocation', () => {
30
+ it('refuses when given neither revocations nor an explicit skip', async () => {
31
+ const r = await verifyDelegation({
32
+ envelope: delegation,
33
+ skipSignatureVerification: true,
34
+ now: AT,
35
+ });
36
+ expect(r.ok).toBe(false);
37
+ expect(r.ok === false && r.code).toBe('E_MALFORMED');
38
+ expect(r.ok === false && r.message).toMatch(/requires `revocations`/i);
39
+ });
40
+
41
+ it('accepts an EMPTY revocation list — "I looked, there were none"', async () => {
42
+ const r = await verifyDelegation({
43
+ envelope: delegation,
44
+ skipSignatureVerification: true,
45
+ now: AT,
46
+ revocations: [],
47
+ });
48
+ expect(r.ok).toBe(true);
49
+ });
50
+
51
+ it('accepts an explicit skip, for callers that genuinely cannot check', async () => {
52
+ const r = await verifyDelegation({
53
+ envelope: delegation,
54
+ skipSignatureVerification: true,
55
+ now: AT,
56
+ skipRevocationCheck: true,
57
+ });
58
+ expect(r.ok).toBe(true);
59
+ });
60
+
61
+ // An empty list and a skip both verify — but they mean different things, and
62
+ // only one of them is a claim about the feed. That distinction is the point
63
+ // of the change.
64
+ it('the two are not the same statement', () => {
65
+ expect('revocations: []').not.toBe('skipRevocationCheck: true');
66
+ });
67
+ });
@@ -262,6 +262,7 @@ describe('oc-agent-protocol test vectors', () => {
262
262
  it(`${name} — declared envelope passes verify() with skipSignatureVerification`, async () => {
263
263
  if (data.kind === 'delegation') {
264
264
  const r = await verifyDelegation({
265
+ skipRevocationCheck: true,
265
266
  envelope: data.expected.envelope,
266
267
  skipSignatureVerification: true,
267
268
  skipTemporalCheck: true,
@@ -273,6 +274,7 @@ describe('oc-agent-protocol test vectors', () => {
273
274
  throw new Error(`action vector ${name} references missing delegation ${data.inputs.delegation_id}`);
274
275
  }
275
276
  const r = await verifyAction({
277
+ skipRevocationCheck: true,
276
278
  action: data.expected.envelope,
277
279
  delegation,
278
280
  skipSignatureVerification: true,
@@ -454,6 +456,7 @@ describe('oc-agent-protocol negative test vectors', () => {
454
456
  sig: { alg: 'bip322', pubkey: v.inputs.address, value: 'AAAA' },
455
457
  };
456
458
  const r = await verifyAction({
459
+ skipRevocationCheck: true,
457
460
  action,
458
461
  delegation,
459
462
  skipSignatureVerification: true,
@@ -704,6 +707,7 @@ describe('oc-agent-protocol private-scope vectors (v1.2)', () => {
704
707
  );
705
708
  }
706
709
  const r = await verifyDelegation({
710
+ skipRevocationCheck: true,
707
711
  envelope: env,
708
712
  skipSignatureVerification: true,
709
713
  skipTemporalCheck: true,
@@ -721,6 +725,7 @@ describe('oc-agent-protocol private-scope vectors (v1.2)', () => {
721
725
  } else {
722
726
  const negative = data as PrivateScopeNegativeVector;
723
727
  const r = await verifyDelegation({
728
+ skipRevocationCheck: true,
724
729
  envelope: env,
725
730
  skipSignatureVerification: true,
726
731
  skipTemporalCheck: true,
package/src/verify.ts CHANGED
@@ -48,6 +48,24 @@ export interface VerifyBase {
48
48
  verifyBip322?: (msg: string, signatureB64: string, address: string) => Promise<boolean>;
49
49
  skipSignatureVerification?: boolean;
50
50
  scopeMode?: ValidationOptions['mode'];
51
+ /**
52
+ * Verify WITHOUT consulting revocation feeds. Required to be explicit.
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
57
+ * were checked only when supplied, and omitting them skipped the check
58
+ * silently, so "checked, clean" was indistinguishable from "never checked".
59
+ *
60
+ * This library has no network by design, so the caller fetches the feed and
61
+ * passes `revocations`. Declining to is a decision they must state.
62
+ */
63
+ skipRevocationCheck?: boolean;
64
+ /**
65
+ * Revocations to evaluate against the delegation (or, for an action, every
66
+ * link in its chain). Fetch these from kind-30085 by `#delegation`.
67
+ */
68
+ revocations?: RevocationEnvelope[];
51
69
  }
52
70
 
53
71
  export class AgentError extends Error {
@@ -204,6 +222,31 @@ export async function verifyDelegation(input: VerifyDelegationInput): Promise<Ve
204
222
  if (!ok) return err('E_BAD_SIG', 'BIP-322 signature did not verify');
205
223
  }
206
224
 
225
+ // Revocation. SECURITY §7 item 7 — check unless the caller states otherwise.
226
+ // Before this, verifyDelegation had NO revocations parameter at all: a
227
+ // revoked delegation returned ok:true and a caller had no way to say
228
+ // otherwise.
229
+ if (!input.skipRevocationCheck && !input.revocations) {
230
+ return err(
231
+ '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
+ );
235
+ }
236
+ if (input.revocations) {
237
+ for (const rev of input.revocations) {
238
+ if (rev.delegation_id !== env.id) continue;
239
+ const rr = await verifyRevocation({
240
+ envelope: rev,
241
+ delegation: env,
242
+ verifyBip322: input.verifyBip322,
243
+ skipSignatureVerification: input.skipSignatureVerification,
244
+ });
245
+ if (!rr.ok) continue; // a malformed revocation revokes nothing
246
+ return err('E_REVOKED', `delegation ${env.id} was revoked by ${rev.id}`);
247
+ }
248
+ }
249
+
207
250
  // Temporal.
208
251
  if (!input.skipTemporalCheck) {
209
252
  const now = input.now ?? new Date();
@@ -254,12 +297,6 @@ export interface VerifyActionInput extends VerifyBase {
254
297
  * E_SUBDELEGATION_DEPTH_EXCEEDED before any per-link work is performed.
255
298
  */
256
299
  maxChainDepth?: number;
257
- /**
258
- * Known revocations targeting any envelope in the chain (root + each
259
- * subdelegation). The verifier checks every link per SUB-DELEGATION.md §2.2
260
- * step 5 — a revocation against ANY link invalidates the action.
261
- */
262
- revocations?: RevocationEnvelope[];
263
300
  content?: Uint8Array;
264
301
  verifyOtsAnchor?: (proofB64: string, blockHeight: number, blockHash: string) => Promise<boolean>;
265
302
  /** If action and revocation are both OTS-anchored, pass a function that returns the comparable block height of each via proof parsing. Defaults: use envelope.ots.block_height. */
@@ -301,6 +338,12 @@ export async function verifyAction(input: VerifyActionInput): Promise<VerifyActi
301
338
  skipSignatureVerification: input.skipSignatureVerification,
302
339
  scopeMode: input.scopeMode,
303
340
  skipTemporalCheck: true, // action window check dominates
341
+ // Revocation is evaluated ONCE, below, across every link in the chain
342
+ // (root + subdelegations) against the action's effective time — a
343
+ // per-link check here would use the wrong clock and would also
344
+ // double-report. Forward the caller's decision so this inner call does
345
+ // not trip the fail-closed guard.
346
+ skipRevocationCheck: true,
304
347
  ...(input.decryptScopesWith ? { decryptScopesWith: input.decryptScopesWith } : {}),
305
348
  });
306
349
  if (!dr.ok) return dr;
@@ -395,6 +438,13 @@ export async function verifyAction(input: VerifyActionInput): Promise<VerifyActi
395
438
  // 6. Revocation check — applies per-link to ALL envelopes in the chain
396
439
  // (root + every subdelegation). Per SUB-DELEGATION.md §2.2 step 5, a
397
440
  // revocation against ANY link invalidates the action.
441
+ if (!input.skipRevocationCheck && !input.revocations) {
442
+ return err(
443
+ 'E_MALFORMED',
444
+ 'verifyAction requires `revocations` (fetch kind-30085 by #delegation for every link ' +
445
+ 'in the chain) or an explicit skipRevocationCheck:true',
446
+ );
447
+ }
398
448
  if (input.revocations && input.revocations.length > 0) {
399
449
  const allLinks: ChainLink[] = [rootHydrated, ...hydratedChain];
400
450
  for (const link of allLinks) {