@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/package.json ADDED
@@ -0,0 +1,75 @@
1
+ {
2
+ "name": "@orangecheck/agent-core",
3
+ "version": "0.1.0",
4
+ "description": "OC Agent canonical messages, envelope formats (delegation/action/revocation), scope grammar, and verification. See https://github.com/orangecheck/oc-agent-protocol.",
5
+ "keywords": [
6
+ "bitcoin",
7
+ "agent",
8
+ "authority",
9
+ "delegation",
10
+ "bip322",
11
+ "oc-agent",
12
+ "orangecheck"
13
+ ],
14
+ "author": "OrangeCheck",
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/orangecheck/oc-packages.git",
19
+ "directory": "agent-core"
20
+ },
21
+ "homepage": "https://github.com/orangecheck/oc-agent-protocol",
22
+ "bugs": {
23
+ "url": "https://github.com/orangecheck/oc-agent-protocol/issues"
24
+ },
25
+ "main": "./dist/index.js",
26
+ "module": "./dist/index.mjs",
27
+ "types": "./dist/index.d.ts",
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "import": "./dist/index.mjs",
32
+ "require": "./dist/index.js"
33
+ },
34
+ "./canonical": {
35
+ "types": "./dist/canonical.d.ts",
36
+ "import": "./dist/canonical.mjs",
37
+ "require": "./dist/canonical.js"
38
+ },
39
+ "./scope": {
40
+ "types": "./dist/scope.d.ts",
41
+ "import": "./dist/scope.mjs",
42
+ "require": "./dist/scope.js"
43
+ },
44
+ "./types": {
45
+ "types": "./dist/types.d.ts",
46
+ "import": "./dist/types.mjs",
47
+ "require": "./dist/types.js"
48
+ }
49
+ },
50
+ "files": [
51
+ "dist",
52
+ "src",
53
+ "README.md",
54
+ "LICENSE"
55
+ ],
56
+ "scripts": {
57
+ "build": "tsup",
58
+ "dev": "tsup --watch",
59
+ "type-check": "tsc --noEmit",
60
+ "test": "vitest run",
61
+ "test:watch": "vitest",
62
+ "clean": "rm -rf dist",
63
+ "prepublishOnly": "npm run clean && npm run build"
64
+ },
65
+ "dependencies": {
66
+ "@noble/hashes": "^1.5.0",
67
+ "@orangecheck/stamp-core": "^0.1.1"
68
+ },
69
+ "devDependencies": {
70
+ "@types/node": "^22.10.2",
71
+ "tsup": "^8.3.5",
72
+ "typescript": "^5.7.2",
73
+ "vitest": "^3.2.4"
74
+ }
75
+ }
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ actionCanonicalMessage,
5
+ canonicalizeScopes,
6
+ computeActionId,
7
+ computeDelegationId,
8
+ computeRevocationId,
9
+ delegationCanonicalMessage,
10
+ revocationCanonicalMessage,
11
+ } from './canonical.js';
12
+
13
+ describe('delegation canonical message', () => {
14
+ it('sorts scopes lexicographically and serializes fields LF-terminated', () => {
15
+ const msg = delegationCanonicalMessage({
16
+ principal: 'bc1qprincipal',
17
+ agent: 'bc1qagent',
18
+ scopes: canonicalizeScopes(['stamp:sign(mime=text/markdown)', 'lock:seal(recipient=bc1qalice)']),
19
+ bond_sats: 0,
20
+ bond_attestation: 'none',
21
+ issued_at: '2026-04-22T12:00:00Z',
22
+ expires_at: '2026-04-29T12:00:00Z',
23
+ nonce: '0123456789abcdef0123456789abcdef',
24
+ });
25
+ expect(msg.startsWith('oc-agent:delegation:v1\n')).toBe(true);
26
+ expect(msg).toContain('scopes: lock:seal(recipient=bc1qalice),stamp:sign(mime=text/markdown)');
27
+ expect(msg.endsWith('\n')).toBe(false);
28
+ });
29
+
30
+ it('produces a deterministic id across identical inputs', () => {
31
+ const input = {
32
+ principal: 'bc1qprincipal000000000000000000000000000000',
33
+ agent: 'bc1qagent0000000000000000000000000000000000',
34
+ scopes: ['lock:seal(recipient=bc1qalice000000000000000000000000000000000)'],
35
+ bond_sats: 0,
36
+ bond_attestation: 'none',
37
+ issued_at: '2026-04-22T12:00:00Z',
38
+ expires_at: '2026-04-29T12:00:00Z',
39
+ nonce: '0123456789abcdef0123456789abcdef',
40
+ };
41
+ const id1 = computeDelegationId(input);
42
+ const id2 = computeDelegationId(input);
43
+ expect(id1).toBe(id2);
44
+ expect(id1).toMatch(/^[0-9a-f]{64}$/);
45
+ });
46
+ });
47
+
48
+ describe('action canonical message', () => {
49
+ it('includes delegation_id and scope_exercised on extra lines', () => {
50
+ const msg = actionCanonicalMessage({
51
+ address: 'bc1qagent',
52
+ content_hash: 'sha256:' + '3'.repeat(64),
53
+ content_length: 1024,
54
+ content_mime: 'application/vnd.oc-lock+json',
55
+ signed_at: '2026-04-22T12:05:00Z',
56
+ delegation_id: 'a'.repeat(64),
57
+ scope_exercised: 'lock:seal(recipient=bc1qalice)',
58
+ });
59
+ expect(msg.startsWith('oc-agent:action:v1\n')).toBe(true);
60
+ expect(msg).toContain('delegation_id: ' + 'a'.repeat(64));
61
+ expect(msg).toContain('scope_exercised: lock:seal(recipient=bc1qalice)');
62
+ });
63
+
64
+ it('id is computable', () => {
65
+ const id = computeActionId({
66
+ address: 'bc1qagent',
67
+ content_hash: 'sha256:' + '3'.repeat(64),
68
+ content_length: 1,
69
+ content_mime: 'text/plain',
70
+ signed_at: '2026-04-22T12:05:00Z',
71
+ delegation_id: 'a'.repeat(64),
72
+ scope_exercised: 'lock:seal(recipient=bc1qalice)',
73
+ });
74
+ expect(id).toMatch(/^[0-9a-f]{64}$/);
75
+ });
76
+ });
77
+
78
+ describe('revocation canonical message', () => {
79
+ it('serializes empty reason as a blank line value', () => {
80
+ const msg = revocationCanonicalMessage({
81
+ address: 'bc1qprincipal',
82
+ delegation_id: 'a'.repeat(64),
83
+ reason: '',
84
+ signed_at: '2026-04-22T14:00:00Z',
85
+ });
86
+ expect(msg).toContain('\nreason: \n');
87
+ const id = computeRevocationId({
88
+ address: 'bc1qprincipal',
89
+ delegation_id: 'a'.repeat(64),
90
+ reason: '',
91
+ signed_at: '2026-04-22T14:00:00Z',
92
+ });
93
+ expect(id).toMatch(/^[0-9a-f]{64}$/);
94
+ });
95
+ });
@@ -0,0 +1,155 @@
1
+ // Canonical messages + envelope canonicalization for OC Agent. SPEC §4.1, §5.1, §9.1.
2
+ //
3
+ // Three canonical-message builders live here — one per envelope kind. Each one
4
+ // produces the exact byte sequence a signer signs via BIP-322 and the hash
5
+ // input for the envelope id.
6
+ //
7
+ // The RFC 8785 JSON canonicalizer and hex utilities are re-exported from
8
+ // @orangecheck/stamp-core so OC Agent and OC Stamp are guaranteed to produce
9
+ // identical bytes for identical structural inputs.
10
+
11
+ import { sha256 } from '@noble/hashes/sha256';
12
+ import { canonicalize, hexEncode } from '@orangecheck/stamp-core/canonical';
13
+
14
+ import { canonicalizeScope, parseScope, type Scope } from './scope.js';
15
+ import type {
16
+ ActionCanonicalInput,
17
+ ActionEnvelope,
18
+ DelegationCanonicalInput,
19
+ DelegationEnvelope,
20
+ RevocationCanonicalInput,
21
+ RevocationEnvelope,
22
+ } from './types.js';
23
+
24
+ export { canonicalize, hexEncode };
25
+
26
+ // ─────────────────────────────────────────────────────────────────────────────
27
+ // Scope sorting + serialization
28
+ // ─────────────────────────────────────────────────────────────────────────────
29
+
30
+ /**
31
+ * Canonicalize and sort a list of scope strings for the delegation canonical
32
+ * message. Each scope is first parsed, then re-emitted in canonical form
33
+ * (constraints sorted by key), and the whole list is sorted lexicographically.
34
+ */
35
+ export function canonicalizeScopes(scopes: string[]): string[] {
36
+ const canonical = scopes.map((s) => canonicalizeScope(parseScope(s)));
37
+ return [...canonical].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
38
+ }
39
+
40
+ /**
41
+ * Same as `canonicalizeScopes` but returns `Scope` objects too, for callers
42
+ * that need them.
43
+ */
44
+ export function parseAndCanonicalizeScopes(scopes: string[]): { canonical: string[]; parsed: Scope[] } {
45
+ const parsed = scopes.map(parseScope);
46
+ const canonicalStrings = parsed.map(canonicalizeScope);
47
+ const indexed = canonicalStrings.map((s, i) => ({ s, p: parsed[i]! }));
48
+ indexed.sort((a, b) => (a.s < b.s ? -1 : a.s > b.s ? 1 : 0));
49
+ return {
50
+ canonical: indexed.map((x) => x.s),
51
+ parsed: indexed.map((x) => x.p),
52
+ };
53
+ }
54
+
55
+ // ─────────────────────────────────────────────────────────────────────────────
56
+ // Canonical messages (SPEC §4.1, §5.1, §9.1)
57
+ // ─────────────────────────────────────────────────────────────────────────────
58
+
59
+ export function delegationCanonicalMessage(input: DelegationCanonicalInput): string {
60
+ const scopeField = input.scopes.join(',');
61
+ return [
62
+ 'oc-agent:delegation:v1',
63
+ `principal: ${input.principal}`,
64
+ `agent: ${input.agent}`,
65
+ `scopes: ${scopeField}`,
66
+ `bond_sats: ${input.bond_sats}`,
67
+ `bond_attestation: ${input.bond_attestation}`,
68
+ `issued_at: ${input.issued_at}`,
69
+ `expires_at: ${input.expires_at}`,
70
+ `nonce: ${input.nonce}`,
71
+ ].join('\n');
72
+ }
73
+
74
+ export function actionCanonicalMessage(input: ActionCanonicalInput): string {
75
+ return [
76
+ 'oc-agent:action:v1',
77
+ `address: ${input.address}`,
78
+ `content_hash: ${input.content_hash}`,
79
+ `content_length: ${input.content_length}`,
80
+ `content_mime: ${input.content_mime}`,
81
+ `signed_at: ${input.signed_at}`,
82
+ `delegation_id: ${input.delegation_id}`,
83
+ `scope_exercised: ${input.scope_exercised}`,
84
+ ].join('\n');
85
+ }
86
+
87
+ export function revocationCanonicalMessage(input: RevocationCanonicalInput): string {
88
+ return [
89
+ 'oc-agent:revocation:v1',
90
+ `address: ${input.address}`,
91
+ `delegation_id: ${input.delegation_id}`,
92
+ `reason: ${input.reason}`,
93
+ `signed_at: ${input.signed_at}`,
94
+ ].join('\n');
95
+ }
96
+
97
+ // ─────────────────────────────────────────────────────────────────────────────
98
+ // Bytes + ids
99
+ // ─────────────────────────────────────────────────────────────────────────────
100
+
101
+ export function delegationCanonicalBytes(input: DelegationCanonicalInput): Uint8Array {
102
+ return new TextEncoder().encode(delegationCanonicalMessage(input));
103
+ }
104
+
105
+ export function actionCanonicalBytes(input: ActionCanonicalInput): Uint8Array {
106
+ return new TextEncoder().encode(actionCanonicalMessage(input));
107
+ }
108
+
109
+ export function revocationCanonicalBytes(input: RevocationCanonicalInput): Uint8Array {
110
+ return new TextEncoder().encode(revocationCanonicalMessage(input));
111
+ }
112
+
113
+ export function computeDelegationId(input: DelegationCanonicalInput): string {
114
+ return hexEncode(sha256(delegationCanonicalBytes(input)));
115
+ }
116
+
117
+ export function computeActionId(input: ActionCanonicalInput): string {
118
+ return hexEncode(sha256(actionCanonicalBytes(input)));
119
+ }
120
+
121
+ export function computeRevocationId(input: RevocationCanonicalInput): string {
122
+ return hexEncode(sha256(revocationCanonicalBytes(input)));
123
+ }
124
+
125
+ // ─────────────────────────────────────────────────────────────────────────────
126
+ // Envelope canonicalization (SPEC §6; RFC 8785 + scope-sorting)
127
+ // ─────────────────────────────────────────────────────────────────────────────
128
+
129
+ export function canonicalizeDelegation(env: DelegationEnvelope): string {
130
+ return canonicalize(env as unknown as Parameters<typeof canonicalize>[0]);
131
+ }
132
+
133
+ export function canonicalizeAction(env: ActionEnvelope): string {
134
+ return canonicalize(env as unknown as Parameters<typeof canonicalize>[0]);
135
+ }
136
+
137
+ export function canonicalizeRevocation(env: RevocationEnvelope): string {
138
+ return canonicalize(env as unknown as Parameters<typeof canonicalize>[0]);
139
+ }
140
+
141
+ export function canonicalDelegationBytes(env: DelegationEnvelope): Uint8Array {
142
+ return new TextEncoder().encode(canonicalizeDelegation(env) + '\n');
143
+ }
144
+
145
+ export function canonicalActionBytes(env: ActionEnvelope): Uint8Array {
146
+ return new TextEncoder().encode(canonicalizeAction(env) + '\n');
147
+ }
148
+
149
+ export function canonicalRevocationBytes(env: RevocationEnvelope): Uint8Array {
150
+ return new TextEncoder().encode(canonicalizeRevocation(env) + '\n');
151
+ }
152
+
153
+ export function sha256Hex(bytes: Uint8Array): string {
154
+ return hexEncode(sha256(bytes));
155
+ }
package/src/index.ts ADDED
@@ -0,0 +1,45 @@
1
+ export * from './types.js';
2
+ export {
3
+ canonicalize,
4
+ hexEncode,
5
+ sha256Hex,
6
+ canonicalizeScopes,
7
+ parseAndCanonicalizeScopes,
8
+ delegationCanonicalMessage,
9
+ actionCanonicalMessage,
10
+ revocationCanonicalMessage,
11
+ delegationCanonicalBytes,
12
+ actionCanonicalBytes,
13
+ revocationCanonicalBytes,
14
+ computeDelegationId,
15
+ computeActionId,
16
+ computeRevocationId,
17
+ canonicalizeDelegation,
18
+ canonicalizeAction,
19
+ canonicalizeRevocation,
20
+ canonicalDelegationBytes,
21
+ canonicalActionBytes,
22
+ canonicalRevocationBytes,
23
+ } from './canonical.js';
24
+ export {
25
+ parseScope,
26
+ canonicalizeScope,
27
+ canonicalizeScopeString,
28
+ validateScope,
29
+ isSubScope,
30
+ ScopeParseError,
31
+ REGISTERED_SCOPES,
32
+ } from './scope.js';
33
+ export type { Scope, ScopeConstraint, ScopeOp, ValidationOptions } from './scope.js';
34
+ export {
35
+ verifyDelegation,
36
+ verifyAction,
37
+ verifyRevocation,
38
+ AgentError,
39
+ } from './verify.js';
40
+ export type {
41
+ VerifyBase,
42
+ VerifyDelegationInput,
43
+ VerifyActionInput,
44
+ VerifyRevocationInput,
45
+ } from './verify.js';
@@ -0,0 +1,162 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ canonicalizeScope,
5
+ canonicalizeScopeString,
6
+ isSubScope,
7
+ parseScope,
8
+ ScopeParseError,
9
+ validateScope,
10
+ } from './scope.js';
11
+
12
+ describe('parseScope', () => {
13
+ it('parses a bare product:verb scope', () => {
14
+ const s = parseScope('nostr:publish');
15
+ expect(s.product).toBe('nostr');
16
+ expect(s.verb).toBe('publish');
17
+ expect(s.constraints).toHaveLength(0);
18
+ });
19
+
20
+ it('parses constraints with =, !=, <=, >=', () => {
21
+ const s = parseScope('ln:send(max_sats<=1000,node=03abc,max_fee_sats<=10)');
22
+ expect(s.constraints).toHaveLength(3);
23
+ expect(s.constraints.find((c) => c.key === 'max_sats')?.op).toBe('<=');
24
+ expect(s.constraints.find((c) => c.key === 'node')?.op).toBe('=');
25
+ });
26
+
27
+ it('accepts a wildcard constraint', () => {
28
+ const s = parseScope('http:request(origin=*)');
29
+ expect(s.constraints[0]?.op).toBe('*');
30
+ });
31
+
32
+ it('rejects whitespace', () => {
33
+ expect(() => parseScope('lock:seal (recipient=bc)')).toThrow(ScopeParseError);
34
+ });
35
+
36
+ it('rejects duplicate keys', () => {
37
+ expect(() => parseScope('ln:send(max_sats=1,max_sats=2)')).toThrow(ScopeParseError);
38
+ });
39
+
40
+ it('parses quoted values', () => {
41
+ const s = parseScope('http:request(origin="https://a.example,b")');
42
+ expect(s.constraints[0]?.value).toBe('https://a.example,b');
43
+ expect(s.constraints[0]?.quoted).toBe(true);
44
+ });
45
+ });
46
+
47
+ describe('canonicalize', () => {
48
+ it('sorts constraints by key', () => {
49
+ expect(canonicalizeScopeString('ln:send(node=03abc,max_sats<=1000)')).toBe(
50
+ 'ln:send(max_sats<=1000,node=03abc)'
51
+ );
52
+ });
53
+
54
+ it('canonicalizes a no-constraint scope', () => {
55
+ expect(canonicalizeScopeString('stamp:sign')).toBe('stamp:sign');
56
+ });
57
+
58
+ it('round-trips quoted values', () => {
59
+ expect(canonicalizeScopeString('http:request(origin="a,b")')).toBe(
60
+ 'http:request(origin="a,b")'
61
+ );
62
+ });
63
+ });
64
+
65
+ describe('validateScope', () => {
66
+ it('strict mode rejects unregistered product', () => {
67
+ expect(() => validateScope(parseScope('xxx:yyy'))).toThrow(ScopeParseError);
68
+ });
69
+
70
+ it('strict mode rejects unregistered constraint key', () => {
71
+ expect(() => validateScope(parseScope('lock:seal(zzz=1)'))).toThrow(ScopeParseError);
72
+ });
73
+
74
+ it('permissive mode accepts unregistered product/key', () => {
75
+ expect(() =>
76
+ validateScope(parseScope('xxx:yyy(foo=1)'), { mode: 'permissive' })
77
+ ).not.toThrow();
78
+ });
79
+ });
80
+
81
+ describe('isSubScope (SPEC §7.4)', () => {
82
+ const cs = (s: string) => canonicalizeScope(parseScope(s));
83
+ void cs;
84
+
85
+ it('accepts exact match', () => {
86
+ expect(
87
+ isSubScope(
88
+ parseScope('lock:seal(recipient=bc1qalice)'),
89
+ parseScope('lock:seal(recipient=bc1qalice)')
90
+ )
91
+ ).toBe(true);
92
+ });
93
+
94
+ it('rejects different value under =', () => {
95
+ expect(
96
+ isSubScope(
97
+ parseScope('stamp:sign(mime=application/pdf)'),
98
+ parseScope('stamp:sign(mime=text/markdown)')
99
+ )
100
+ ).toBe(false);
101
+ });
102
+
103
+ it('accepts tighter numeric range', () => {
104
+ expect(
105
+ isSubScope(
106
+ parseScope('ln:send(max_sats=500,node=03abc,max_fee_sats=5)'),
107
+ parseScope('ln:send(max_sats<=1000,node=03abc,max_fee_sats<=10)')
108
+ )
109
+ ).toBe(true);
110
+ });
111
+
112
+ it('rejects wider numeric range', () => {
113
+ expect(
114
+ isSubScope(
115
+ parseScope('ln:send(max_sats=5000)'),
116
+ parseScope('ln:send(max_sats<=1000)')
117
+ )
118
+ ).toBe(false);
119
+ });
120
+
121
+ it('wildcard grants admit anything', () => {
122
+ expect(
123
+ isSubScope(
124
+ parseScope('http:request(origin=https://evil.com)'),
125
+ parseScope('http:request(origin=*)')
126
+ )
127
+ ).toBe(true);
128
+ });
129
+
130
+ it('exercised may add keys not in granted', () => {
131
+ expect(
132
+ isSubScope(
133
+ parseScope('ln:send(max_sats=500,node=03abc,max_fee_sats=5)'),
134
+ parseScope('ln:send(max_sats<=1000)')
135
+ )
136
+ ).toBe(true);
137
+ });
138
+
139
+ it('rejects mismatched product or verb', () => {
140
+ expect(
141
+ isSubScope(parseScope('lock:chat'), parseScope('lock:seal'))
142
+ ).toBe(false);
143
+ });
144
+
145
+ it('accepts != with admissible value', () => {
146
+ expect(
147
+ isSubScope(
148
+ parseScope('http:request(method=GET)'),
149
+ parseScope('http:request(method!=POST)')
150
+ )
151
+ ).toBe(true);
152
+ });
153
+
154
+ it('rejects != with disallowed value', () => {
155
+ expect(
156
+ isSubScope(
157
+ parseScope('http:request(method=POST)'),
158
+ parseScope('http:request(method!=POST)')
159
+ )
160
+ ).toBe(false);
161
+ });
162
+ });