@orangecheck/agent-core 0.1.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/LICENSE +21 -0
- package/README.md +127 -0
- package/dist/canonical.d.mts +27 -0
- package/dist/canonical.d.ts +27 -0
- package/dist/canonical.js +263 -0
- package/dist/canonical.js.map +1 -0
- package/dist/canonical.mjs +237 -0
- package/dist/canonical.mjs.map +1 -0
- package/dist/index.d.mts +38 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +671 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +633 -0
- package/dist/index.mjs.map +1 -0
- package/dist/scope.d.mts +28 -0
- package/dist/scope.d.ts +28 -0
- package/dist/scope.js +249 -0
- package/dist/scope.js.map +1 -0
- package/dist/scope.mjs +241 -0
- package/dist/scope.mjs.map +1 -0
- package/dist/types.d.mts +127 -0
- package/dist/types.d.ts +127 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/dist/types.mjs +6 -0
- package/dist/types.mjs.map +1 -0
- package/package.json +75 -0
- package/src/canonical.test.ts +95 -0
- package/src/canonical.ts +155 -0
- package/src/index.ts +45 -0
- package/src/scope.test.ts +162 -0
- package/src/scope.ts +330 -0
- package/src/test-vectors.test.ts +199 -0
- package/src/types.ts +189 -0
- package/src/verify.ts +460 -0
package/src/scope.ts
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// Scope grammar, canonicalization, and sub-scope relation. See SPEC.md §7.
|
|
2
|
+
//
|
|
3
|
+
// A scope is <product>:<verb>(<constraint-list>).
|
|
4
|
+
// Constraints are <key><op><value>, op ∈ { =, !=, <, <=, >, >=, * }.
|
|
5
|
+
// Canonical form: constraints sorted by key; no whitespace.
|
|
6
|
+
|
|
7
|
+
export type ScopeOp = '=' | '!=' | '<' | '<=' | '>' | '>=' | '*';
|
|
8
|
+
|
|
9
|
+
export interface ScopeConstraint {
|
|
10
|
+
key: string;
|
|
11
|
+
op: ScopeOp;
|
|
12
|
+
/** `undefined` for the wildcard `*` op; otherwise the raw textual value (unquoted). */
|
|
13
|
+
value: string | undefined;
|
|
14
|
+
/** True if the value was supplied as a quoted string; preserved for round-trip fidelity. */
|
|
15
|
+
quoted: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Scope {
|
|
19
|
+
product: string;
|
|
20
|
+
verb: string;
|
|
21
|
+
constraints: ScopeConstraint[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
25
|
+
// Registered products/verbs (SPEC §7.3) and constraint keys (SPEC §7.6).
|
|
26
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
export const REGISTERED_SCOPES: Record<string, { keys: string[] }> = {
|
|
29
|
+
'lock:seal': { keys: ['recipient', 'mime', 'max_bytes'] },
|
|
30
|
+
'lock:chat': { keys: ['recipient', 'max_bytes_per_msg', 'max_msgs'] },
|
|
31
|
+
'stamp:sign': { keys: ['mime', 'max_bytes', 'content_hash_prefix'] },
|
|
32
|
+
'vote:cast': { keys: ['poll_id', 'choice'] },
|
|
33
|
+
'nostr:publish': { keys: ['kind', 'relay', 'max_bytes'] },
|
|
34
|
+
'http:request': { keys: ['origin', 'method', 'max_rps', 'max_bytes_out'] },
|
|
35
|
+
'ln:send': { keys: ['max_sats', 'node', 'max_fee_sats'] },
|
|
36
|
+
'mcp:invoke': { keys: ['server', 'tool', 'max_invocations'] },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Keys whose values are compared numerically for sub-scope ordering. */
|
|
40
|
+
const NUMERIC_KEYS = new Set<string>([
|
|
41
|
+
'max_bytes',
|
|
42
|
+
'max_bytes_per_msg',
|
|
43
|
+
'max_msgs',
|
|
44
|
+
'max_bytes_out',
|
|
45
|
+
'max_rps',
|
|
46
|
+
'max_sats',
|
|
47
|
+
'max_fee_sats',
|
|
48
|
+
'max_invocations',
|
|
49
|
+
'kind',
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
const IDENT_RE = /^[a-z][a-z0-9_]*$/;
|
|
53
|
+
const BARE_TOKEN_RE = /^[A-Za-z0-9_.:/@+\-]+$/;
|
|
54
|
+
|
|
55
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
56
|
+
// Parse
|
|
57
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
export class ScopeParseError extends Error {
|
|
60
|
+
constructor(message: string) {
|
|
61
|
+
super(message);
|
|
62
|
+
this.name = 'ScopeParseError';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function parseScope(input: string): Scope {
|
|
67
|
+
if (typeof input !== 'string' || input.length === 0) {
|
|
68
|
+
throw new ScopeParseError('scope must be a non-empty string');
|
|
69
|
+
}
|
|
70
|
+
if (/\s/.test(input)) {
|
|
71
|
+
throw new ScopeParseError(`scope may not contain whitespace: ${JSON.stringify(input)}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const colonIdx = input.indexOf(':');
|
|
75
|
+
if (colonIdx < 0) throw new ScopeParseError('scope missing "product:verb" separator');
|
|
76
|
+
|
|
77
|
+
const product = input.slice(0, colonIdx);
|
|
78
|
+
if (!IDENT_RE.test(product)) throw new ScopeParseError(`invalid product: ${product}`);
|
|
79
|
+
|
|
80
|
+
const rest = input.slice(colonIdx + 1);
|
|
81
|
+
const parenIdx = rest.indexOf('(');
|
|
82
|
+
|
|
83
|
+
let verb: string;
|
|
84
|
+
let constraintText = '';
|
|
85
|
+
if (parenIdx < 0) {
|
|
86
|
+
verb = rest;
|
|
87
|
+
} else {
|
|
88
|
+
verb = rest.slice(0, parenIdx);
|
|
89
|
+
if (!rest.endsWith(')')) throw new ScopeParseError('scope constraint list must end with ")"');
|
|
90
|
+
constraintText = rest.slice(parenIdx + 1, -1);
|
|
91
|
+
}
|
|
92
|
+
if (!IDENT_RE.test(verb)) throw new ScopeParseError(`invalid verb: ${verb}`);
|
|
93
|
+
|
|
94
|
+
const constraints: ScopeConstraint[] = [];
|
|
95
|
+
if (constraintText.length > 0) {
|
|
96
|
+
for (const piece of splitTopLevelCommas(constraintText)) {
|
|
97
|
+
constraints.push(parseConstraint(piece));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// No duplicate keys.
|
|
102
|
+
const seen = new Set<string>();
|
|
103
|
+
for (const c of constraints) {
|
|
104
|
+
if (seen.has(c.key)) throw new ScopeParseError(`duplicate constraint key: ${c.key}`);
|
|
105
|
+
seen.add(c.key);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { product, verb, constraints };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function splitTopLevelCommas(text: string): string[] {
|
|
112
|
+
const out: string[] = [];
|
|
113
|
+
let depth = 0;
|
|
114
|
+
let inQuotes = false;
|
|
115
|
+
let start = 0;
|
|
116
|
+
for (let i = 0; i < text.length; i++) {
|
|
117
|
+
const ch = text[i];
|
|
118
|
+
if (inQuotes) {
|
|
119
|
+
if (ch === '\\' && i + 1 < text.length) {
|
|
120
|
+
i++;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (ch === '"') inQuotes = false;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (ch === '"') {
|
|
127
|
+
inQuotes = true;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (ch === '(') depth++;
|
|
131
|
+
else if (ch === ')') depth--;
|
|
132
|
+
else if (ch === ',' && depth === 0) {
|
|
133
|
+
out.push(text.slice(start, i));
|
|
134
|
+
start = i + 1;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
out.push(text.slice(start));
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function parseConstraint(piece: string): ScopeConstraint {
|
|
142
|
+
if (piece.length === 0) throw new ScopeParseError('empty constraint');
|
|
143
|
+
|
|
144
|
+
// The `*` op (wildcard) is an op with no value. Recognized by "key=*" form.
|
|
145
|
+
// SPEC uses "key=*"; we also accept "key*" as legacy alias.
|
|
146
|
+
// Ops in descending length so ">=" beats ">" and "!=" beats "!".
|
|
147
|
+
const OPS: ScopeOp[] = ['>=', '<=', '!=', '=', '>', '<'];
|
|
148
|
+
|
|
149
|
+
// Special-case wildcard: "key=*" or "key*".
|
|
150
|
+
const wildcardMatch = /^([a-z][a-z0-9_]*)(?:=\*|\*)$/.exec(piece);
|
|
151
|
+
if (wildcardMatch) {
|
|
152
|
+
return { key: wildcardMatch[1]!, op: '*', value: undefined, quoted: false };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (const op of OPS) {
|
|
156
|
+
const idx = piece.indexOf(op);
|
|
157
|
+
if (idx <= 0) continue; // key must come first and be non-empty
|
|
158
|
+
const key = piece.slice(0, idx);
|
|
159
|
+
if (!IDENT_RE.test(key)) continue;
|
|
160
|
+
const raw = piece.slice(idx + op.length);
|
|
161
|
+
const { value, quoted } = parseValue(raw);
|
|
162
|
+
return { key, op, value, quoted };
|
|
163
|
+
}
|
|
164
|
+
throw new ScopeParseError(`constraint missing operator: ${piece}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function parseValue(raw: string): { value: string; quoted: boolean } {
|
|
168
|
+
if (raw.length === 0) throw new ScopeParseError('constraint value is empty');
|
|
169
|
+
if (raw.startsWith('"')) {
|
|
170
|
+
if (!raw.endsWith('"') || raw.length < 2) {
|
|
171
|
+
throw new ScopeParseError(`unterminated quoted value: ${raw}`);
|
|
172
|
+
}
|
|
173
|
+
let v = '';
|
|
174
|
+
for (let i = 1; i < raw.length - 1; i++) {
|
|
175
|
+
const ch = raw[i]!;
|
|
176
|
+
if (ch === '\\' && i + 1 < raw.length - 1) {
|
|
177
|
+
const next = raw[++i]!;
|
|
178
|
+
v += next;
|
|
179
|
+
} else if (ch === '"') {
|
|
180
|
+
throw new ScopeParseError(`unescaped quote in value: ${raw}`);
|
|
181
|
+
} else {
|
|
182
|
+
v += ch;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return { value: v, quoted: true };
|
|
186
|
+
}
|
|
187
|
+
if (!BARE_TOKEN_RE.test(raw)) {
|
|
188
|
+
throw new ScopeParseError(`invalid bare-token value: ${JSON.stringify(raw)}`);
|
|
189
|
+
}
|
|
190
|
+
return { value: raw, quoted: false };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
194
|
+
// Canonicalize
|
|
195
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
export function canonicalizeScope(scope: Scope): string {
|
|
198
|
+
const sorted = [...scope.constraints].sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
199
|
+
const parts = sorted.map(serializeConstraint);
|
|
200
|
+
const inner = parts.join(',');
|
|
201
|
+
return `${scope.product}:${scope.verb}${parts.length === 0 ? '' : `(${inner})`}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function canonicalizeScopeString(input: string): string {
|
|
205
|
+
return canonicalizeScope(parseScope(input));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function serializeConstraint(c: ScopeConstraint): string {
|
|
209
|
+
if (c.op === '*') return `${c.key}=*`;
|
|
210
|
+
const v = c.quoted ? quoteValue(c.value ?? '') : c.value ?? '';
|
|
211
|
+
return `${c.key}${c.op}${v}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function quoteValue(v: string): string {
|
|
215
|
+
let out = '"';
|
|
216
|
+
for (const ch of v) {
|
|
217
|
+
if (ch === '"' || ch === '\\') out += '\\' + ch;
|
|
218
|
+
else out += ch;
|
|
219
|
+
}
|
|
220
|
+
out += '"';
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
225
|
+
// Registry-based validation
|
|
226
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
export interface ValidationOptions {
|
|
229
|
+
/**
|
|
230
|
+
* Strict: reject unknown products/verbs and unknown constraint keys.
|
|
231
|
+
* Permissive: accept unknown products/verbs; ignore unknown keys without treating them as wider.
|
|
232
|
+
* Default: 'strict'.
|
|
233
|
+
*/
|
|
234
|
+
mode?: 'strict' | 'permissive';
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function validateScope(scope: Scope, options: ValidationOptions = {}): void {
|
|
238
|
+
const mode = options.mode ?? 'strict';
|
|
239
|
+
const reg = REGISTERED_SCOPES[`${scope.product}:${scope.verb}`];
|
|
240
|
+
if (!reg) {
|
|
241
|
+
if (mode === 'strict') {
|
|
242
|
+
throw new ScopeParseError(`unregistered scope: ${scope.product}:${scope.verb}`);
|
|
243
|
+
}
|
|
244
|
+
return; // permissive: no further checks
|
|
245
|
+
}
|
|
246
|
+
const registered = new Set(reg.keys);
|
|
247
|
+
for (const c of scope.constraints) {
|
|
248
|
+
if (!registered.has(c.key)) {
|
|
249
|
+
if (mode === 'strict') {
|
|
250
|
+
throw new ScopeParseError(
|
|
251
|
+
`unregistered constraint key for ${scope.product}:${scope.verb}: ${c.key}`
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
// permissive: ignore
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
260
|
+
// Sub-scope relation (SPEC §7.4)
|
|
261
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Is `exercised` a sub-scope of `granted`?
|
|
265
|
+
* Returns true iff every constraint of `granted` admits the corresponding constraint
|
|
266
|
+
* (or absence) in `exercised`, per SPEC §7.4.
|
|
267
|
+
*/
|
|
268
|
+
export function isSubScope(exercised: Scope, granted: Scope): boolean {
|
|
269
|
+
if (exercised.product !== granted.product) return false;
|
|
270
|
+
if (exercised.verb !== granted.verb) return false;
|
|
271
|
+
|
|
272
|
+
const exIndex = new Map<string, ScopeConstraint>();
|
|
273
|
+
for (const c of exercised.constraints) exIndex.set(c.key, c);
|
|
274
|
+
|
|
275
|
+
for (const g of granted.constraints) {
|
|
276
|
+
const ex = exIndex.get(g.key);
|
|
277
|
+
if (g.op === '*') continue; // wildcard: no requirement
|
|
278
|
+
|
|
279
|
+
if (g.op === '=') {
|
|
280
|
+
if (!ex) return false;
|
|
281
|
+
if (ex.op !== '=' || ex.value !== g.value) return false;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (g.op === '!=') {
|
|
286
|
+
if (!ex) return false;
|
|
287
|
+
if (ex.op === '=' && ex.value !== g.value) continue;
|
|
288
|
+
if (ex.op === '!=' && ex.value === g.value) continue;
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Ordered ops: >=, <=, >, <. Exercised's implied range must be ⊆ granted's.
|
|
293
|
+
if (g.op === '<' || g.op === '<=' || g.op === '>' || g.op === '>=') {
|
|
294
|
+
if (!ex) return false;
|
|
295
|
+
if (!NUMERIC_KEYS.has(g.key)) return false;
|
|
296
|
+
if (ex.op === '*') return false;
|
|
297
|
+
if (ex.value === undefined || g.value === undefined) return false;
|
|
298
|
+
if (!rangeSubset(ex, g)) return false;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function rangeSubset(ex: ScopeConstraint, g: ScopeConstraint): boolean {
|
|
306
|
+
const exRange = opToRange(ex);
|
|
307
|
+
const gRange = opToRange(g);
|
|
308
|
+
if (!exRange || !gRange) return false;
|
|
309
|
+
return gRange.lo <= exRange.lo && exRange.hi <= gRange.hi;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function opToRange(c: ScopeConstraint): { lo: number; hi: number } | null {
|
|
313
|
+
if (c.value === undefined) return null;
|
|
314
|
+
const n = Number(c.value);
|
|
315
|
+
if (!Number.isFinite(n)) return null;
|
|
316
|
+
switch (c.op) {
|
|
317
|
+
case '=':
|
|
318
|
+
return { lo: n, hi: n };
|
|
319
|
+
case '<':
|
|
320
|
+
return { lo: -Infinity, hi: n - 1 }; // integers only
|
|
321
|
+
case '<=':
|
|
322
|
+
return { lo: -Infinity, hi: n };
|
|
323
|
+
case '>':
|
|
324
|
+
return { lo: n + 1, hi: Infinity };
|
|
325
|
+
case '>=':
|
|
326
|
+
return { lo: n, hi: Infinity };
|
|
327
|
+
default:
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Verify every committed test vector in oc-agent-protocol/test-vectors/.
|
|
2
|
+
|
|
3
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it } from 'vitest';
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
actionCanonicalMessage,
|
|
11
|
+
canonicalizeScopes,
|
|
12
|
+
computeActionId,
|
|
13
|
+
computeDelegationId,
|
|
14
|
+
computeRevocationId,
|
|
15
|
+
delegationCanonicalMessage,
|
|
16
|
+
revocationCanonicalMessage,
|
|
17
|
+
} from './canonical.js';
|
|
18
|
+
import { verifyAction, verifyDelegation, verifyRevocation } from './verify.js';
|
|
19
|
+
import type {
|
|
20
|
+
ActionEnvelope,
|
|
21
|
+
DelegationEnvelope,
|
|
22
|
+
RevocationEnvelope,
|
|
23
|
+
} from './types.js';
|
|
24
|
+
|
|
25
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const VECTORS_DIR = resolve(__dirname, '..', '..', '..', 'oc-agent-protocol', 'test-vectors');
|
|
27
|
+
|
|
28
|
+
interface BaseVector {
|
|
29
|
+
description: string;
|
|
30
|
+
kind: 'delegation' | 'action' | 'revocation';
|
|
31
|
+
expected: {
|
|
32
|
+
canonical_message: string;
|
|
33
|
+
canonical_message_bytes_len: number;
|
|
34
|
+
id: string;
|
|
35
|
+
envelope: DelegationEnvelope | ActionEnvelope | RevocationEnvelope;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface DelegationVector extends BaseVector {
|
|
40
|
+
kind: 'delegation';
|
|
41
|
+
inputs: {
|
|
42
|
+
principal: string;
|
|
43
|
+
agent: string;
|
|
44
|
+
scopes: string[];
|
|
45
|
+
bond: { sats: number; attestation_id: string } | null;
|
|
46
|
+
issued_at: string;
|
|
47
|
+
expires_at: string;
|
|
48
|
+
nonce: string;
|
|
49
|
+
};
|
|
50
|
+
expected: BaseVector['expected'] & { envelope: DelegationEnvelope };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface ActionVector extends BaseVector {
|
|
54
|
+
kind: 'action';
|
|
55
|
+
inputs: {
|
|
56
|
+
address: string;
|
|
57
|
+
content_hash: string;
|
|
58
|
+
content_length: number;
|
|
59
|
+
content_mime: string;
|
|
60
|
+
signed_at: string;
|
|
61
|
+
delegation_id: string;
|
|
62
|
+
scope_exercised: string;
|
|
63
|
+
};
|
|
64
|
+
expected: BaseVector['expected'] & { envelope: ActionEnvelope };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface RevocationVector extends BaseVector {
|
|
68
|
+
kind: 'revocation';
|
|
69
|
+
inputs: {
|
|
70
|
+
address: string;
|
|
71
|
+
delegation_id: string;
|
|
72
|
+
reason: string;
|
|
73
|
+
signed_at: string;
|
|
74
|
+
};
|
|
75
|
+
expected: BaseVector['expected'] & { envelope: RevocationEnvelope };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
type Vector = DelegationVector | ActionVector | RevocationVector;
|
|
79
|
+
|
|
80
|
+
async function loadVectors(): Promise<{ name: string; data: Vector }[]> {
|
|
81
|
+
try {
|
|
82
|
+
const files = await readdir(VECTORS_DIR);
|
|
83
|
+
const out: { name: string; data: Vector }[] = [];
|
|
84
|
+
for (const name of files) {
|
|
85
|
+
if (!name.endsWith('.json')) continue;
|
|
86
|
+
const text = await readFile(join(VECTORS_DIR, name), 'utf8');
|
|
87
|
+
out.push({ name, data: JSON.parse(text) as Vector });
|
|
88
|
+
}
|
|
89
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
90
|
+
} catch {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const vectors = await loadVectors();
|
|
96
|
+
|
|
97
|
+
describe('oc-agent-protocol test vectors', () => {
|
|
98
|
+
if (vectors.length === 0) {
|
|
99
|
+
it.skip('(no test-vectors directory found — skipping cross-implementation checks)', () => {});
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// First pass: the vectors can cross-reference each other (action cites delegation id).
|
|
104
|
+
// We'll need the envelopes keyed for the verifyAction tests.
|
|
105
|
+
const delegationEnvelopes = new Map<string, DelegationEnvelope>();
|
|
106
|
+
for (const { data } of vectors) {
|
|
107
|
+
if (data.kind === 'delegation') {
|
|
108
|
+
delegationEnvelopes.set(data.expected.id, data.expected.envelope);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const { name, data } of vectors) {
|
|
113
|
+
it(`${name} — canonical message reconstructs byte-identical`, () => {
|
|
114
|
+
const msg = reconstructCanonical(data);
|
|
115
|
+
expect(msg).toBe(data.expected.canonical_message);
|
|
116
|
+
expect(new TextEncoder().encode(msg).byteLength).toBe(
|
|
117
|
+
data.expected.canonical_message_bytes_len
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it(`${name} — id equals sha256(canonical_message)`, () => {
|
|
122
|
+
const id = reconstructId(data);
|
|
123
|
+
expect(id).toBe(data.expected.id);
|
|
124
|
+
expect(id).toBe(data.expected.envelope.id);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it(`${name} — declared envelope passes verify() with skipSignatureVerification`, async () => {
|
|
128
|
+
if (data.kind === 'delegation') {
|
|
129
|
+
const r = await verifyDelegation({
|
|
130
|
+
envelope: data.expected.envelope,
|
|
131
|
+
skipSignatureVerification: true,
|
|
132
|
+
skipTemporalCheck: true,
|
|
133
|
+
});
|
|
134
|
+
expect(r.ok).toBe(true);
|
|
135
|
+
} else if (data.kind === 'action') {
|
|
136
|
+
const delegation = delegationEnvelopes.get(data.inputs.delegation_id);
|
|
137
|
+
if (!delegation) {
|
|
138
|
+
throw new Error(`action vector ${name} references missing delegation ${data.inputs.delegation_id}`);
|
|
139
|
+
}
|
|
140
|
+
const r = await verifyAction({
|
|
141
|
+
action: data.expected.envelope,
|
|
142
|
+
delegation,
|
|
143
|
+
skipSignatureVerification: true,
|
|
144
|
+
});
|
|
145
|
+
expect(r.ok).toBe(true);
|
|
146
|
+
} else {
|
|
147
|
+
const delegation = delegationEnvelopes.get(data.inputs.delegation_id);
|
|
148
|
+
if (!delegation) {
|
|
149
|
+
throw new Error(`revocation vector ${name} references missing delegation ${data.inputs.delegation_id}`);
|
|
150
|
+
}
|
|
151
|
+
const r = await verifyRevocation({
|
|
152
|
+
envelope: data.expected.envelope,
|
|
153
|
+
delegation,
|
|
154
|
+
skipSignatureVerification: true,
|
|
155
|
+
});
|
|
156
|
+
expect(r.ok).toBe(true);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
function reconstructCanonical(v: Vector): string {
|
|
163
|
+
if (v.kind === 'delegation') {
|
|
164
|
+
// Canonicalize each scope (constraints sorted by key) then sort the list.
|
|
165
|
+
const canonical = canonicalizeScopes(v.inputs.scopes);
|
|
166
|
+
return delegationCanonicalMessage({
|
|
167
|
+
principal: v.inputs.principal,
|
|
168
|
+
agent: v.inputs.agent,
|
|
169
|
+
scopes: canonical,
|
|
170
|
+
bond_sats: v.inputs.bond?.sats ?? 0,
|
|
171
|
+
bond_attestation: v.inputs.bond?.attestation_id ?? 'none',
|
|
172
|
+
issued_at: v.inputs.issued_at,
|
|
173
|
+
expires_at: v.inputs.expires_at,
|
|
174
|
+
nonce: v.inputs.nonce,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
if (v.kind === 'action') {
|
|
178
|
+
return actionCanonicalMessage(v.inputs);
|
|
179
|
+
}
|
|
180
|
+
return revocationCanonicalMessage(v.inputs);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function reconstructId(v: Vector): string {
|
|
184
|
+
if (v.kind === 'delegation') {
|
|
185
|
+
const canonical = canonicalizeScopes(v.inputs.scopes);
|
|
186
|
+
return computeDelegationId({
|
|
187
|
+
principal: v.inputs.principal,
|
|
188
|
+
agent: v.inputs.agent,
|
|
189
|
+
scopes: canonical,
|
|
190
|
+
bond_sats: v.inputs.bond?.sats ?? 0,
|
|
191
|
+
bond_attestation: v.inputs.bond?.attestation_id ?? 'none',
|
|
192
|
+
issued_at: v.inputs.issued_at,
|
|
193
|
+
expires_at: v.inputs.expires_at,
|
|
194
|
+
nonce: v.inputs.nonce,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
if (v.kind === 'action') return computeActionId(v.inputs);
|
|
198
|
+
return computeRevocationId(v.inputs);
|
|
199
|
+
}
|