@metamynd/agentsafe-guard 0.2.0 → 0.3.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.
@@ -1,480 +1,606 @@
1
- // agentsafe-guard.mjs — drop-in runtime governance for any Node agent (OpenClaw, LangChain, custom).
2
- //
3
- // ZERO external dependencies: uses Node's built-in Ed25519 (node:crypto) + fetch (Node 18+),
4
- // plus policy-core.mjs (the deterministic evaluator, itself dependency-free, generated from
5
- // backend/src/policy-core). Before an agent performs a governed action the guard can either
6
- // call the AgentSafe authorize gate (trustless fallback) OR evaluate a signed policy bundle
7
- // LOCALLY (spec §9.2 cooperative mode) — both compute the identical allow/block/escalate
8
- // verdict from the identical inputs, because they run the same policy-core.
9
- //
10
- // The agent's private key is a Hedera Ed25519 DER key (the AGENT_KEY the seed prints).
11
- import crypto from 'node:crypto';
12
- import { readFileSync } from 'node:fs';
13
- import { evaluate, buildAuthMessage, applySignedLast } from './policy-core.mjs';
14
- import { verifyDidSignature } from './magp-did.mjs';
15
- import { checkSettlementBinding } from './x402.mjs';
16
-
17
- /**
18
- * @param {{ api: string, agentDid: string, agentKey: string }} cfg
19
- * api e.g. "http://localhost:9926/api/v1" or "https://metamynd.ai/api/v1"
20
- * agentDid the agent's did:hedera
21
- * agentKey the agent's Ed25519 private key (Hedera DER hex, held only by the agent)
22
- */
23
- /**
24
- * Async loader build a guard from the portable config the one-call `POST /onboarding/agent`
25
- * endpoint returns: a URL, a file path, or the config object itself. Overrides win over the config.
26
- * const guard = await createGuardFromConfig('./agent.metamynd.json');
27
- */
28
- export async function createGuardFromConfig(source, overrides = {}) {
29
- let cfg = source;
30
- if (typeof source === 'string') {
31
- cfg = /^https?:\/\//.test(source) ? await (await fetch(source)).json() : JSON.parse(readFileSync(source, 'utf8'));
32
- }
33
- if (cfg && cfg.data && !cfg.agentDid) cfg = cfg.data; // unwrap a { success, data } API response
34
- return createGuard({ config: cfg, ...overrides });
35
- }
36
-
37
- export function createGuard(opts = {}) {
38
- // Accept a portable agent config (from /onboarding/agent) via `config` or `configPath`, in
39
- // addition to explicit { api, agentDid, agentKey }. Explicit fields win over the config.
40
- let cfg = opts.config ?? null;
41
- if (!cfg && opts.configPath) {
42
- try { cfg = JSON.parse(readFileSync(opts.configPath, 'utf8')); }
43
- catch (e) { throw new Error(`createGuard: cannot read configPath "${opts.configPath}": ${e.message}`); }
44
- }
45
- if (cfg && cfg.data && !cfg.agentDid) cfg = cfg.data; // unwrap a { success, data } API response
46
- const api = opts.api ?? cfg?.apiBase ?? cfg?.api;
47
- const agentDid = opts.agentDid ?? cfg?.agentDid;
48
- const agentKey = opts.agentKey ?? cfg?.agentKey;
49
- if (!api || !agentDid || !agentKey) throw new Error('createGuard requires { api, agentDid, agentKey } — directly, or via { config } / { configPath } / createGuardFromConfig()');
50
- const base = api.replace(/\/$/, '');
51
- const privateKey = crypto.createPrivateKey({ key: Buffer.from(agentKey, 'hex'), format: 'der', type: 'pkcs8' });
52
-
53
- // Ed25519 over the exact canonical message the backend verifies.
54
- function sign(message) {
55
- return crypto.sign(null, Buffer.from(message, 'utf8'), privateKey).toString('hex');
56
- }
57
-
58
- // --- Enforcement mode (spec §9.2 + local-first plan) --------------------------------------
59
- // 'local' (DEFAULT): decide the rule layer LOCALLY against a cached signed bundle — a
60
- // block/escalate needs no network; an allowed VALUE action is still sealed by the remote
61
- // gate (two-phase hold + cumulative cap + evidence). 'remote': every call hits the gate.
62
- const mode = opts.mode ?? cfg?.mode ?? 'local';
63
- const bundleUrl = opts.bundleUrl ?? cfg?.bundleUrl ?? `${base}/policy/bundle/${encodeURIComponent(agentDid)}`;
64
- const sealValueActions = opts.sealValueActions !== false; // default true
65
- // Build B — trustless currency check. When on, the guard trusts its local bundle ONLY if that
66
- // bundle is the LATEST one anchored on the agent's Hedera topic (read from a public mirror);
67
- // otherwise it defers to the authoritative remote gate. Opt-in for now.
68
- const verifyOnChain = opts.verifyOnChain ?? cfg?.verifyOnChain ?? false;
69
- const _anchorTtlMs = opts.anchorTtlMs ?? 60_000;
70
- let _bundle = null;
71
- let _bundleAt = 0;
72
- let _bundleMaxAgeMs = 10 * 60 * 1000; // overwritten by the bundle's maxStaleness
73
- let _anchor = null;
74
- let _anchorAt = 0;
75
- let _highestSeq = 0; // monotonic: never accept a mirror response with fewer policy ops than seen
76
-
77
- /** Parse an ISO-8601 duration like "PT10M" / "PT30S" / "PT1H" → ms (or null). */
78
- function _durationMs(s) {
79
- const m = /^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$/.exec(String(s ?? ''));
80
- if (!m) return null;
81
- return ((+m[1] || 0) * 3600 + (+m[2] || 0) * 60 + (+m[3] || 0)) * 1000 || null;
82
- }
83
-
84
- /**
85
- * Build a signed authorize request (spec §7.2/§7.3) WITHOUT sending it the object an
86
- * agent presents to a counterparty (e.g. an MCP) so the counterparty can re-verify the
87
- * agent's authorization trustlessly against the agent's policy bundle (§9.3). Same shape
88
- * `authorize()` posts to the gate; a fresh nonce each call.
89
- */
90
- function buildSignedRequest({ action, amount = 0, currency = 'USD', merchant = '', context = {} }) {
91
- const nonce = crypto.randomUUID();
92
- const issuedAt = new Date().toISOString();
93
- const message = buildAuthMessage({ agentDid, action, amount, currency, merchant, nonce, issuedAt });
94
- return { agentDid, action, amount, currency, merchant, itinerary: context, nonce, issuedAt, signature: sign(message) };
95
- }
96
-
97
- /**
98
- * Ask the gate whether an action is authorized. Never throws on a policy decision —
99
- * returns { decision:'allow'|'block'|'escalate', reasonCode, authorizationId, remaining }.
100
- * A network/gate failure returns a fail-CLOSED block so the agent can't proceed blind.
101
- */
102
- async function authorize({ action, amount = 0, currency = 'USD', merchant = '', context = {} }) {
103
- const nonce = crypto.randomUUID();
104
- const issuedAt = new Date().toISOString();
105
- // Build the canonical signed message with policy-core so the guard and the
106
- // backend gate produce byte-identical input to Ed25519 (spec §7.3).
107
- const message = buildAuthMessage({ agentDid, action, amount, currency, merchant, nonce, issuedAt });
108
- try {
109
- const res = await fetch(`${base}/policy/mandate/authorize`, {
110
- method: 'POST',
111
- headers: { 'Content-Type': 'application/json' },
112
- body: JSON.stringify({ agentDid, action, amount, currency, merchant, itinerary: context, nonce, issuedAt, signature: sign(message) }),
113
- });
114
- const body = await res.json().catch(() => null);
115
- return body?.data ?? { decision: 'block', reasonCode: `GATE_HTTP_${res.status}` };
116
- } catch (err) {
117
- return { decision: 'block', reasonCode: 'GATE_UNREACHABLE', error: String(err?.message ?? err) };
118
- }
119
- }
120
-
121
- /**
122
- * Settle an approved hold (two-phase). Call after the real action succeeds with the
123
- * amount actually charged (≤ the authorized amount). Pass the x402 `settlementTxHash`
124
- * to record the on-chain payment proof against the capture (§7a.3.2). Optional
125
- * skip for non-payment tools.
126
- */
127
- async function capture(authorizationId, amountCharged, bookingRef, settlementTxHash) {
128
- const res = await fetch(`${base}/policy/mandate/authorize/${authorizationId}/capture`, {
129
- method: 'POST', headers: { 'Content-Type': 'application/json' },
130
- body: JSON.stringify({ amountCharged, bookingRef, settlementTxHash }),
131
- });
132
- return res.json().catch(() => ({}));
133
- }
134
-
135
- /**
136
- * Evaluate a signed policy bundle LOCALLY — no network — using the same
137
- * deterministic policy-core the gate runs (spec §9.2 cooperative mode). Given the
138
- * same (rule packs, mandate, request), this returns the identical verdict the
139
- * gate would. The stateful parts the gate owns (nonce/replay, atomic spend-cap
140
- * reservation, evidence anchoring) are NOT done here this is the local
141
- * allow/block/escalate pre-check, so `authorizationId`/`remaining` are null.
142
- *
143
- * @param {object} p
144
- * @param {Array<{standardKey:string,document:object}>} [p.standards] enforced Standards bound to the agent
145
- * @param {Array<{standardKey:string,document:object}>} [p.sops] active SOPs assigned to the agent
146
- * @param {object} [p.mandate] the ODRL mandate document (omit to skip the mandate layer)
147
- * @param {{action:string,amount?:number,merchant?:string,context?:object,cumulativeSpend?:number,now?:string}} p.request
148
- * @returns {{decision:'allow'|'block'|'escalate',reasonCode:string|null,authorizationId:null,remaining:null,proofRef:null}}
149
- */
150
- function evaluateLocally({ standards = [], sops = [], mandate, request }) {
151
- const { action, amount = 0, merchant = '', context = {}, cumulativeSpend = amount, now } = request;
152
- // Signed fields (action/agentDid/amount, mm:* operands) are applied LAST so an
153
- // unsigned context key can never shadow them (spec §6.4.2) — the same invariant
154
- // the gate enforces, via the same policy-core helper.
155
- return evaluate({
156
- standards,
157
- sops,
158
- mandate,
159
- context: applySignedLast(context, { action, agentDid, amount }),
160
- mandateRequest: mandate
161
- ? {
162
- target: action,
163
- now: now ?? new Date().toISOString(),
164
- values: applySignedLast(context, {
165
- 'mm:payAmount': amount,
166
- 'mm:cumulativeSpend': cumulativeSpend,
167
- 'mm:merchant': merchant,
168
- }),
169
- }
170
- : undefined,
171
- });
172
- }
173
-
174
- /** Fetch + cache the agent's signed policy bundle (refreshed per its maxStaleness). */
175
- async function loadBundle(force = false) {
176
- const now = Date.now();
177
- if (!force && _bundle && now - _bundleAt < _bundleMaxAgeMs) return _bundle;
178
- const res = await fetch(bundleUrl);
179
- const body = await res.json().catch(() => null);
180
- const b = body?.data ?? body;
181
- if (!b || (!b.mandates && !b.sops && !b.standards)) throw new Error(`invalid policy bundle from ${bundleUrl}`);
182
- _bundle = b;
183
- _bundleAt = now;
184
- _bundleMaxAgeMs = _durationMs(b.maxStaleness) ?? _bundleMaxAgeMs;
185
- return b;
186
- }
187
-
188
- /** Map a fetched bundle into the shape evaluateLocally expects, for one action. */
189
- function _bundleFor(b, action) {
190
- return {
191
- standards: (b.standards ?? []).map((s) => ({ standardKey: s.id ?? s.standardKey ?? 'standard', document: s.document })).filter((s) => s.document),
192
- sops: (b.sops ?? []).map((s) => ({ standardKey: s.id ?? s.sopId ?? 'sop', document: s.document })).filter((s) => s.document),
193
- mandate: ((b.mandates ?? []).find((m) => m.action === action) ?? (b.mandates ?? [])[0])?.document,
194
- };
195
- }
196
-
197
- const _sha256 = (s) => 'sha256:' + crypto.createHash('sha256').update(String(s)).digest('hex');
198
-
199
- /**
200
- * Read the CURRENT anchored policy for this agent from its OWN Hedera topic via a public
201
- * mirror node no MetaMynd call (Build B / spec §5.3.1). Returns { sigDigest, seq } of the
202
- * latest `policy-update` op, or null. Cached for `_anchorTtlMs`; monotonic on `seq`.
203
- */
204
- async function _currentAnchor() {
205
- const now = Date.now();
206
- if (_anchor && now - _anchorAt < _anchorTtlMs) return _anchor;
207
- const m = /^did:hedera:([^:]+):[^_]+_(.+)$/.exec(agentDid);
208
- if (!m) return _anchor;
209
- const network = m[1];
210
- const topicId = m[2];
211
- const mbase = network === 'mainnet' ? 'https://mainnet.mirrornode.hedera.com' : 'https://testnet.mirrornode.hedera.com';
212
- try {
213
- // Newest-first: the latest `policy-update` op for this DID is the current policy. Its topic
214
- // sequence_number is the monotonic marker (globally increasing under Hedera consensus), so a
215
- // rollback / a mirror hiding recent updates shows a LOWER seq and is rejected. (A very busy
216
- // topic could bury the op past one page; a per-agent topic won't — pagination is a refinement.)
217
- const body = await fetch(`${mbase}/api/v1/topics/${topicId}/messages?limit=100&order=desc`).then((r) => (r.ok ? r.json() : null));
218
- const hit = (body?.messages ?? [])
219
- .map((x) => { try { return { seq: Number(x.sequence_number), op: JSON.parse(Buffer.from(x.message, 'base64').toString('utf8')) }; } catch { return null; } })
220
- .filter((e) => e && e.op?.op === 'policy-update' && e.op.did === agentDid)
221
- .sort((a, b) => b.seq - a.seq)[0];
222
- if (!hit) return _anchor;
223
- const a = { sigDigest: hit.op.sigDigest ?? null, seq: hit.seq };
224
- if (a.seq >= _highestSeq) { _anchor = a; _anchorAt = now; _highestSeq = a.seq; }
225
- } catch { /* mirror unreachable — keep the last known anchor */ }
226
- return _anchor;
227
- }
228
-
229
- /**
230
- * LOCAL-FIRST decision (the default). Evaluates the rule layer against the cached
231
- * bundle with the same policy-core the gate runs — so a block/escalate is decided
232
- * with NO network. An allowed VALUE action (amount > 0) is then sealed by the remote
233
- * gate (two-phase hold + cumulative-spend cap + anchored evidence — the parts that
234
- * MUST be server-side); set `sealValueActions:false` for pure offline. If the bundle
235
- * can't be loaded, defers to the authoritative remote gate rather than blind-allow.
236
- */
237
- async function authorizeLocal(input) {
238
- const { action, amount = 0 } = input;
239
- let b;
240
- try {
241
- b = await loadBundle();
242
- } catch {
243
- return authorize(input); // no local rules → authoritative remote gate
244
- }
245
- // Trustless currency check (Build B): trust the local bundle only if it is the LATEST one
246
- // anchored on Hedera; otherwise defer to the authoritative remote gate (never evaluate against
247
- // a bundle we can't prove is current this defeats a stale/rolled-back or forged bundle).
248
- if (verifyOnChain) {
249
- const anchor = await _currentAnchor();
250
- const sig = b?.proof?.signature;
251
- if (!anchor?.sigDigest || !sig || _sha256(sig) !== anchor.sigDigest) return authorize(input);
252
- }
253
- const local = evaluateLocally({ ..._bundleFor(b, action), request: input });
254
- if (local.decision !== 'allow') return local; // decided locally, no network
255
- if (amount > 0 && sealValueActions) return authorize(input); // seal value action remotely
256
- return local; // non-value allow local is sufficient
257
- }
258
-
259
- /** Mode-aware decision used by guardTool: 'local' (default) or 'remote'. */
260
- async function check(input) {
261
- return mode === 'remote' ? authorize(input) : authorizeLocal(input);
262
- }
263
-
264
- /**
265
- * Watch for policy changes over Server-Sent Events (Build C) ZERO-dependency (plain fetch,
266
- * no socket client). On a `policy:changed` push the guard invalidates its bundle + on-chain
267
- * anchor cache, so the NEXT call re-fetches (and re-verifies) the new rules — reaching the edge
268
- * in ~1s instead of within maxStaleness. Push is an optimization: a dropped stream still leaves
269
- * staleness (A) + the on-chain check (B) as the floor. Auto-reconnects with a short backoff.
270
- * Returns a handle with `.close()`. Optional `onChange(payload)` callback.
271
- */
272
- function watchPolicy(onChange) {
273
- let stopped = false;
274
- let controller = null;
275
- (async () => {
276
- while (!stopped) {
277
- try {
278
- controller = new AbortController();
279
- const res = await fetch(`${base}/policy/events/${encodeURIComponent(agentDid)}`, {
280
- headers: { Accept: 'text/event-stream' },
281
- signal: controller.signal,
282
- });
283
- if (!res.ok || !res.body) throw new Error(`policy events ${res.status}`);
284
- const reader = res.body.getReader();
285
- const dec = new TextDecoder();
286
- let buf = '';
287
- while (!stopped) {
288
- const { value, done } = await reader.read();
289
- if (done) break;
290
- buf += dec.decode(value, { stream: true });
291
- let i;
292
- while ((i = buf.indexOf('\n\n')) >= 0) {
293
- const frame = buf.slice(0, i);
294
- buf = buf.slice(i + 2);
295
- if (!/^event:\s*policy:changed/m.test(frame)) continue; // ignore comments/heartbeats
296
- _bundle = null; _bundleAt = 0; _anchor = null; _anchorAt = 0; // invalidate next call re-fetches
297
- if (onChange) {
298
- const dline = frame.split('\n').find((l) => l.startsWith('data:'));
299
- try { onChange(dline ? JSON.parse(dline.slice(5).trim()) : {}); } catch { /* ignore */ }
300
- }
301
- }
302
- }
303
- } catch {
304
- /* stream droppedreconnect */
305
- }
306
- if (!stopped) await new Promise((r) => setTimeout(r, 2000));
307
- }
308
- })();
309
- return { close() { stopped = true; try { controller?.abort(); } catch { /* ignore */ } } };
310
- }
311
-
312
- /**
313
- * Like guardTool, but evaluates LOCALLY against a policy bundle instead of calling
314
- * the gate — cooperative-mode, low-latency governance (spec §9.2). Fails CLOSED:
315
- * any error during local evaluation throws GovernanceBlocked, never allows.
316
- *
317
- * @param {string} action
318
- * @param {(args:any, decision:any)=>any} handler
319
- * @param {(args:any)=>{amount?:number,merchant?:string,context?:object}} mapArgs
320
- * @param {object|((args:any)=>object|Promise<object>)} getBundle { standards, sops, mandate } (or a resolver)
321
- */
322
- function guardToolLocal(action, handler, mapArgs = (a) => a, getBundle = {}) {
323
- return async (args) => {
324
- let decision;
325
- try {
326
- const { amount, merchant, context } = mapArgs(args);
327
- const bundle = typeof getBundle === 'function' ? await getBundle(args) : getBundle;
328
- decision = evaluateLocally({ ...bundle, request: { action, amount, merchant, context } });
329
- } catch (err) {
330
- decision = { decision: 'block', reasonCode: 'LOCAL_EVAL_ERROR', error: String(err?.message ?? err) };
331
- }
332
- if (decision.decision !== 'allow') {
333
- const err = new Error(`AgentSafe ${decision.decision.toUpperCase()} "${action}": ${decision.reasonCode}`);
334
- err.name = 'GovernanceBlocked';
335
- err.governance = decision;
336
- throw err;
337
- }
338
- return handler(args, decision);
339
- };
340
- }
341
-
342
- /**
343
- * Wrap a tool handler so it is gated. Returns a function you register with your agent
344
- * framework in place of the raw handler. On a non-allow decision it THROWS a
345
- * GovernanceBlocked error (with `.governance`) so the agent surfaces the reason and
346
- * does NOT perform the action.
347
- *
348
- * @param {string} action the governed action (must match a mandate scope, e.g. 'flight-purchase')
349
- * @param {(args:any, decision:any)=>any} handler the real tool implementation
350
- * @param {(args:any)=>{amount?:number,currency?:string,merchant?:string,context?:object}} mapArgs
351
- * maps the tool's call args to the gate inputs (amount/merchant + the context the rules need)
352
- */
353
- function guardTool(action, handler, mapArgs = (a) => a) {
354
- return async (args) => {
355
- const decision = await check({ action, ...mapArgs(args) });
356
- if (decision.decision !== 'allow') {
357
- const err = new Error(`AgentSafe ${decision.decision.toUpperCase()} "${action}": ${decision.reasonCode}`);
358
- err.name = 'GovernanceBlocked';
359
- err.governance = decision;
360
- throw err;
361
- }
362
- return handler(args, decision);
363
- };
364
- }
365
-
366
- /**
367
- * Mutual-handshake INITIATOR (spec §8.2). Prove control of this agent's DID to a
368
- * Service and verify the Service controls its DID — no issuer calls (keys are in
369
- * the DIDs, §4.1.2). Returns { hello, prove } to drive the exchange:
370
- * const hs = guard.handshake();
371
- * const { nonceA, message } = hs.hello(); // → send HELLO to the Service
372
- * const { sigA, handshakeId } = hs.prove({ nonceA, challenge }); // verifies the Service, → send PROVE
373
- * `prove` throws HandshakeFailed if the Service's CHALLENGE does not verify.
374
- */
375
- function handshake() {
376
- return {
377
- hello() {
378
- const nonceA = crypto.randomUUID();
379
- return { nonceA, message: { fromDid: agentDid, nonceA, protoVersion: '0.4' } };
380
- },
381
- prove({ nonceA, challenge } = {}) {
382
- const { toDid, nonceB, sigB, handshakeId } = challenge ?? {};
383
- if (!toDid || !nonceB || !sigB) throw new Error('malformed CHALLENGE');
384
- if (!verifyDidSignature(toDid, nonceA, sigB)) {
385
- const e = new Error('Service failed to prove control of its DID');
386
- e.name = 'HandshakeFailed';
387
- throw e;
388
- }
389
- return { handshakeId, sigA: sign(nonceB), remoteDid: toDid };
390
- },
391
- };
392
- }
393
-
394
- /**
395
- * Read a Service's 402 PaymentRequirements and prepare to pay (spec §7a.1 step 5).
396
- * Refuses a 402 that is NOT bound to a MAGP authorization (§7a.2.1) — the agent
397
- * must never pay for an ungoverned request — and refuses one whose authorization
398
- * does not match the `authorizationId` the agent holds from its own authorize
399
- * (allow) step, so a swapped 402 can't redirect the payment.
400
- *
401
- * @param {object} requirements the x402 PaymentRequirements from the 402 response
402
- * @param {string} [expectedAuthorizationId] the authorizationId from guard.authorize()
403
- * @returns {{authorizationId:string, amountMinor:string, payTo:string, asset:string, network:string, resource:string}}
404
- */
405
- function preparePayment(requirements, expectedAuthorizationId) {
406
- const a = requirements?.accepts?.[0];
407
- if (!a?.extra?.magpAuthorizationId) {
408
- const e = new Error('402 is not bound to a MAGP authorization — refusing to pay');
409
- e.name = 'UnboundPayment';
410
- throw e;
411
- }
412
- if (expectedAuthorizationId && a.extra.magpAuthorizationId !== expectedAuthorizationId) {
413
- const e = new Error('402 authorization does not match the agent authorization');
414
- e.name = 'AuthorizationMismatch';
415
- throw e;
416
- }
417
- // Pay exactly the authorized amount; the binding check guards against overpay.
418
- checkSettlementBinding(requirements, { authorizationId: a.extra.magpAuthorizationId, paidAmountMinor: a.maxAmountRequired });
419
- return {
420
- authorizationId: a.extra.magpAuthorizationId,
421
- amountMinor: a.maxAmountRequired,
422
- payTo: a.payTo,
423
- asset: a.asset,
424
- network: a.network,
425
- resource: a.resource,
426
- };
427
- }
428
-
429
- /**
430
- * Poll the outcome of an escalated action (spec §9a). When authorize() returns
431
- * `escalate`, its `escalationId` parks the action for the Owner to approve/deny.
432
- * The agent polls this until the status is terminal; on `approved` the returned
433
- * `authorizationId` carries into the §7a capture/pay flow. Fails soft (never throws).
434
- * @returns {Promise<{status:string,reasonCode:string,authorizationId:string|null,expiresAt:string|null}>}
435
- */
436
- async function escalationStatus(escalationId) {
437
- try {
438
- const res = await fetch(`${base}/policy/escalations/${encodeURIComponent(escalationId)}/status`);
439
- const body = await res.json().catch(() => null);
440
- return body?.data ?? { status: 'unknown', reasonCode: `GATE_HTTP_${res.status}`, authorizationId: null, expiresAt: null };
441
- } catch (err) {
442
- return { status: 'unreachable', reasonCode: 'GATE_UNREACHABLE', authorizationId: null, expiresAt: null, error: String(err?.message ?? err) };
443
- }
444
- }
445
-
446
- /**
447
- * BYOK proof-of-possession (onboarding proposal #4). For an agent that brought its OWN key,
448
- * MetaMynd issued the identity with a one-time `challenge` and left the key UNVERIFIED — the gate
449
- * blocks it with AGENT_KEY_UNVERIFIED until control is proven. This signs the challenge with the
450
- * agent's private key (the same Ed25519 the gate checks) and submits it to verify-key, flipping
451
- * the key to verified. A one-time SETUP step: verify-key is owner-authenticated, so pass the owner
452
- * `token` you onboarded with. `ref` defaults to the identityId; `challenge` comes from the config.
453
- *
454
- * @param {{ ref: string, challenge: string, token?: string }} p
455
- * @returns {Promise<{ verified: boolean, did?: string }>}
456
- */
457
- async function verifyKey({ ref, challenge, token } = {}) {
458
- if (!ref || !challenge) throw new Error('verifyKey requires { ref, challenge } (from the BYOK onboarding config)');
459
- const res = await fetch(`${base}/agent-identity/${encodeURIComponent(ref)}/verify-key`, {
460
- method: 'POST',
461
- headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
462
- body: JSON.stringify({ signature: sign(challenge) }),
463
- });
464
- const body = await res.json().catch(() => null);
465
- if (!res.ok) {
466
- const e = new Error(body?.message || `verify-key HTTP ${res.status}`);
467
- e.name = 'KeyVerificationFailed';
468
- throw e;
469
- }
470
- return body?.data ?? { verified: true };
471
- }
472
-
473
- /** Sign a BYOK challenge with the agent's key (hex) — for integrators who submit verify-key themselves. */
474
- function signChallenge(challenge) {
475
- if (!challenge) throw new Error('signChallenge requires the challenge nonce');
476
- return sign(challenge);
477
- }
478
-
479
- return { authorize, authorizeLocal, check, loadBundle, policyAnchor: _currentAnchor, watchPolicy, mode, verifyOnChain, buildSignedRequest, capture, guardTool, evaluateLocally, guardToolLocal, handshake, preparePayment, escalationStatus, verifyKey, signChallenge, agentDid };
480
- }
1
+ // agentsafe-guard.mjs — drop-in runtime governance for any Node agent (OpenClaw, LangChain, custom).
2
+ //
3
+ // ZERO external dependencies: uses Node's built-in Ed25519 (node:crypto) + fetch (Node 18+),
4
+ // plus policy-core.mjs (the deterministic evaluator, itself dependency-free, generated from
5
+ // backend/src/policy-core). Before an agent performs a governed action the guard can either
6
+ // call the AgentSafe authorize gate (trustless fallback) OR evaluate a signed policy bundle
7
+ // LOCALLY (spec §9.2 cooperative mode) — both compute the identical allow/block/escalate
8
+ // verdict from the identical inputs, because they run the same policy-core.
9
+ //
10
+ // The agent's private key is a Hedera Ed25519 DER key (the AGENT_KEY the seed prints).
11
+ import crypto from 'node:crypto';
12
+ import { readFileSync } from 'node:fs';
13
+ import { evaluate, buildAuthMessage, applySignedLast, operatingModeGate } from './policy-core.mjs';
14
+ import { verifyDidSignature } from './magp-did.mjs';
15
+ import { checkSettlementBinding } from './x402.mjs';
16
+
17
+ /**
18
+ * ExecutionAdapter (SAFR §19, Phase-4 PR-4) the seam between a PERMITTING verdict
19
+ * (allow / observe) and the real side-effect. Before this, a guarded tool called its
20
+ * handler directly, so the only outcomes were "execute for real" or "throw". An adapter
21
+ * interposes so the SAME governed decision can be run live, SIMULATED (dry-run), or routed
22
+ * to a sandbox — without touching the tool handler or the gate.
23
+ *
24
+ * Contract: `async (execCtx) => result`, where
25
+ * execCtx = { action, args, decision, proceed }
26
+ * proceed() runs the real handler (handler(args, decision)) and returns its result.
27
+ * An adapter that calls `proceed()` executes for real; one that returns WITHOUT calling it
28
+ * substitutes the side-effect. Adapters run ONLY after the guard has permitted the action —
29
+ * a block/escalate still throws GovernanceBlocked before any adapter is consulted.
30
+ */
31
+
32
+ /** The default: execute the real handler unchanged. */
33
+ export const liveExecutionAdapter = (ctx) => ctx.proceed();
34
+
35
+ /**
36
+ * Simulate the side-effect: do NOT call the handler, return a describe-only result. Lets an
37
+ * agent exercise a fully-governed flow (identity mandate → controls → verdict) with no real
38
+ * booking/payment/write for staging, canaries, and OBSERVE-mode dry-runs.
39
+ */
40
+ export const dryRunExecutionAdapter = (ctx) => ({
41
+ dryRun: true,
42
+ action: ctx.action,
43
+ decision: ctx.decision?.decision ?? null,
44
+ reasonCode: ctx.decision?.reasonCode ?? null,
45
+ authorizationId: ctx.decision?.authorizationId ?? null,
46
+ args: ctx.args,
47
+ });
48
+
49
+ /**
50
+ * Process-default adapter from `AGENTSAFE_EXECUTION_MODE` ('live' | 'dry-run'). Returns null
51
+ * when unset/live so the caller's own default (live) applies behavior-neutral by default.
52
+ */
53
+ export function executionAdapterFromEnv(env = (typeof process !== 'undefined' ? process.env : {})) {
54
+ const mode = String(env.AGENTSAFE_EXECUTION_MODE ?? '').toLowerCase().trim();
55
+ if (mode === 'dry-run' || mode === 'dryrun') return dryRunExecutionAdapter;
56
+ return null;
57
+ }
58
+
59
+ /**
60
+ * @param {{ api: string, agentDid: string, agentKey: string }} cfg
61
+ * api e.g. "http://localhost:9926/api/v1" or "https://metamynd.ai/api/v1"
62
+ * agentDid the agent's did:hedera
63
+ * agentKey the agent's Ed25519 private key (Hedera DER hex, held only by the agent)
64
+ */
65
+ /**
66
+ * Async loader build a guard from the portable config the one-call `POST /onboarding/agent`
67
+ * endpoint returns: a URL, a file path, or the config object itself. Overrides win over the config.
68
+ * const guard = await createGuardFromConfig('./agent.metamynd.json');
69
+ */
70
+ export async function createGuardFromConfig(source, overrides = {}) {
71
+ let cfg = source;
72
+ if (typeof source === 'string') {
73
+ cfg = /^https?:\/\//.test(source) ? await (await fetch(source)).json() : JSON.parse(readFileSync(source, 'utf8'));
74
+ }
75
+ if (cfg && cfg.data && !cfg.agentDid) cfg = cfg.data; // unwrap a { success, data } API response
76
+ return createGuard({ config: cfg, ...overrides });
77
+ }
78
+
79
+ export function createGuard(opts = {}) {
80
+ // Accept a portable agent config (from /onboarding/agent) via `config` or `configPath`, in
81
+ // addition to explicit { api, agentDid, agentKey }. Explicit fields win over the config.
82
+ let cfg = opts.config ?? null;
83
+ if (!cfg && opts.configPath) {
84
+ try { cfg = JSON.parse(readFileSync(opts.configPath, 'utf8')); }
85
+ catch (e) { throw new Error(`createGuard: cannot read configPath "${opts.configPath}": ${e.message}`); }
86
+ }
87
+ if (cfg && cfg.data && !cfg.agentDid) cfg = cfg.data; // unwrap a { success, data } API response
88
+ const api = opts.api ?? cfg?.apiBase ?? cfg?.api;
89
+ const agentDid = opts.agentDid ?? cfg?.agentDid;
90
+ const agentKey = opts.agentKey ?? cfg?.agentKey;
91
+ if (!api || !agentDid || !agentKey) throw new Error('createGuard requires { api, agentDid, agentKey } — directly, or via { config } / { configPath } / createGuardFromConfig()');
92
+ const base = api.replace(/\/$/, '');
93
+ // ExecutionAdapter seam (SAFR §19): an explicit opt wins, else the AGENTSAFE_EXECUTION_MODE env,
94
+ // else live. Applies to every guarded tool unless a tool passes its own adapter.
95
+ const defaultExecutionAdapter = opts.executionAdapter ?? executionAdapterFromEnv() ?? liveExecutionAdapter;
96
+ const privateKey = crypto.createPrivateKey({ key: Buffer.from(agentKey, 'hex'), format: 'der', type: 'pkcs8' });
97
+
98
+ // Ed25519 over the exact canonical message the backend verifies.
99
+ function sign(message) {
100
+ return crypto.sign(null, Buffer.from(message, 'utf8'), privateKey).toString('hex');
101
+ }
102
+
103
+ // --- Enforcement mode (spec §9.2 + local-first plan) --------------------------------------
104
+ // 'local' (DEFAULT): decide the rule layer LOCALLY against a cached signed bundle — a
105
+ // block/escalate needs no network; an allowed VALUE action is still sealed by the remote
106
+ // gate (two-phase hold + cumulative cap + evidence). 'remote': every call hits the gate.
107
+ const mode = opts.mode ?? cfg?.mode ?? 'local';
108
+ const bundleUrl = opts.bundleUrl ?? cfg?.bundleUrl ?? `${base}/policy/bundle/${encodeURIComponent(agentDid)}`;
109
+ const sealValueActions = opts.sealValueActions !== false; // default true
110
+ // Build B — trustless currency check. When on, the guard trusts its local bundle ONLY if that
111
+ // bundle is the LATEST one anchored on the agent's Hedera topic (read from a public mirror);
112
+ // otherwise it defers to the authoritative remote gate. Opt-in for now.
113
+ const verifyOnChain = opts.verifyOnChain ?? cfg?.verifyOnChain ?? false;
114
+ const _anchorTtlMs = opts.anchorTtlMs ?? 60_000;
115
+ let _bundle = null;
116
+ let _bundleAt = 0;
117
+ let _bundleMaxAgeMs = 10 * 60 * 1000; // overwritten by the bundle's maxStaleness
118
+ let _anchor = null;
119
+ let _anchorAt = 0;
120
+ let _highestSeq = 0; // monotonic: never accept a mirror response with fewer policy ops than seen
121
+
122
+ /** Parse an ISO-8601 duration like "PT10M" / "PT30S" / "PT1H" ms (or null). */
123
+ function _durationMs(s) {
124
+ const m = /^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$/.exec(String(s ?? ''));
125
+ if (!m) return null;
126
+ return ((+m[1] || 0) * 3600 + (+m[2] || 0) * 60 + (+m[3] || 0)) * 1000 || null;
127
+ }
128
+
129
+ /**
130
+ * Build a signed authorize request (spec §7.2/§7.3) WITHOUT sending it — the object an
131
+ * agent presents to a counterparty (e.g. an MCP) so the counterparty can re-verify the
132
+ * agent's authorization trustlessly against the agent's policy bundle (§9.3). Same shape
133
+ * `authorize()` posts to the gate; a fresh nonce each call.
134
+ */
135
+ function buildSignedRequest({ action, amount = 0, currency = 'USD', merchant = '', context = {}, trace, materiality }) {
136
+ const nonce = crypto.randomUUID();
137
+ const issuedAt = new Date().toISOString();
138
+ const message = buildAuthMessage({ agentDid, action, amount, currency, merchant, nonce, issuedAt });
139
+ // trace/materiality are GovernanceEnvelope fields (SAFR §5) unsigned metadata; the
140
+ // signed message stays the action subset, so verification is unchanged.
141
+ return { agentDid, action, amount, currency, merchant, itinerary: context, trace, materiality, nonce, issuedAt, signature: sign(message) };
142
+ }
143
+
144
+ /**
145
+ * Ask the gate whether an action is authorized. Never throws on a policy decision —
146
+ * returns { decision:'allow'|'block'|'escalate', reasonCode, authorizationId, remaining }.
147
+ * A network/gate failure returns a fail-CLOSED block so the agent can't proceed blind.
148
+ */
149
+ async function authorize({ action, amount = 0, currency = 'USD', merchant = '', context = {}, trace, materiality }) {
150
+ const nonce = crypto.randomUUID();
151
+ const issuedAt = new Date().toISOString();
152
+ // Build the canonical signed message with policy-core so the guard and the
153
+ // backend gate produce byte-identical input to Ed25519 (spec §7.3).
154
+ const message = buildAuthMessage({ agentDid, action, amount, currency, merchant, nonce, issuedAt });
155
+ try {
156
+ const res = await fetch(`${base}/policy/mandate/authorize`, {
157
+ method: 'POST',
158
+ headers: { 'Content-Type': 'application/json' },
159
+ // trace/materiality (SAFR §5 envelope) ride as unsigned metadata; JSON.stringify
160
+ // drops them when undefined, so an agent that omits them sends the legacy body.
161
+ body: JSON.stringify({ agentDid, action, amount, currency, merchant, itinerary: context, trace, materiality, nonce, issuedAt, signature: sign(message) }),
162
+ });
163
+ const body = await res.json().catch(() => null);
164
+ return body?.data ?? { decision: 'block', reasonCode: `GATE_HTTP_${res.status}` };
165
+ } catch (err) {
166
+ return { decision: 'block', reasonCode: 'GATE_UNREACHABLE', error: String(err?.message ?? err) };
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Settle an approved hold (two-phase). Call after the real action succeeds with the
172
+ * amount actually charged (≤ the authorized amount). Pass the x402 `settlementTxHash`
173
+ * to record the on-chain payment proof against the capture (§7a.3.2). Optional —
174
+ * skip for non-payment tools.
175
+ */
176
+ async function capture(authorizationId, amountCharged, bookingRef, settlementTxHash) {
177
+ const res = await fetch(`${base}/policy/mandate/authorize/${authorizationId}/capture`, {
178
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
179
+ body: JSON.stringify({ amountCharged, bookingRef, settlementTxHash }),
180
+ });
181
+ return res.json().catch(() => ({}));
182
+ }
183
+
184
+ /**
185
+ * Evaluate a signed policy bundle LOCALLY — no network — using the same
186
+ * deterministic policy-core the gate runs (spec §9.2 cooperative mode). Given the
187
+ * same (rule packs, mandate, request), this returns the identical verdict the
188
+ * gate would. The stateful parts the gate owns (nonce/replay, atomic spend-cap
189
+ * reservation, evidence anchoring) are NOT done here — this is the local
190
+ * allow/block/escalate pre-check, so `authorizationId`/`remaining` are null.
191
+ *
192
+ * @param {object} p
193
+ * @param {Array<{standardKey:string,document:object}>} [p.standards] enforced Standards bound to the agent
194
+ * @param {Array<{standardKey:string,document:object}>} [p.sops] active SOPs assigned to the agent
195
+ * @param {object} [p.mandate] the ODRL mandate document (omit to skip the mandate layer)
196
+ * @param {{action:string,amount?:number,merchant?:string,context?:object,cumulativeSpend?:number,now?:string}} p.request
197
+ * @returns {{decision:'allow'|'block'|'escalate',reasonCode:string|null,authorizationId:null,remaining:null,proofRef:null}}
198
+ */
199
+ function evaluateLocally({ contained = null, operatingMode = null, standards = [], sops = [], mandate, request }) {
200
+ // Push containment (Phase 2.3): a server-CONTAINED agent is denied at the EDGE,
201
+ // before any rule eval. `contained` rides alongside the signed bundle as a SIBLING
202
+ // response field (never inside the signed payload, so the bundle signature stays
203
+ // valid) and is refreshed on the `policy:changed` push, reaching the guard in ~1s.
204
+ if (contained && contained.status) {
205
+ const decision = contained.status === 'quarantined' ? 'quarantine' : 'suspend';
206
+ const reasonCode = contained.status === 'quarantined' ? 'AGENT_QUARANTINED' : 'AGENT_SUSPENDED';
207
+ return { decision, reasonCode, authorizationId: null, remaining: null, proofRef: null };
208
+ }
209
+ const { action, amount = 0, merchant = '', context = {}, cumulativeSpend = amount, now } = request;
210
+ // Operating-mode autonomy ladder (Phase 2.5b): the trust-driven posture rides as a
211
+ // SIBLING (like `contained`) and biases the edge verdict identically to the gate.
212
+ // READ_ONLY denies a value-bearing action up-front; SUPERVISED/RESTRICTED only
213
+ // ESCALATE, applied to the verdict below so a rule block/escalate still outranks it.
214
+ const modeGate = operatingModeGate(operatingMode?.mode, { amount, riskLevel: context?.riskLevel });
215
+ if (modeGate.decision === 'block') {
216
+ return { decision: 'block', reasonCode: modeGate.reasonCode, authorizationId: null, remaining: null, proofRef: null };
217
+ }
218
+ // Signed fields (action/agentDid/amount, mm:* operands) are applied LAST so an
219
+ // unsigned context key can never shadow them (spec §6.4.2) the same invariant
220
+ // the gate enforces, via the same policy-core helper.
221
+ const verdict = evaluate({
222
+ standards,
223
+ sops,
224
+ mandate,
225
+ context: applySignedLast(context, { action, agentDid, amount }),
226
+ mandateRequest: mandate
227
+ ? {
228
+ target: action,
229
+ now: now ?? new Date().toISOString(),
230
+ values: applySignedLast(context, {
231
+ 'mm:payAmount': amount,
232
+ 'mm:cumulativeSpend': cumulativeSpend,
233
+ 'mm:merchant': merchant,
234
+ }),
235
+ }
236
+ : undefined,
237
+ });
238
+ // Mode ESCALATE floor: only lifts an otherwise-PERMIT (allow or observe) to human
239
+ // review (never softens a stricter verdict) — most-restrictive-wins, mirroring the
240
+ // backend gate exactly (escalate outranks observe, so a flag never masks it).
241
+ if ((verdict.decision === 'allow' || verdict.decision === 'observe') && modeGate.decision === 'escalate') {
242
+ return { ...verdict, decision: 'escalate', reasonCode: modeGate.reasonCode };
243
+ }
244
+ return verdict;
245
+ }
246
+
247
+ /** Fetch + cache the agent's signed policy bundle (refreshed per its maxStaleness). */
248
+ async function loadBundle(force = false) {
249
+ const now = Date.now();
250
+ if (!force && _bundle && now - _bundleAt < _bundleMaxAgeMs) return _bundle;
251
+ const res = await fetch(bundleUrl);
252
+ const body = await res.json().catch(() => null);
253
+ const b = body?.data ?? body;
254
+ if (!b || (!b.mandates && !b.sops && !b.standards)) throw new Error(`invalid policy bundle from ${bundleUrl}`);
255
+ // Live containment + operating mode ride as SIBLINGS of the signed bundle (never
256
+ // inside it, so the signature stays valid); stash them on the in-memory copy.
257
+ b.contained = body?.contained ?? null;
258
+ b.operatingMode = body?.operatingMode ?? null;
259
+ _bundle = b;
260
+ _bundleAt = now;
261
+ _bundleMaxAgeMs = _durationMs(b.maxStaleness) ?? _bundleMaxAgeMs;
262
+ return b;
263
+ }
264
+
265
+ /** Map a fetched bundle into the shape evaluateLocally expects, for one action. */
266
+ function _bundleFor(b, action) {
267
+ return {
268
+ contained: b.contained ?? null,
269
+ operatingMode: b.operatingMode ?? null,
270
+ standards: (b.standards ?? []).map((s) => ({ standardKey: s.id ?? s.standardKey ?? 'standard', document: s.document })).filter((s) => s.document),
271
+ sops: (b.sops ?? []).map((s) => ({ standardKey: s.id ?? s.sopId ?? 'sop', document: s.document })).filter((s) => s.document),
272
+ mandate: ((b.mandates ?? []).find((m) => m.action === action) ?? (b.mandates ?? [])[0])?.document,
273
+ };
274
+ }
275
+
276
+ const _sha256 = (s) => 'sha256:' + crypto.createHash('sha256').update(String(s)).digest('hex');
277
+
278
+ /**
279
+ * Read the CURRENT anchored policy for this agent from its OWN Hedera topic via a public
280
+ * mirror node — no MetaMynd call (Build B / spec §5.3.1). Returns { sigDigest, seq } of the
281
+ * latest `policy-update` op, or null. Cached for `_anchorTtlMs`; monotonic on `seq`.
282
+ */
283
+ async function _currentAnchor() {
284
+ const now = Date.now();
285
+ if (_anchor && now - _anchorAt < _anchorTtlMs) return _anchor;
286
+ const m = /^did:hedera:([^:]+):[^_]+_(.+)$/.exec(agentDid);
287
+ if (!m) return _anchor;
288
+ const network = m[1];
289
+ const topicId = m[2];
290
+ const mbase = network === 'mainnet' ? 'https://mainnet.mirrornode.hedera.com' : 'https://testnet.mirrornode.hedera.com';
291
+ try {
292
+ // Newest-first: the latest `policy-update` op for this DID is the current policy. Its topic
293
+ // sequence_number is the monotonic marker (globally increasing under Hedera consensus), so a
294
+ // rollback / a mirror hiding recent updates shows a LOWER seq and is rejected. (A very busy
295
+ // topic could bury the op past one page; a per-agent topic won't — pagination is a refinement.)
296
+ const body = await fetch(`${mbase}/api/v1/topics/${topicId}/messages?limit=100&order=desc`).then((r) => (r.ok ? r.json() : null));
297
+ const hit = (body?.messages ?? [])
298
+ .map((x) => { try { return { seq: Number(x.sequence_number), op: JSON.parse(Buffer.from(x.message, 'base64').toString('utf8')) }; } catch { return null; } })
299
+ .filter((e) => e && e.op?.op === 'policy-update' && e.op.did === agentDid)
300
+ .sort((a, b) => b.seq - a.seq)[0];
301
+ if (!hit) return _anchor;
302
+ const a = { sigDigest: hit.op.sigDigest ?? null, seq: hit.seq };
303
+ if (a.seq >= _highestSeq) { _anchor = a; _anchorAt = now; _highestSeq = a.seq; }
304
+ } catch { /* mirror unreachablekeep the last known anchor */ }
305
+ return _anchor;
306
+ }
307
+
308
+ /**
309
+ * LOCAL-FIRST decision (the default). Evaluates the rule layer against the cached
310
+ * bundle with the same policy-core the gate runs — so a block/escalate is decided
311
+ * with NO network. An allowed VALUE action (amount > 0) is then sealed by the remote
312
+ * gate (two-phase hold + cumulative-spend cap + anchored evidence — the parts that
313
+ * MUST be server-side); set `sealValueActions:false` for pure offline. If the bundle
314
+ * can't be loaded, defers to the authoritative remote gate rather than blind-allow.
315
+ */
316
+ async function authorizeLocal(input) {
317
+ const { action, amount = 0 } = input;
318
+ let b;
319
+ try {
320
+ b = await loadBundle();
321
+ } catch {
322
+ return authorize(input); // no local rules authoritative remote gate
323
+ }
324
+ // Trustless currency check (Build B): trust the local bundle only if it is the LATEST one
325
+ // anchored on Hedera; otherwise defer to the authoritative remote gate (never evaluate against
326
+ // a bundle we can't prove is current — this defeats a stale/rolled-back or forged bundle).
327
+ if (verifyOnChain) {
328
+ const anchor = await _currentAnchor();
329
+ const sig = b?.proof?.signature;
330
+ if (!anchor?.sigDigest || !sig || _sha256(sig) !== anchor.sigDigest) return authorize(input);
331
+ }
332
+ const local = evaluateLocally({ ..._bundleFor(b, action), request: input });
333
+ // allow/observe both PERMIT; block/escalate/contain are decided locally with no network.
334
+ const permits = local.decision === 'allow' || local.decision === 'observe';
335
+ if (!permits) return local; // denied/escalated locally, no network
336
+ if (amount > 0 && sealValueActions) return authorize(input); // seal value action remotely (allow or observe)
337
+ return local; // non-value permit — local is sufficient
338
+ }
339
+
340
+ /** Mode-aware decision used by guardTool: 'local' (default) or 'remote'. */
341
+ async function check(input) {
342
+ return mode === 'remote' ? authorize(input) : authorizeLocal(input);
343
+ }
344
+
345
+ /**
346
+ * Watch for policy changes over Server-Sent Events (Build C) — ZERO-dependency (plain fetch,
347
+ * no socket client). On a `policy:changed` push the guard invalidates its bundle + on-chain
348
+ * anchor cache, so the NEXT call re-fetches (and re-verifies) the new rules reaching the edge
349
+ * in ~1s instead of within maxStaleness. Push is an optimization: a dropped stream still leaves
350
+ * staleness (A) + the on-chain check (B) as the floor. Auto-reconnects with a short backoff.
351
+ * Returns a handle with `.close()`. Optional `onChange(payload)` callback.
352
+ */
353
+ function watchPolicy(onChange) {
354
+ let stopped = false;
355
+ let controller = null;
356
+ (async () => {
357
+ while (!stopped) {
358
+ try {
359
+ controller = new AbortController();
360
+ const res = await fetch(`${base}/policy/events/${encodeURIComponent(agentDid)}`, {
361
+ headers: { Accept: 'text/event-stream' },
362
+ signal: controller.signal,
363
+ });
364
+ if (!res.ok || !res.body) throw new Error(`policy events ${res.status}`);
365
+ const reader = res.body.getReader();
366
+ const dec = new TextDecoder();
367
+ let buf = '';
368
+ while (!stopped) {
369
+ const { value, done } = await reader.read();
370
+ if (done) break;
371
+ buf += dec.decode(value, { stream: true });
372
+ let i;
373
+ while ((i = buf.indexOf('\n\n')) >= 0) {
374
+ const frame = buf.slice(0, i);
375
+ buf = buf.slice(i + 2);
376
+ if (!/^event:\s*policy:changed/m.test(frame)) continue; // ignore comments/heartbeats
377
+ _bundle = null; _bundleAt = 0; _anchor = null; _anchorAt = 0; // invalidate → next call re-fetches
378
+ if (onChange) {
379
+ const dline = frame.split('\n').find((l) => l.startsWith('data:'));
380
+ try { onChange(dline ? JSON.parse(dline.slice(5).trim()) : {}); } catch { /* ignore */ }
381
+ }
382
+ }
383
+ }
384
+ } catch {
385
+ /* stream dropped reconnect */
386
+ }
387
+ if (!stopped) await new Promise((r) => setTimeout(r, 2000));
388
+ }
389
+ })();
390
+ return { close() { stopped = true; try { controller?.abort(); } catch { /* ignore */ } } };
391
+ }
392
+
393
+ /**
394
+ * Like guardTool, but evaluates LOCALLY against a policy bundle instead of calling
395
+ * the gate cooperative-mode, low-latency governance (spec §9.2). Fails CLOSED:
396
+ * any error during local evaluation throws GovernanceBlocked, never allows.
397
+ *
398
+ * @param {string} action
399
+ * @param {(args:any, decision:any)=>any} handler
400
+ * @param {(args:any)=>{amount?:number,merchant?:string,context?:object}} mapArgs
401
+ * @param {object|((args:any)=>object|Promise<object>)} getBundle { standards, sops, mandate } (or a resolver)
402
+ */
403
+ function guardToolLocal(action, handler, mapArgs = (a) => a, getBundle = {}, toolOpts = {}) {
404
+ const adapter = toolOpts.executionAdapter ?? defaultExecutionAdapter;
405
+ return async (args) => {
406
+ let decision;
407
+ try {
408
+ const { amount, merchant, context } = mapArgs(args);
409
+ const bundle = typeof getBundle === 'function' ? await getBundle(args) : getBundle;
410
+ decision = evaluateLocally({ ...bundle, request: { action, amount, merchant, context } });
411
+ } catch (err) {
412
+ decision = { decision: 'block', reasonCode: 'LOCAL_EVAL_ERROR', error: String(err?.message ?? err) };
413
+ }
414
+ // allow/observe both PERMIT execution; observe is permit-but-flag (SAFR §11) — the
415
+ // handler receives the `decision` so a caller can surface/log the observation.
416
+ if (decision.decision !== 'allow' && decision.decision !== 'observe') {
417
+ const err = new Error(`AgentSafe ${decision.decision.toUpperCase()} "${action}": ${decision.reasonCode}`);
418
+ err.name = 'GovernanceBlocked';
419
+ err.governance = decision;
420
+ throw err;
421
+ }
422
+ if (decision.decision === 'observe') {
423
+ console.warn(`[agentsafe] OBSERVE "${action}": ${decision.reasonCode} — permitted under monitoring`);
424
+ }
425
+ // ExecutionAdapter seam (§19): the adapter runs the real handler (proceed) or substitutes it.
426
+ return adapter({ action, args, decision, proceed: () => handler(args, decision) });
427
+ };
428
+ }
429
+
430
+ /**
431
+ * Wrap a tool handler so it is gated. Returns a function you register with your agent
432
+ * framework in place of the raw handler. On a non-allow decision it THROWS a
433
+ * GovernanceBlocked error (with `.governance`) so the agent surfaces the reason and
434
+ * does NOT perform the action.
435
+ *
436
+ * @param {string} action the governed action (must match a mandate scope, e.g. 'flight-purchase')
437
+ * @param {(args:any, decision:any)=>any} handler the real tool implementation
438
+ * @param {(args:any)=>{amount?:number,currency?:string,merchant?:string,context?:object}} mapArgs
439
+ * maps the tool's call args to the gate inputs (amount/merchant + the context the rules need)
440
+ */
441
+ function guardTool(action, handler, mapArgs = (a) => a, toolOpts = {}) {
442
+ const adapter = toolOpts.executionAdapter ?? defaultExecutionAdapter;
443
+ return async (args) => {
444
+ const decision = await check({ action, ...mapArgs(args) });
445
+ // allow/observe both PERMIT execution; observe is permit-but-flag (SAFR §11) — the
446
+ // handler receives the `decision` so a caller can surface/log the observation.
447
+ if (decision.decision !== 'allow' && decision.decision !== 'observe') {
448
+ const err = new Error(`AgentSafe ${decision.decision.toUpperCase()} "${action}": ${decision.reasonCode}`);
449
+ err.name = 'GovernanceBlocked';
450
+ err.governance = decision;
451
+ throw err;
452
+ }
453
+ if (decision.decision === 'observe') {
454
+ console.warn(`[agentsafe] OBSERVE "${action}": ${decision.reasonCode} permitted under monitoring`);
455
+ }
456
+ // ExecutionAdapter seam (§19): the adapter runs the real handler (proceed) or substitutes it.
457
+ return adapter({ action, args, decision, proceed: () => handler(args, decision) });
458
+ };
459
+ }
460
+
461
+ /**
462
+ * Mutual-handshake INITIATOR (spec §8.2). Prove control of this agent's DID to a
463
+ * Service and verify the Service controls its DID — no issuer calls (keys are in
464
+ * the DIDs, §4.1.2). Returns { hello, prove } to drive the exchange:
465
+ * const hs = guard.handshake();
466
+ * const { nonceA, message } = hs.hello(); // → send HELLO to the Service
467
+ * const { sigA, handshakeId } = hs.prove({ nonceA, challenge }); // verifies the Service, → send PROVE
468
+ * `prove` throws HandshakeFailed if the Service's CHALLENGE does not verify.
469
+ */
470
+ function handshake() {
471
+ return {
472
+ hello() {
473
+ const nonceA = crypto.randomUUID();
474
+ return { nonceA, message: { fromDid: agentDid, nonceA, protoVersion: '0.4' } };
475
+ },
476
+ prove({ nonceA, challenge } = {}) {
477
+ const { toDid, nonceB, sigB, handshakeId } = challenge ?? {};
478
+ if (!toDid || !nonceB || !sigB) throw new Error('malformed CHALLENGE');
479
+ if (!verifyDidSignature(toDid, nonceA, sigB)) {
480
+ const e = new Error('Service failed to prove control of its DID');
481
+ e.name = 'HandshakeFailed';
482
+ throw e;
483
+ }
484
+ return { handshakeId, sigA: sign(nonceB), remoteDid: toDid };
485
+ },
486
+ };
487
+ }
488
+
489
+ /**
490
+ * Read a Service's 402 PaymentRequirements and prepare to pay (spec §7a.1 step 5).
491
+ * Refuses a 402 that is NOT bound to a MAGP authorization (§7a.2.1) — the agent
492
+ * must never pay for an ungoverned request — and refuses one whose authorization
493
+ * does not match the `authorizationId` the agent holds from its own authorize
494
+ * (allow) step, so a swapped 402 can't redirect the payment.
495
+ *
496
+ * @param {object} requirements the x402 PaymentRequirements from the 402 response
497
+ * @param {string} [expectedAuthorizationId] the authorizationId from guard.authorize()
498
+ * @returns {{authorizationId:string, amountMinor:string, payTo:string, asset:string, network:string, resource:string}}
499
+ */
500
+ function preparePayment(requirements, expectedAuthorizationId) {
501
+ const a = requirements?.accepts?.[0];
502
+ if (!a?.extra?.magpAuthorizationId) {
503
+ const e = new Error('402 is not bound to a MAGP authorization — refusing to pay');
504
+ e.name = 'UnboundPayment';
505
+ throw e;
506
+ }
507
+ if (expectedAuthorizationId && a.extra.magpAuthorizationId !== expectedAuthorizationId) {
508
+ const e = new Error('402 authorization does not match the agent authorization');
509
+ e.name = 'AuthorizationMismatch';
510
+ throw e;
511
+ }
512
+ // Pay exactly the authorized amount; the binding check guards against overpay.
513
+ checkSettlementBinding(requirements, { authorizationId: a.extra.magpAuthorizationId, paidAmountMinor: a.maxAmountRequired });
514
+ return {
515
+ authorizationId: a.extra.magpAuthorizationId,
516
+ amountMinor: a.maxAmountRequired,
517
+ payTo: a.payTo,
518
+ asset: a.asset,
519
+ network: a.network,
520
+ resource: a.resource,
521
+ };
522
+ }
523
+
524
+ /**
525
+ * Poll the outcome of an escalated action (spec §9a). When authorize() returns
526
+ * `escalate`, its `escalationId` parks the action for the Owner to approve/deny.
527
+ * The agent polls this until the status is terminal; on `approved` the returned
528
+ * `authorizationId` carries into the §7a capture/pay flow. Fails soft (never throws).
529
+ * @returns {Promise<{status:string,reasonCode:string,authorizationId:string|null,expiresAt:string|null}>}
530
+ */
531
+ async function escalationStatus(escalationId) {
532
+ try {
533
+ const res = await fetch(`${base}/policy/escalations/${encodeURIComponent(escalationId)}/status`);
534
+ const body = await res.json().catch(() => null);
535
+ return body?.data ?? { status: 'unknown', reasonCode: `GATE_HTTP_${res.status}`, authorizationId: null, expiresAt: null };
536
+ } catch (err) {
537
+ return { status: 'unreachable', reasonCode: 'GATE_UNREACHABLE', authorizationId: null, expiresAt: null, error: String(err?.message ?? err) };
538
+ }
539
+ }
540
+
541
+ /**
542
+ * Effect-safety runtime (E2): report the external-effect lifecycle so an AMBIGUOUS
543
+ * connector outcome never becomes a blind capture/void. Call effectDispatching() just
544
+ * before the side-effecting call, effectDispatched() when the connector accepts, and —
545
+ * critically — effectUnknown() when the response is lost/timed out (instead of guessing).
546
+ * Once UNKNOWN, capture/void are refused by the gate until the effect is reconciled.
547
+ */
548
+ async function _effectPost(authorizationId, kind, payload = {}) {
549
+ try {
550
+ const res = await fetch(`${base}/policy/mandate/authorize/${encodeURIComponent(authorizationId)}/effect/${kind}`, {
551
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
552
+ });
553
+ const body = await res.json().catch(() => null);
554
+ return body?.data ?? { ok: false, reasonCode: `GATE_HTTP_${res.status}` };
555
+ } catch (err) {
556
+ return { ok: false, reasonCode: 'GATE_UNREACHABLE', error: String(err?.message ?? err) };
557
+ }
558
+ }
559
+ const effectDispatching = (authorizationId) => _effectPost(authorizationId, 'dispatching');
560
+ const effectDispatched = (authorizationId, remoteRef) => _effectPost(authorizationId, 'dispatched', { remoteRef });
561
+ const effectUnknown = (authorizationId, reason) => _effectPost(authorizationId, 'unknown', { reason });
562
+ async function effectStatus(authorizationId) {
563
+ try {
564
+ const res = await fetch(`${base}/policy/mandate/authorize/${encodeURIComponent(authorizationId)}/effect`);
565
+ const body = await res.json().catch(() => null);
566
+ return body?.data ?? { effectState: null, reasonCode: `GATE_HTTP_${res.status}` };
567
+ } catch (err) {
568
+ return { effectState: 'unreachable', reasonCode: 'GATE_UNREACHABLE', error: String(err?.message ?? err) };
569
+ }
570
+ }
571
+
572
+ /**
573
+ * BYOK proof-of-possession (onboarding proposal #4). For an agent that brought its OWN key,
574
+ * MetaMynd issued the identity with a one-time `challenge` and left the key UNVERIFIED — the gate
575
+ * blocks it with AGENT_KEY_UNVERIFIED until control is proven. This signs the challenge with the
576
+ * agent's private key (the same Ed25519 the gate checks) and submits it to verify-key, flipping
577
+ * the key to verified. A one-time SETUP step: verify-key is owner-authenticated, so pass the owner
578
+ * `token` you onboarded with. `ref` defaults to the identityId; `challenge` comes from the config.
579
+ *
580
+ * @param {{ ref: string, challenge: string, token?: string }} p
581
+ * @returns {Promise<{ verified: boolean, did?: string }>}
582
+ */
583
+ async function verifyKey({ ref, challenge, token } = {}) {
584
+ if (!ref || !challenge) throw new Error('verifyKey requires { ref, challenge } (from the BYOK onboarding config)');
585
+ const res = await fetch(`${base}/agent-identity/${encodeURIComponent(ref)}/verify-key`, {
586
+ method: 'POST',
587
+ headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
588
+ body: JSON.stringify({ signature: sign(challenge) }),
589
+ });
590
+ const body = await res.json().catch(() => null);
591
+ if (!res.ok) {
592
+ const e = new Error(body?.message || `verify-key HTTP ${res.status}`);
593
+ e.name = 'KeyVerificationFailed';
594
+ throw e;
595
+ }
596
+ return body?.data ?? { verified: true };
597
+ }
598
+
599
+ /** Sign a BYOK challenge with the agent's key (hex) — for integrators who submit verify-key themselves. */
600
+ function signChallenge(challenge) {
601
+ if (!challenge) throw new Error('signChallenge requires the challenge nonce');
602
+ return sign(challenge);
603
+ }
604
+
605
+ return { authorize, authorizeLocal, check, loadBundle, policyAnchor: _currentAnchor, watchPolicy, mode, verifyOnChain, buildSignedRequest, capture, guardTool, evaluateLocally, guardToolLocal, handshake, preparePayment, escalationStatus, effectDispatching, effectDispatched, effectUnknown, effectStatus, verifyKey, signChallenge, agentDid, executionAdapter: defaultExecutionAdapter };
606
+ }