@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/README.md +1 -1
- package/dist/index.js +13 -2
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +13 -2
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/delegation-window.test.ts +82 -0
- package/src/federation.test.ts +21 -1
- package/src/federation.ts +3 -1
- package/src/private-scope.test.ts +4 -2
- package/src/scope.test.ts +20 -0
- package/src/test-vectors.test.ts +21 -0
- package/src/verify.ts +14 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orangecheck/agent-core",
|
|
3
|
-
"version": "2.
|
|
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
|
+
});
|
package/src/federation.test.ts
CHANGED
|
@@ -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
|
-
|
|
193
|
-
const
|
|
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
|
+
});
|
package/src/test-vectors.test.ts
CHANGED
|
@@ -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');
|