axis-platform-sdk 0.2.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.
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Reputation report-back — the platform's outbound "this agent misbehaved"
3
+ * signal.
4
+ *
5
+ * A platform that boots an agent has knowledge the rest of the network doesn't:
6
+ * THIS agent spammed / abused / violated policy here. AXIS Layer 3 (Reputation)
7
+ * is exactly the channel for that signal, as a signed **Trust Attestation** (a
8
+ * negative one). This module builds a protocol-shaped TA (axis-protocol
9
+ * SPEC §4.5 / schemas/trust-attestation.json), signs it with the PLATFORM'S OWN
10
+ * Ed25519 key, and POSTs it to a configurable reputation index.
11
+ *
12
+ * Crucial scoping note (per the locked decision):
13
+ * - The canonical registry is IDENTITY-ONLY (Layer 1 + Layer 2). Trust
14
+ * Attestations MUST NOT be stored there.
15
+ * - The reputation index is a SEPARATE, future, commercial service. It does
16
+ * not exist yet. This module ships the SENDING mechanism; `axis-reputation`
17
+ * ships a stub receiver that accepts-and-discards until the real index is
18
+ * built. Default REPUTATION_URL is OFF — unconfigured = graceful no-op.
19
+ *
20
+ * Zero-dep: signing uses WebCrypto (`crypto.subtle`) Ed25519, the same
21
+ * primitive axis-protocol-sdk uses. The platform keypair is generated once and
22
+ * persisted via a tiny pluggable key store (default in-memory; a real platform
23
+ * persists the JWK to its secret store / KV / D1).
24
+ */
25
+
26
+ // --- base64url (no Buffer dependency; Workers/browser/Node 20+) -------------
27
+
28
+ function bytesToB64url(bytes) {
29
+ let s = '';
30
+ for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
31
+ return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
32
+ }
33
+
34
+ function b64urlToBytes(s) {
35
+ const padded = s.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (s.length % 4)) % 4);
36
+ const bin = atob(padded);
37
+ const out = new Uint8Array(bin.length);
38
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
39
+ return out;
40
+ }
41
+
42
+ // --- RFC 8785 JCS (kept minimal + identical in spirit to axis-protocol jcs) --
43
+
44
+ function jcsCanonicalize(value) {
45
+ if (value === null) return 'null';
46
+ const t = typeof value;
47
+ if (t === 'number') {
48
+ if (!Number.isFinite(value)) throw new TypeError('non-finite number not representable in JCS');
49
+ return JSON.stringify(value);
50
+ }
51
+ if (t === 'string' || t === 'boolean') return JSON.stringify(value);
52
+ if (Array.isArray(value)) {
53
+ return '[' + value.map((el) => (el === undefined ? 'null' : jcsCanonicalize(el))).join(',') + ']';
54
+ }
55
+ if (t === 'object') {
56
+ const keys = Object.keys(value).sort();
57
+ const parts = [];
58
+ for (const k of keys) {
59
+ const v = value[k];
60
+ if (v === undefined || typeof v === 'function' || typeof v === 'symbol') continue;
61
+ parts.push(JSON.stringify(k) + ':' + jcsCanonicalize(v));
62
+ }
63
+ return '{' + parts.join(',') + '}';
64
+ }
65
+ throw new TypeError(`value of type ${t} not representable in JCS`);
66
+ }
67
+
68
+ // --- platform key store -----------------------------------------------------
69
+
70
+ /**
71
+ * Default in-memory platform key store. Generate once, hold for the isolate's
72
+ * lifetime. A real platform implements the same `load()` / `save(jwk)` shape
73
+ * over its secret store / KV / D1 so the key is stable across restarts.
74
+ */
75
+ export class MemoryKeyStore {
76
+ constructor() {
77
+ this._jwk = null;
78
+ }
79
+ async load() {
80
+ return this._jwk;
81
+ }
82
+ async save(jwk) {
83
+ this._jwk = jwk;
84
+ }
85
+ }
86
+
87
+ let _defaultKeyStore = null;
88
+ function defaultKeyStore() {
89
+ if (!_defaultKeyStore) _defaultKeyStore = new MemoryKeyStore();
90
+ return _defaultKeyStore;
91
+ }
92
+
93
+ /**
94
+ * Get (generating + persisting on first use) this platform's Ed25519 signing
95
+ * key. Returns { privateKey: CryptoKey, publicKeyB64: string }.
96
+ *
97
+ * @param {object} [opts]
98
+ * @param {object} [opts.keyStore] Pluggable store with async load()/save(jwk).
99
+ */
100
+ export async function getPlatformKey({ keyStore } = {}) {
101
+ const store = keyStore || defaultKeyStore();
102
+ let jwk = await store.load();
103
+ if (!jwk) {
104
+ const pair = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
105
+ jwk = await crypto.subtle.exportKey('jwk', pair.privateKey);
106
+ await store.save(jwk);
107
+ }
108
+ const privateKey = await crypto.subtle.importKey('jwk', jwk, { name: 'Ed25519' }, false, ['sign']);
109
+ // Derive the raw public key from the private JWK's `x` (Ed25519 public bytes).
110
+ const publicKeyB64 = jwk.x; // already base64url in a JWK
111
+ return { privateKey, publicKeyB64, jwk };
112
+ }
113
+
114
+ // --- Trust Attestation build + sign -----------------------------------------
115
+
116
+ /**
117
+ * Build a (negative) Trust Attestation document MINUS its signature, in the
118
+ * protocol shape (axis-protocol SPEC §4.5). `category` becomes the TA `scope`
119
+ * (the domain of the attestation); `reason` becomes the `statement`.
120
+ *
121
+ * @returns the unsigned TA object (no `signature` field yet).
122
+ */
123
+ export function buildAttestation({ platformId, agentId, category, reason, issuedAt }) {
124
+ if (!platformId) throw new Error('buildAttestation: platformId is required (this platform\'s AXIS id)');
125
+ if (!agentId) throw new Error('buildAttestation: agentId (subject) is required');
126
+ const ts = issuedAt || new Date().toISOString();
127
+ // id convention: ta:{operator-of-platform-or-platform-slug}:{descriptor}
128
+ const slug = String(platformId).replace(/^axis:/, '').replace(/[^a-z0-9:-]/gi, '-').toLowerCase();
129
+ const cat = String(category || 'abuse').replace(/[^a-z0-9:-]/gi, '-').toLowerCase();
130
+ const idDescriptor = `${cat}-${ts.replace(/[^0-9]/g, '').slice(0, 14)}`;
131
+ return {
132
+ axis_version: '0.1',
133
+ type: 'TrustAttestation',
134
+ id: `ta:${slug}:${idDescriptor}`,
135
+ issued_by: platformId,
136
+ subject: agentId,
137
+ issued_at: ts,
138
+ scope: cat,
139
+ statement: reason ? String(reason).slice(0, 1000) : `Flagged at ${platformId}`,
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Sign an unsigned TA with an Ed25519 private key. The signed bytes are the
145
+ * RFC 8785 JCS canonicalization of the TA WITHOUT its `signature` field — the
146
+ * same minus-the-proof convention every other AXIS signed body uses. Returns
147
+ * the TA with `signature` attached (base64url, no padding).
148
+ */
149
+ export async function signAttestation(attestation, privateKey) {
150
+ const { signature, ...unsigned } = attestation;
151
+ const bytes = new TextEncoder().encode(jcsCanonicalize(unsigned));
152
+ const sig = new Uint8Array(await crypto.subtle.sign('Ed25519', privateKey, bytes));
153
+ return { ...unsigned, signature: bytesToB64url(sig) };
154
+ }
155
+
156
+ /**
157
+ * Verify a TA's self-consistency: does `signature` verify against the supplied
158
+ * Ed25519 public key (base64url raw 32 bytes) over the JCS of the TA minus its
159
+ * signature? This is what the stub receiver runs. It proves the report wasn't
160
+ * tampered with in transit and that the holder of `platform_public_key` signed
161
+ * it — NOT that the public key belongs to a trusted platform (that's the
162
+ * index's policy job later).
163
+ */
164
+ export async function verifyAttestation(attestation, publicKeyB64) {
165
+ if (!attestation || typeof attestation !== 'object' || !attestation.signature) return false;
166
+ const { signature, ...unsigned } = attestation;
167
+ let key;
168
+ try {
169
+ key = await crypto.subtle.importKey('raw', b64urlToBytes(publicKeyB64), { name: 'Ed25519' }, false, ['verify']);
170
+ } catch {
171
+ return false;
172
+ }
173
+ const bytes = new TextEncoder().encode(jcsCanonicalize(unsigned));
174
+ let sig;
175
+ try {
176
+ sig = b64urlToBytes(signature);
177
+ } catch {
178
+ return false;
179
+ }
180
+ return crypto.subtle.verify('Ed25519', key, sig, bytes);
181
+ }
182
+
183
+ // --- report-back ------------------------------------------------------------
184
+
185
+ /**
186
+ * Default reputation index URL. OFF by default — reputation is a separate,
187
+ * future commercial service. When the real `axis-reputation` index ships, set
188
+ * this (or pass `reputationUrl`) to its `/attestations` endpoint.
189
+ */
190
+ export const DEFAULT_REPUTATION_URL = null;
191
+
192
+ /**
193
+ * Report a negative reputation flag about an agent.
194
+ *
195
+ * Builds a signed Trust Attestation and POSTs
196
+ * { attestation, platform_public_key, signature }
197
+ * to the reputation index. If no index is configured (the default), this is a
198
+ * graceful no-op: it returns { sent: false, reason: 'reputation_disabled' } and
199
+ * NEVER throws — a platform's boot flow must not break because reputation isn't
200
+ * wired up yet.
201
+ *
202
+ * @param {object} args
203
+ * @param {string} args.platformId This platform's AXIS id (the attestor).
204
+ * @param {string} args.agentId Subject agent's AXIS id.
205
+ * @param {string} [args.operatorId] Subject's operator id (recorded, not signed into TA core).
206
+ * @param {string} args.category Flag category -> TA scope (e.g. 'abuse:spam').
207
+ * @param {string} args.reason Human-readable reason -> TA statement.
208
+ * @param {object} [opts]
209
+ * @param {string|null} [opts.reputationUrl=DEFAULT_REPUTATION_URL]
210
+ * @param {object} [opts.keyStore] Platform key store (default in-memory).
211
+ * @param {function} [opts.fetchImpl=fetch]
212
+ * @returns {Promise<{ sent: boolean, status?: number, attestation?: object, reason?: string }>}
213
+ */
214
+ export async function reportFlag(
215
+ { platformId, agentId, operatorId, category, reason },
216
+ { reputationUrl = DEFAULT_REPUTATION_URL, keyStore, fetchImpl = fetch } = {}
217
+ ) {
218
+ let attestation;
219
+ let publicKeyB64;
220
+ try {
221
+ const { privateKey, publicKeyB64: pk } = await getPlatformKey({ keyStore });
222
+ publicKeyB64 = pk;
223
+ const unsigned = buildAttestation({ platformId, agentId, category, reason });
224
+ attestation = await signAttestation(unsigned, privateKey);
225
+ } catch (e) {
226
+ // Building/signing failed (e.g. missing platformId). Don't throw out of a
227
+ // boot flow; surface the reason.
228
+ return { sent: false, reason: `report_build_failed: ${e.message}` };
229
+ }
230
+
231
+ if (!reputationUrl) {
232
+ // Reputation index not configured. No-op, but hand back the signed TA so a
233
+ // caller can hold it as a portfolio item or send it later.
234
+ return { sent: false, reason: 'reputation_disabled', attestation };
235
+ }
236
+
237
+ try {
238
+ const res = await fetchImpl(reputationUrl, {
239
+ method: 'POST',
240
+ headers: { 'Content-Type': 'application/json' },
241
+ body: JSON.stringify({
242
+ attestation,
243
+ platform_public_key: publicKeyB64,
244
+ signature: attestation.signature,
245
+ operator_id: operatorId || null,
246
+ }),
247
+ });
248
+ return { sent: res.status >= 200 && res.status < 300, status: res.status, attestation };
249
+ } catch (e) {
250
+ // Network failure to the index must not break the platform's boot flow.
251
+ return { sent: false, reason: `reputation_unreachable: ${e.message}`, attestation };
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Convenience: block the agent locally AND report it. The local block is the
257
+ * authoritative action (it sticks regardless of the report's fate); the report
258
+ * is best-effort. Returns both outcomes.
259
+ *
260
+ * @param {import('./blocklist.js').Blocklist} blocklist
261
+ * @param {object} args Same as reportFlag, plus the agentId is what gets blocked.
262
+ * @param {object} [opts] reportFlag opts.
263
+ */
264
+ export async function blockAndReport(blocklist, args, opts = {}) {
265
+ await blocklist.blockAgent(args.agentId, args.reason);
266
+ const report = await reportFlag(args, opts);
267
+ return { blocked: true, agent_id: args.agentId, report };
268
+ }
package/src/scope.js ADDED
@@ -0,0 +1,46 @@
1
+ // Scope matching for the AXIS scope grammar (spec v0.2 §4.4).
2
+ //
3
+ // Ported verbatim from kipple-governor/src/scope-match.ts so the operator side
4
+ // (Governor proxy chain-walk) and the platform side (this SDK) agree on scope
5
+ // semantics exactly. Colon-separated segments; `*` is a wildcard for ONE
6
+ // segment, not multi; no recursion. If the protocol grammar changes, change it
7
+ // in both places.
8
+
9
+ /**
10
+ * Does `granted` cover `required`? I.e. can a caller granted `granted` claim
11
+ * the permission `required`?
12
+ *
13
+ * scopeCovers('admin:*', 'admin:users') === true
14
+ * scopeCovers('admin:users', 'admin:users') === true
15
+ * scopeCovers('admin:users', 'admin:roles') === false
16
+ * scopeCovers('admin:*', 'admin:users:delete') === false // * = 1 segment
17
+ * scopeCovers('anthropic:complete', 'anthropic:complete') === true
18
+ */
19
+ export function scopeCovers(granted, required) {
20
+ if (!granted || !required) return false;
21
+ if (granted === required) return true;
22
+ const g = granted.split(':');
23
+ const r = required.split(':');
24
+ if (g.length !== r.length) return false;
25
+ for (let i = 0; i < g.length; i++) {
26
+ if (g[i] === '*') continue;
27
+ if (g[i] !== r[i]) return false;
28
+ }
29
+ return true;
30
+ }
31
+
32
+ /**
33
+ * Do the granted scopes cover EVERY required scope? Empty `required` is
34
+ * trivially satisfied. Returns the missing required scopes on failure so a
35
+ * caller can put them in a denial reason.
36
+ *
37
+ * @returns {{ ok: boolean, missing: string[] }}
38
+ */
39
+ export function coversAll(granted, required) {
40
+ const grantedList = Array.isArray(granted) ? granted : [];
41
+ const missing = [];
42
+ for (const req of required) {
43
+ if (!grantedList.some((g) => scopeCovers(g, req))) missing.push(req);
44
+ }
45
+ return { ok: missing.length === 0, missing };
46
+ }
package/src/verify.js ADDED
@@ -0,0 +1,125 @@
1
+ /**
2
+ * verifyAgent — the one function that matters.
3
+ *
4
+ * An AXIS agent shows up at your platform and presents an AIT. This calls the
5
+ * registry to verify it (signature + revocation + delegation chain, all done
6
+ * server-side), then applies YOUR platform's gate: audience match, required
7
+ * scopes, blocked/approved operators, minimum verification tier. Returns a
8
+ * single structured verdict you can act on.
9
+ *
10
+ * Trust model notes:
11
+ * - The registry's `effective_scope` (returned when a valid delegation is
12
+ * presented) is the trustworthy, chain-walked scope. The AIT's self-declared
13
+ * `scope` is NOT trusted. An agent acting on its own identity with no
14
+ * delegation has NO proven scope, so any non-empty `requireScopes` denies it.
15
+ * - Audience is matched locally: the registry only checks that `aud` exists,
16
+ * not that it equals your platform. That check is yours.
17
+ */
18
+ import { decodeAitPayload } from './decode.js';
19
+ import { coversAll } from './scope.js';
20
+ import { registryGet, enrich, TIER_RANK, DEFAULT_REGISTRY } from './client.js';
21
+
22
+ /**
23
+ * @param {string} token The AIT the agent presented.
24
+ * @param {object} opts
25
+ * @param {string} [opts.audience] Your platform's audience id. If set, the AIT's `aud` must equal it.
26
+ * @param {string[]} [opts.requireScopes] Scopes the agent must hold (checked against trustworthy effective_scope).
27
+ * @param {string} [opts.minTier] Minimum operator verification tier (email|domain|verified|kyb_individual|kyb_organization).
28
+ * @param {string[]} [opts.blockedOperators] Operator ids to reject outright.
29
+ * @param {string[]|null} [opts.approvedOperators] If set, only these operator ids are accepted.
30
+ * @param {string} [opts.registryBaseUrl] Defaults to https://registry.axisprime.ai
31
+ * @param {function} [opts.fetchImpl] Injectable fetch (testing).
32
+ * @returns {Promise<object>} verdict: { accepted, reason?, code?, agent_id?, operator_id?, effective_scope?, delegation_valid?, tier?, expires_at? }
33
+ */
34
+ export async function verifyAgent(token, opts = {}) {
35
+ const {
36
+ audience,
37
+ requireScopes = [],
38
+ minTier,
39
+ blockedOperators = [],
40
+ approvedOperators = null,
41
+ registryBaseUrl = DEFAULT_REGISTRY,
42
+ fetchImpl = fetch,
43
+ } = opts;
44
+
45
+ const deny = (reason, code, extra = {}) => ({ accepted: false, reason, code, ...extra });
46
+
47
+ if (!token) return deny('No AIT presented', 'no_token');
48
+
49
+ // 1. Audience: the agent must have addressed THIS platform.
50
+ if (audience) {
51
+ const payload = decodeAitPayload(token);
52
+ if (!payload || !payload.aud) return deny('AIT is missing an audience (aud) claim', 'missing_aud');
53
+ if (payload.aud !== audience) {
54
+ return deny(`AIT audience '${payload.aud}' does not match this platform ('${audience}')`, 'audience_mismatch');
55
+ }
56
+ }
57
+
58
+ // 2. Registry verification (signature + revocation + chain walk).
59
+ let res;
60
+ try {
61
+ res = await registryGet(registryBaseUrl, `/verify?token=${encodeURIComponent(token)}`, { fetchImpl });
62
+ } catch {
63
+ return deny('Registry unreachable', 'registry_error');
64
+ }
65
+ const body = res.body || {};
66
+ if (res.status !== 200) {
67
+ return deny(body?.error?.message || 'Verification request failed', body?.error?.code || 'verify_failed');
68
+ }
69
+ if (body.valid !== true) {
70
+ // valid:false carries a stable `code` (invalid_signature|token_expired|agent_revoked|agent_suspended).
71
+ return deny(body.reason || 'AIT is not valid', body.code || 'invalid_ait', { agent_id: body.agent_id });
72
+ }
73
+
74
+ const agentId = body.agent_id;
75
+ const operatorId = body.operator_id;
76
+
77
+ // 3. Operator allow / block.
78
+ if (blockedOperators.includes(operatorId)) {
79
+ return deny('Operator is blocked at this platform', 'operator_blocked', { agent_id: agentId, operator_id: operatorId });
80
+ }
81
+ if (approvedOperators && !approvedOperators.includes(operatorId)) {
82
+ return deny('Operator is not on this platform\'s approved list', 'operator_not_approved', { agent_id: agentId, operator_id: operatorId });
83
+ }
84
+
85
+ // 4. Scope. Trustworthy scope is effective_scope, and only when the
86
+ // delegation actually validated. A direct AIT (no valid delegation) has
87
+ // no proven scope.
88
+ const delegationValid = body.delegation_valid === true;
89
+ const grantedScopes = delegationValid && Array.isArray(body.effective_scope) ? body.effective_scope : [];
90
+ if (requireScopes.length) {
91
+ const check = delegationValid ? coversAll(grantedScopes, requireScopes) : { ok: false, missing: requireScopes };
92
+ if (!check.ok) {
93
+ return deny(`Missing required scope(s): ${check.missing.join(', ')}`, 'insufficient_scope', {
94
+ agent_id: agentId,
95
+ operator_id: operatorId,
96
+ effective_scope: grantedScopes,
97
+ missing: check.missing,
98
+ });
99
+ }
100
+ }
101
+
102
+ // 5. Minimum tier (optional; needs a presentation-layer fetch).
103
+ let tier = null;
104
+ if (minTier) {
105
+ const info = await enrich(agentId, token, { registryBaseUrl, fetchImpl });
106
+ tier = info.tier;
107
+ if ((TIER_RANK[tier] || 0) < (TIER_RANK[minTier] || 0)) {
108
+ return deny(`Operator tier '${tier || 'unknown'}' is below the required '${minTier}'`, 'insufficient_tier', {
109
+ agent_id: agentId,
110
+ operator_id: operatorId,
111
+ tier,
112
+ });
113
+ }
114
+ }
115
+
116
+ return {
117
+ accepted: true,
118
+ agent_id: agentId,
119
+ operator_id: operatorId,
120
+ effective_scope: grantedScopes,
121
+ delegation_valid: delegationValid,
122
+ tier,
123
+ expires_at: body.expires_at || null,
124
+ };
125
+ }