@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.
- package/README.md +4 -2
- package/dist/index.d.mts +9 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.js +60 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +58 -4
- package/dist/index.mjs.map +1 -1
- package/dist/types.d.mts +9 -1
- package/dist/types.d.ts +9 -1
- package/dist/types.js.map +1 -1
- package/dist/types.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/index.ts +4 -0
- package/src/live-time.test.ts +151 -0
- package/src/private-scope.test.ts +4 -2
- package/src/revocation-authority.test.ts +15 -13
- package/src/revocation-failclosed.test.ts +1 -1
- package/src/scope.test.ts +20 -0
- package/src/test-vectors.test.ts +64 -8
- package/src/types.ts +9 -0
- package/src/verify.ts +114 -1
|
@@ -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
|
+
});
|
package/src/test-vectors.test.ts
CHANGED
|
@@ -18,7 +18,13 @@ import {
|
|
|
18
18
|
subdelegationCanonicalMessage,
|
|
19
19
|
} from './canonical.js';
|
|
20
20
|
import { parseScope, ScopeParseError } from './scope.js';
|
|
21
|
-
import {
|
|
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
|
-
|
|
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
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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');
|