@metamynd/agentsafe-guard 0.3.0 → 0.4.0

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