@orangecheck/agent-core 2.2.0 → 2.4.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/src/federation.ts CHANGED
@@ -25,7 +25,13 @@ import {
25
25
  hexEncode,
26
26
  revocationCanonicalMessage,
27
27
  } from './canonical.js';
28
- import type { ActorRef, AgentErrorCode, DelegationBond, DelegationRevocationRef } from './types.js';
28
+ import type {
29
+ ActorRef,
30
+ AgentErrorCode,
31
+ DelegationBond,
32
+ DelegationRevocationRef,
33
+ RevocationEnvelope,
34
+ } from './types.js';
29
35
 
30
36
  // ─────────────────────────────────────────────────────────────────────────────
31
37
  // Types (FEDERATION.md §2 / §3.2)
@@ -221,6 +227,13 @@ async function checkFederationQuorum(
221
227
 
222
228
  export interface VerifyFederationDelegationInput extends VerifyFederationBase {
223
229
  envelope: FederationDelegationEnvelope;
230
+ /**
231
+ * Revocations to check (kind-30085 by `#delegation`): federation revocations,
232
+ * and agent self-revocations when `revocation.holders` includes "agent".
233
+ * Required unless `skipRevocationCheck` states that revocation is not checked.
234
+ */
235
+ revocations?: Array<FederationRevocationEnvelope | RevocationEnvelope>;
236
+ skipRevocationCheck?: boolean;
224
237
  now?: Date;
225
238
  skipTemporalCheck?: boolean;
226
239
  }
@@ -273,6 +286,21 @@ export async function verifyFederationDelegation(
273
286
  const quorum = await checkFederationQuorum(env.principal, env.sig, env.id, input);
274
287
  if (!quorum.ok) return quorum;
275
288
 
289
+ // Revocation — the same rule as verifyDelegation: check unless told not to.
290
+ if (!input.skipRevocationCheck && !input.revocations) {
291
+ return fail(
292
+ 'E_MALFORMED',
293
+ 'verifyFederationDelegation requires `revocations` (fetch kind-30085 by #delegation) ' +
294
+ 'or an explicit skipRevocationCheck:true to state that revocation is not being checked'
295
+ );
296
+ }
297
+ for (const rev of input.revocations ?? []) {
298
+ if (rev?.delegation_id !== env.id) continue;
299
+ const rr = await verifyRevocationOf(env, rev, input);
300
+ if (!rr.ok) continue; // a malformed or unauthorized revocation revokes nothing
301
+ return fail('E_REVOKED', `delegation ${env.id} was revoked by ${rev.id}`);
302
+ }
303
+
276
304
  if (!input.skipTemporalCheck) {
277
305
  const now = input.now ?? new Date();
278
306
  const issued = new Date(env.issued_at);
@@ -285,6 +313,52 @@ export async function verifyFederationDelegation(
285
313
  return { ok: true, id: env.id, canonicalMessage: delegationCanonicalMessage(canonInput) };
286
314
  }
287
315
 
316
+ /**
317
+ * FEDERATION.md §4: the federation (M-of-N) may always revoke; the agent may
318
+ * self-revoke with its own BIP-322 signature when `revocation.holders`
319
+ * includes "agent".
320
+ */
321
+ async function verifyRevocationOf(
322
+ d: FederationDelegationEnvelope,
323
+ rev: FederationRevocationEnvelope | RevocationEnvelope,
324
+ input: VerifyFederationBase
325
+ ): Promise<FederationVerifyResult> {
326
+ if ((rev.signer as FederationPrincipal | undefined)?.alg === 'federation') {
327
+ return verifyFederationRevocation({
328
+ envelope: rev as FederationRevocationEnvelope,
329
+ delegation: d,
330
+ verifyBip322: input.verifyBip322,
331
+ skipSignatureVerification: input.skipSignatureVerification,
332
+ });
333
+ }
334
+ const v1 = rev as RevocationEnvelope;
335
+ const holders = d.revocation?.holders ?? ['principal'];
336
+ if (!Array.isArray(holders) || !holders.includes('agent')) {
337
+ return fail('E_REVOKER_UNAUTHORIZED', 'only the federation may revoke this delegation');
338
+ }
339
+ if (v1.kind !== 'agent-revocation' || v1.signer?.address !== d.agent.address) {
340
+ return fail('E_REVOKER_UNAUTHORIZED', 'a single-address revocation must be signed by the agent');
341
+ }
342
+ if (typeof v1.reason !== 'string' || !ISO_UTC.test(v1.signed_at ?? '')) {
343
+ return fail('E_MALFORMED', 'revocation reason / signed_at invalid');
344
+ }
345
+ const canonInput = {
346
+ address: v1.signer.address,
347
+ delegation_id: v1.delegation_id,
348
+ reason: v1.reason,
349
+ signed_at: v1.signed_at,
350
+ };
351
+ const id = computeRevocationId(canonInput);
352
+ if (id !== v1.id) return fail('E_BAD_ID', `reconstructed id (${id}) != revocation.id (${v1.id})`);
353
+ if (!input.skipSignatureVerification) {
354
+ if (!input.verifyBip322) return fail('E_BAD_SIG', 'no BIP-322 verifier supplied');
355
+ if (typeof v1.sig?.value !== 'string' || !(await input.verifyBip322(v1.id, v1.sig.value, v1.signer.address))) {
356
+ return fail('E_BAD_SIG', 'revocation BIP-322 signature did not verify');
357
+ }
358
+ }
359
+ return { ok: true, id: v1.id, canonicalMessage: revocationCanonicalMessage(canonInput) };
360
+ }
361
+
288
362
  // ─────────────────────────────────────────────────────────────────────────────
289
363
  // Revocation under a federation principal (FEDERATION.md §4)
290
364
  // ─────────────────────────────────────────────────────────────────────────────
@@ -176,13 +176,37 @@ describe('revocation priority uses only verified anchors', () => {
176
176
  verifyOtsAnchor: async () => false,
177
177
  });
178
178
  expect(clean.ok && clean.anchor).toEqual({
179
- status: 'confirmed',
179
+ status: 'claimed',
180
180
  blockHeight: 1,
181
181
  blockHash: 'ab'.repeat(32),
182
182
  verified: false,
183
183
  });
184
184
  });
185
185
 
186
+ // The ots block is outside the signature: its block is a claim until walked.
187
+ it('reports an unwalked anchor as claimed, and a walked one as confirmed', async () => {
188
+ const unwalked = await verifyAction({
189
+ action: action(d, later, confirmed(1)),
190
+ delegation: d,
191
+ revocations: [],
192
+ skipSignatureVerification: true,
193
+ });
194
+ expect(unwalked.ok && unwalked.anchor.status).toBe('claimed');
195
+ const walked = await verifyAction({
196
+ action: action(d, later, confirmed(1)),
197
+ delegation: d,
198
+ revocations: [],
199
+ skipSignatureVerification: true,
200
+ verifyOtsAnchor: async () => true,
201
+ });
202
+ expect(walked.ok && walked.anchor).toEqual({
203
+ status: 'confirmed',
204
+ blockHeight: 1,
205
+ blockHash: 'ab'.repeat(32),
206
+ verified: true,
207
+ });
208
+ });
209
+
186
210
  it('the anchor verifier is asked about the envelope it belongs to', async () => {
187
211
  const a = action(d, later, confirmed(100));
188
212
  const revAnchored = revocation(d, PRINCIPAL, '2026-04-23T00:00:00Z', confirmed(200));
package/src/scope.test.ts CHANGED
@@ -204,3 +204,28 @@ describe('ordered-op values must be decimal integers (SPEC §7.4)', () => {
204
204
  expect(isSubScope(exercised, parseScope('ln:send(max_sats<=0)'))).toBe(false);
205
205
  });
206
206
  });
207
+
208
+ // oc-pledge-protocol SPEC §7.3 defines pledge:create and its constraint keys.
209
+ describe('pledge:create is a registered scope', () => {
210
+ for (const s of [
211
+ 'pledge:create',
212
+ 'pledge:create(max_bond_sats=2000000)',
213
+ 'pledge:create(mechanism=vote_resolves)',
214
+ 'pledge:create(counterparty=bc1qalice000000000000000000000000000000000)',
215
+ 'pledge:create(max_bond_sats=2000000,mechanism=vote_resolves,counterparty=bc1qalice000000000000000000000000000000000)',
216
+ ]) {
217
+ it(`strict mode accepts ${s}`, () => {
218
+ expect(() => validateScope(parseScope(s), { mode: 'strict' })).not.toThrow();
219
+ });
220
+ }
221
+
222
+ it('strict mode still rejects an unregistered pledge:create key', () => {
223
+ expect(() => validateScope(parseScope('pledge:create(foo=1)'), { mode: 'strict' })).toThrow(ScopeParseError);
224
+ });
225
+
226
+ it('max_bond_sats is an ordered numeric bound', () => {
227
+ const granted = parseScope('pledge:create(max_bond_sats<=2000000)');
228
+ expect(isSubScope(parseScope('pledge:create(max_bond_sats=500000)'), granted)).toBe(true);
229
+ expect(isSubScope(parseScope('pledge:create(max_bond_sats=3000000)'), granted)).toBe(false);
230
+ });
231
+ });
package/src/scope.ts CHANGED
@@ -34,6 +34,8 @@ export const REGISTERED_SCOPES: Record<string, { keys: string[] }> = {
34
34
  'http:request': { keys: ['origin', 'method', 'max_rps', 'max_bytes_out'] },
35
35
  'ln:send': { keys: ['max_sats', 'node', 'max_fee_sats'] },
36
36
  'mcp:invoke': { keys: ['server', 'tool', 'max_invocations'] },
37
+ // oc-pledge-protocol SPEC §7.3.
38
+ 'pledge:create': { keys: ['max_bond_sats', 'mechanism', 'counterparty'] },
37
39
  };
38
40
 
39
41
  /** Keys whose values are compared numerically for sub-scope ordering. */
@@ -46,6 +48,7 @@ const NUMERIC_KEYS = new Set<string>([
46
48
  'max_sats',
47
49
  'max_fee_sats',
48
50
  'max_invocations',
51
+ 'max_bond_sats',
49
52
  'kind',
50
53
  ]);
51
54
 
package/src/types.ts CHANGED
@@ -289,10 +289,15 @@ export interface VerifyActionOkExtra {
289
289
  */
290
290
  chain: SubdelegationEnvelope[];
291
291
  scopeExercised: string;
292
+ /**
293
+ * `claimed` is a confirmed `ots` block no verifier walked: the block is the
294
+ * envelope's say-so. Only a passing `verifyOtsAnchor` yields `confirmed`.
295
+ */
292
296
  anchor:
293
297
  | { status: 'none' }
294
298
  | { status: 'pending' }
295
- | { status: 'confirmed'; blockHeight: number; blockHash: string; verified: boolean };
299
+ | { status: 'claimed'; blockHeight: number; blockHash: string; verified: false }
300
+ | { status: 'confirmed'; blockHeight: number; blockHash: string; verified: true };
296
301
  }
297
302
 
298
303
  export type VerifyActionResult =
package/src/verify.ts CHANGED
@@ -512,8 +512,11 @@ export async function verifyAction(input: VerifyActionInput): Promise<VerifyActi
512
512
  if (h === null || hash === null) {
513
513
  return err('E_MALFORMED', 'confirmed OTS proof missing block_height or block_hash');
514
514
  }
515
- const verified = actionAnchor !== null;
516
- anchor = { status: 'confirmed', blockHeight: h, blockHash: hash, verified } as typeof anchor;
515
+ anchor = (
516
+ actionAnchor !== null
517
+ ? { status: 'confirmed', blockHeight: h, blockHash: hash, verified: true }
518
+ : { status: 'claimed', blockHeight: h, blockHash: hash, verified: false }
519
+ ) as typeof anchor;
517
520
  }
518
521
 
519
522
  return {