@metamynd/agentsafe-guard 0.1.0 → 0.1.2

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,316 +1,316 @@
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
- /**
59
- * Build a signed authorize request (spec §7.2/§7.3) WITHOUT sending it — the object an
60
- * agent presents to a counterparty (e.g. an MCP) so the counterparty can re-verify the
61
- * agent's authorization trustlessly against the agent's policy bundle (§9.3). Same shape
62
- * `authorize()` posts to the gate; a fresh nonce each call.
63
- */
64
- function buildSignedRequest({ action, amount = 0, currency = 'USD', merchant = '', context = {} }) {
65
- const nonce = crypto.randomUUID();
66
- const issuedAt = new Date().toISOString();
67
- const message = buildAuthMessage({ agentDid, action, amount, currency, merchant, nonce, issuedAt });
68
- return { agentDid, action, amount, currency, merchant, itinerary: context, nonce, issuedAt, signature: sign(message) };
69
- }
70
-
71
- /**
72
- * Ask the gate whether an action is authorized. Never throws on a policy decision —
73
- * returns { decision:'allow'|'block'|'escalate', reasonCode, authorizationId, remaining }.
74
- * A network/gate failure returns a fail-CLOSED block so the agent can't proceed blind.
75
- */
76
- async function authorize({ action, amount = 0, currency = 'USD', merchant = '', context = {} }) {
77
- const nonce = crypto.randomUUID();
78
- const issuedAt = new Date().toISOString();
79
- // Build the canonical signed message with policy-core so the guard and the
80
- // backend gate produce byte-identical input to Ed25519 (spec §7.3).
81
- const message = buildAuthMessage({ agentDid, action, amount, currency, merchant, nonce, issuedAt });
82
- try {
83
- const res = await fetch(`${base}/policy/mandate/authorize`, {
84
- method: 'POST',
85
- headers: { 'Content-Type': 'application/json' },
86
- body: JSON.stringify({ agentDid, action, amount, currency, merchant, itinerary: context, nonce, issuedAt, signature: sign(message) }),
87
- });
88
- const body = await res.json().catch(() => null);
89
- return body?.data ?? { decision: 'block', reasonCode: `GATE_HTTP_${res.status}` };
90
- } catch (err) {
91
- return { decision: 'block', reasonCode: 'GATE_UNREACHABLE', error: String(err?.message ?? err) };
92
- }
93
- }
94
-
95
- /**
96
- * Settle an approved hold (two-phase). Call after the real action succeeds with the
97
- * amount actually charged (≤ the authorized amount). Pass the x402 `settlementTxHash`
98
- * to record the on-chain payment proof against the capture (§7a.3.2). Optional —
99
- * skip for non-payment tools.
100
- */
101
- async function capture(authorizationId, amountCharged, bookingRef, settlementTxHash) {
102
- const res = await fetch(`${base}/policy/mandate/authorize/${authorizationId}/capture`, {
103
- method: 'POST', headers: { 'Content-Type': 'application/json' },
104
- body: JSON.stringify({ amountCharged, bookingRef, settlementTxHash }),
105
- });
106
- return res.json().catch(() => ({}));
107
- }
108
-
109
- /**
110
- * Evaluate a signed policy bundle LOCALLY — no network — using the same
111
- * deterministic policy-core the gate runs (spec §9.2 cooperative mode). Given the
112
- * same (rule packs, mandate, request), this returns the identical verdict the
113
- * gate would. The stateful parts the gate owns (nonce/replay, atomic spend-cap
114
- * reservation, evidence anchoring) are NOT done here — this is the local
115
- * allow/block/escalate pre-check, so `authorizationId`/`remaining` are null.
116
- *
117
- * @param {object} p
118
- * @param {Array<{standardKey:string,document:object}>} [p.standards] enforced Standards bound to the agent
119
- * @param {Array<{standardKey:string,document:object}>} [p.sops] active SOPs assigned to the agent
120
- * @param {object} [p.mandate] the ODRL mandate document (omit to skip the mandate layer)
121
- * @param {{action:string,amount?:number,merchant?:string,context?:object,cumulativeSpend?:number,now?:string}} p.request
122
- * @returns {{decision:'allow'|'block'|'escalate',reasonCode:string|null,authorizationId:null,remaining:null,proofRef:null}}
123
- */
124
- function evaluateLocally({ standards = [], sops = [], mandate, request }) {
125
- const { action, amount = 0, merchant = '', context = {}, cumulativeSpend = amount, now } = request;
126
- // Signed fields (action/agentDid/amount, mm:* operands) are applied LAST so an
127
- // unsigned context key can never shadow them (spec §6.4.2) — the same invariant
128
- // the gate enforces, via the same policy-core helper.
129
- return evaluate({
130
- standards,
131
- sops,
132
- mandate,
133
- context: applySignedLast(context, { action, agentDid, amount }),
134
- mandateRequest: mandate
135
- ? {
136
- target: action,
137
- now: now ?? new Date().toISOString(),
138
- values: applySignedLast(context, {
139
- 'mm:payAmount': amount,
140
- 'mm:cumulativeSpend': cumulativeSpend,
141
- 'mm:merchant': merchant,
142
- }),
143
- }
144
- : undefined,
145
- });
146
- }
147
-
148
- /**
149
- * Like guardTool, but evaluates LOCALLY against a policy bundle instead of calling
150
- * the gate — cooperative-mode, low-latency governance (spec §9.2). Fails CLOSED:
151
- * any error during local evaluation throws GovernanceBlocked, never allows.
152
- *
153
- * @param {string} action
154
- * @param {(args:any, decision:any)=>any} handler
155
- * @param {(args:any)=>{amount?:number,merchant?:string,context?:object}} mapArgs
156
- * @param {object|((args:any)=>object|Promise<object>)} getBundle { standards, sops, mandate } (or a resolver)
157
- */
158
- function guardToolLocal(action, handler, mapArgs = (a) => a, getBundle = {}) {
159
- return async (args) => {
160
- let decision;
161
- try {
162
- const { amount, merchant, context } = mapArgs(args);
163
- const bundle = typeof getBundle === 'function' ? await getBundle(args) : getBundle;
164
- decision = evaluateLocally({ ...bundle, request: { action, amount, merchant, context } });
165
- } catch (err) {
166
- decision = { decision: 'block', reasonCode: 'LOCAL_EVAL_ERROR', error: String(err?.message ?? err) };
167
- }
168
- if (decision.decision !== 'allow') {
169
- const err = new Error(`AgentSafe ${decision.decision.toUpperCase()} "${action}": ${decision.reasonCode}`);
170
- err.name = 'GovernanceBlocked';
171
- err.governance = decision;
172
- throw err;
173
- }
174
- return handler(args, decision);
175
- };
176
- }
177
-
178
- /**
179
- * Wrap a tool handler so it is gated. Returns a function you register with your agent
180
- * framework in place of the raw handler. On a non-allow decision it THROWS a
181
- * GovernanceBlocked error (with `.governance`) so the agent surfaces the reason and
182
- * does NOT perform the action.
183
- *
184
- * @param {string} action the governed action (must match a mandate scope, e.g. 'flight-purchase')
185
- * @param {(args:any, decision:any)=>any} handler the real tool implementation
186
- * @param {(args:any)=>{amount?:number,currency?:string,merchant?:string,context?:object}} mapArgs
187
- * maps the tool's call args to the gate inputs (amount/merchant + the context the rules need)
188
- */
189
- function guardTool(action, handler, mapArgs = (a) => a) {
190
- return async (args) => {
191
- const decision = await authorize({ action, ...mapArgs(args) });
192
- if (decision.decision !== 'allow') {
193
- const err = new Error(`AgentSafe ${decision.decision.toUpperCase()} "${action}": ${decision.reasonCode}`);
194
- err.name = 'GovernanceBlocked';
195
- err.governance = decision;
196
- throw err;
197
- }
198
- return handler(args, decision);
199
- };
200
- }
201
-
202
- /**
203
- * Mutual-handshake INITIATOR (spec §8.2). Prove control of this agent's DID to a
204
- * Service and verify the Service controls its DID — no issuer calls (keys are in
205
- * the DIDs, §4.1.2). Returns { hello, prove } to drive the exchange:
206
- * const hs = guard.handshake();
207
- * const { nonceA, message } = hs.hello(); // → send HELLO to the Service
208
- * const { sigA, handshakeId } = hs.prove({ nonceA, challenge }); // verifies the Service, → send PROVE
209
- * `prove` throws HandshakeFailed if the Service's CHALLENGE does not verify.
210
- */
211
- function handshake() {
212
- return {
213
- hello() {
214
- const nonceA = crypto.randomUUID();
215
- return { nonceA, message: { fromDid: agentDid, nonceA, protoVersion: '0.4' } };
216
- },
217
- prove({ nonceA, challenge } = {}) {
218
- const { toDid, nonceB, sigB, handshakeId } = challenge ?? {};
219
- if (!toDid || !nonceB || !sigB) throw new Error('malformed CHALLENGE');
220
- if (!verifyDidSignature(toDid, nonceA, sigB)) {
221
- const e = new Error('Service failed to prove control of its DID');
222
- e.name = 'HandshakeFailed';
223
- throw e;
224
- }
225
- return { handshakeId, sigA: sign(nonceB), remoteDid: toDid };
226
- },
227
- };
228
- }
229
-
230
- /**
231
- * Read a Service's 402 PaymentRequirements and prepare to pay (spec §7a.1 step 5).
232
- * Refuses a 402 that is NOT bound to a MAGP authorization (§7a.2.1) — the agent
233
- * must never pay for an ungoverned request — and refuses one whose authorization
234
- * does not match the `authorizationId` the agent holds from its own authorize
235
- * (allow) step, so a swapped 402 can't redirect the payment.
236
- *
237
- * @param {object} requirements the x402 PaymentRequirements from the 402 response
238
- * @param {string} [expectedAuthorizationId] the authorizationId from guard.authorize()
239
- * @returns {{authorizationId:string, amountMinor:string, payTo:string, asset:string, network:string, resource:string}}
240
- */
241
- function preparePayment(requirements, expectedAuthorizationId) {
242
- const a = requirements?.accepts?.[0];
243
- if (!a?.extra?.magpAuthorizationId) {
244
- const e = new Error('402 is not bound to a MAGP authorization — refusing to pay');
245
- e.name = 'UnboundPayment';
246
- throw e;
247
- }
248
- if (expectedAuthorizationId && a.extra.magpAuthorizationId !== expectedAuthorizationId) {
249
- const e = new Error('402 authorization does not match the agent authorization');
250
- e.name = 'AuthorizationMismatch';
251
- throw e;
252
- }
253
- // Pay exactly the authorized amount; the binding check guards against overpay.
254
- checkSettlementBinding(requirements, { authorizationId: a.extra.magpAuthorizationId, paidAmountMinor: a.maxAmountRequired });
255
- return {
256
- authorizationId: a.extra.magpAuthorizationId,
257
- amountMinor: a.maxAmountRequired,
258
- payTo: a.payTo,
259
- asset: a.asset,
260
- network: a.network,
261
- resource: a.resource,
262
- };
263
- }
264
-
265
- /**
266
- * Poll the outcome of an escalated action (spec §9a). When authorize() returns
267
- * `escalate`, its `escalationId` parks the action for the Owner to approve/deny.
268
- * The agent polls this until the status is terminal; on `approved` the returned
269
- * `authorizationId` carries into the §7a capture/pay flow. Fails soft (never throws).
270
- * @returns {Promise<{status:string,reasonCode:string,authorizationId:string|null,expiresAt:string|null}>}
271
- */
272
- async function escalationStatus(escalationId) {
273
- try {
274
- const res = await fetch(`${base}/policy/escalations/${encodeURIComponent(escalationId)}/status`);
275
- const body = await res.json().catch(() => null);
276
- return body?.data ?? { status: 'unknown', reasonCode: `GATE_HTTP_${res.status}`, authorizationId: null, expiresAt: null };
277
- } catch (err) {
278
- return { status: 'unreachable', reasonCode: 'GATE_UNREACHABLE', authorizationId: null, expiresAt: null, error: String(err?.message ?? err) };
279
- }
280
- }
281
-
282
- /**
283
- * BYOK proof-of-possession (onboarding proposal #4). For an agent that brought its OWN key,
284
- * MetaMynd issued the identity with a one-time `challenge` and left the key UNVERIFIED — the gate
285
- * blocks it with AGENT_KEY_UNVERIFIED until control is proven. This signs the challenge with the
286
- * agent's private key (the same Ed25519 the gate checks) and submits it to verify-key, flipping
287
- * the key to verified. A one-time SETUP step: verify-key is owner-authenticated, so pass the owner
288
- * `token` you onboarded with. `ref` defaults to the identityId; `challenge` comes from the config.
289
- *
290
- * @param {{ ref: string, challenge: string, token?: string }} p
291
- * @returns {Promise<{ verified: boolean, did?: string }>}
292
- */
293
- async function verifyKey({ ref, challenge, token } = {}) {
294
- if (!ref || !challenge) throw new Error('verifyKey requires { ref, challenge } (from the BYOK onboarding config)');
295
- const res = await fetch(`${base}/agent-identity/${encodeURIComponent(ref)}/verify-key`, {
296
- method: 'POST',
297
- headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
298
- body: JSON.stringify({ signature: sign(challenge) }),
299
- });
300
- const body = await res.json().catch(() => null);
301
- if (!res.ok) {
302
- const e = new Error(body?.message || `verify-key HTTP ${res.status}`);
303
- e.name = 'KeyVerificationFailed';
304
- throw e;
305
- }
306
- return body?.data ?? { verified: true };
307
- }
308
-
309
- /** Sign a BYOK challenge with the agent's key (hex) — for integrators who submit verify-key themselves. */
310
- function signChallenge(challenge) {
311
- if (!challenge) throw new Error('signChallenge requires the challenge nonce');
312
- return sign(challenge);
313
- }
314
-
315
- return { authorize, buildSignedRequest, capture, guardTool, evaluateLocally, guardToolLocal, handshake, preparePayment, escalationStatus, verifyKey, signChallenge, agentDid };
316
- }
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
+ /**
59
+ * Build a signed authorize request (spec §7.2/§7.3) WITHOUT sending it — the object an
60
+ * agent presents to a counterparty (e.g. an MCP) so the counterparty can re-verify the
61
+ * agent's authorization trustlessly against the agent's policy bundle (§9.3). Same shape
62
+ * `authorize()` posts to the gate; a fresh nonce each call.
63
+ */
64
+ function buildSignedRequest({ action, amount = 0, currency = 'USD', merchant = '', context = {} }) {
65
+ const nonce = crypto.randomUUID();
66
+ const issuedAt = new Date().toISOString();
67
+ const message = buildAuthMessage({ agentDid, action, amount, currency, merchant, nonce, issuedAt });
68
+ return { agentDid, action, amount, currency, merchant, itinerary: context, nonce, issuedAt, signature: sign(message) };
69
+ }
70
+
71
+ /**
72
+ * Ask the gate whether an action is authorized. Never throws on a policy decision —
73
+ * returns { decision:'allow'|'block'|'escalate', reasonCode, authorizationId, remaining }.
74
+ * A network/gate failure returns a fail-CLOSED block so the agent can't proceed blind.
75
+ */
76
+ async function authorize({ action, amount = 0, currency = 'USD', merchant = '', context = {} }) {
77
+ const nonce = crypto.randomUUID();
78
+ const issuedAt = new Date().toISOString();
79
+ // Build the canonical signed message with policy-core so the guard and the
80
+ // backend gate produce byte-identical input to Ed25519 (spec §7.3).
81
+ const message = buildAuthMessage({ agentDid, action, amount, currency, merchant, nonce, issuedAt });
82
+ try {
83
+ const res = await fetch(`${base}/policy/mandate/authorize`, {
84
+ method: 'POST',
85
+ headers: { 'Content-Type': 'application/json' },
86
+ body: JSON.stringify({ agentDid, action, amount, currency, merchant, itinerary: context, nonce, issuedAt, signature: sign(message) }),
87
+ });
88
+ const body = await res.json().catch(() => null);
89
+ return body?.data ?? { decision: 'block', reasonCode: `GATE_HTTP_${res.status}` };
90
+ } catch (err) {
91
+ return { decision: 'block', reasonCode: 'GATE_UNREACHABLE', error: String(err?.message ?? err) };
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Settle an approved hold (two-phase). Call after the real action succeeds with the
97
+ * amount actually charged (≤ the authorized amount). Pass the x402 `settlementTxHash`
98
+ * to record the on-chain payment proof against the capture (§7a.3.2). Optional —
99
+ * skip for non-payment tools.
100
+ */
101
+ async function capture(authorizationId, amountCharged, bookingRef, settlementTxHash) {
102
+ const res = await fetch(`${base}/policy/mandate/authorize/${authorizationId}/capture`, {
103
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
104
+ body: JSON.stringify({ amountCharged, bookingRef, settlementTxHash }),
105
+ });
106
+ return res.json().catch(() => ({}));
107
+ }
108
+
109
+ /**
110
+ * Evaluate a signed policy bundle LOCALLY — no network — using the same
111
+ * deterministic policy-core the gate runs (spec §9.2 cooperative mode). Given the
112
+ * same (rule packs, mandate, request), this returns the identical verdict the
113
+ * gate would. The stateful parts the gate owns (nonce/replay, atomic spend-cap
114
+ * reservation, evidence anchoring) are NOT done here — this is the local
115
+ * allow/block/escalate pre-check, so `authorizationId`/`remaining` are null.
116
+ *
117
+ * @param {object} p
118
+ * @param {Array<{standardKey:string,document:object}>} [p.standards] enforced Standards bound to the agent
119
+ * @param {Array<{standardKey:string,document:object}>} [p.sops] active SOPs assigned to the agent
120
+ * @param {object} [p.mandate] the ODRL mandate document (omit to skip the mandate layer)
121
+ * @param {{action:string,amount?:number,merchant?:string,context?:object,cumulativeSpend?:number,now?:string}} p.request
122
+ * @returns {{decision:'allow'|'block'|'escalate',reasonCode:string|null,authorizationId:null,remaining:null,proofRef:null}}
123
+ */
124
+ function evaluateLocally({ standards = [], sops = [], mandate, request }) {
125
+ const { action, amount = 0, merchant = '', context = {}, cumulativeSpend = amount, now } = request;
126
+ // Signed fields (action/agentDid/amount, mm:* operands) are applied LAST so an
127
+ // unsigned context key can never shadow them (spec §6.4.2) — the same invariant
128
+ // the gate enforces, via the same policy-core helper.
129
+ return evaluate({
130
+ standards,
131
+ sops,
132
+ mandate,
133
+ context: applySignedLast(context, { action, agentDid, amount }),
134
+ mandateRequest: mandate
135
+ ? {
136
+ target: action,
137
+ now: now ?? new Date().toISOString(),
138
+ values: applySignedLast(context, {
139
+ 'mm:payAmount': amount,
140
+ 'mm:cumulativeSpend': cumulativeSpend,
141
+ 'mm:merchant': merchant,
142
+ }),
143
+ }
144
+ : undefined,
145
+ });
146
+ }
147
+
148
+ /**
149
+ * Like guardTool, but evaluates LOCALLY against a policy bundle instead of calling
150
+ * the gate — cooperative-mode, low-latency governance (spec §9.2). Fails CLOSED:
151
+ * any error during local evaluation throws GovernanceBlocked, never allows.
152
+ *
153
+ * @param {string} action
154
+ * @param {(args:any, decision:any)=>any} handler
155
+ * @param {(args:any)=>{amount?:number,merchant?:string,context?:object}} mapArgs
156
+ * @param {object|((args:any)=>object|Promise<object>)} getBundle { standards, sops, mandate } (or a resolver)
157
+ */
158
+ function guardToolLocal(action, handler, mapArgs = (a) => a, getBundle = {}) {
159
+ return async (args) => {
160
+ let decision;
161
+ try {
162
+ const { amount, merchant, context } = mapArgs(args);
163
+ const bundle = typeof getBundle === 'function' ? await getBundle(args) : getBundle;
164
+ decision = evaluateLocally({ ...bundle, request: { action, amount, merchant, context } });
165
+ } catch (err) {
166
+ decision = { decision: 'block', reasonCode: 'LOCAL_EVAL_ERROR', error: String(err?.message ?? err) };
167
+ }
168
+ if (decision.decision !== 'allow') {
169
+ const err = new Error(`AgentSafe ${decision.decision.toUpperCase()} "${action}": ${decision.reasonCode}`);
170
+ err.name = 'GovernanceBlocked';
171
+ err.governance = decision;
172
+ throw err;
173
+ }
174
+ return handler(args, decision);
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Wrap a tool handler so it is gated. Returns a function you register with your agent
180
+ * framework in place of the raw handler. On a non-allow decision it THROWS a
181
+ * GovernanceBlocked error (with `.governance`) so the agent surfaces the reason and
182
+ * does NOT perform the action.
183
+ *
184
+ * @param {string} action the governed action (must match a mandate scope, e.g. 'flight-purchase')
185
+ * @param {(args:any, decision:any)=>any} handler the real tool implementation
186
+ * @param {(args:any)=>{amount?:number,currency?:string,merchant?:string,context?:object}} mapArgs
187
+ * maps the tool's call args to the gate inputs (amount/merchant + the context the rules need)
188
+ */
189
+ function guardTool(action, handler, mapArgs = (a) => a) {
190
+ return async (args) => {
191
+ const decision = await authorize({ action, ...mapArgs(args) });
192
+ if (decision.decision !== 'allow') {
193
+ const err = new Error(`AgentSafe ${decision.decision.toUpperCase()} "${action}": ${decision.reasonCode}`);
194
+ err.name = 'GovernanceBlocked';
195
+ err.governance = decision;
196
+ throw err;
197
+ }
198
+ return handler(args, decision);
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Mutual-handshake INITIATOR (spec §8.2). Prove control of this agent's DID to a
204
+ * Service and verify the Service controls its DID — no issuer calls (keys are in
205
+ * the DIDs, §4.1.2). Returns { hello, prove } to drive the exchange:
206
+ * const hs = guard.handshake();
207
+ * const { nonceA, message } = hs.hello(); // → send HELLO to the Service
208
+ * const { sigA, handshakeId } = hs.prove({ nonceA, challenge }); // verifies the Service, → send PROVE
209
+ * `prove` throws HandshakeFailed if the Service's CHALLENGE does not verify.
210
+ */
211
+ function handshake() {
212
+ return {
213
+ hello() {
214
+ const nonceA = crypto.randomUUID();
215
+ return { nonceA, message: { fromDid: agentDid, nonceA, protoVersion: '0.4' } };
216
+ },
217
+ prove({ nonceA, challenge } = {}) {
218
+ const { toDid, nonceB, sigB, handshakeId } = challenge ?? {};
219
+ if (!toDid || !nonceB || !sigB) throw new Error('malformed CHALLENGE');
220
+ if (!verifyDidSignature(toDid, nonceA, sigB)) {
221
+ const e = new Error('Service failed to prove control of its DID');
222
+ e.name = 'HandshakeFailed';
223
+ throw e;
224
+ }
225
+ return { handshakeId, sigA: sign(nonceB), remoteDid: toDid };
226
+ },
227
+ };
228
+ }
229
+
230
+ /**
231
+ * Read a Service's 402 PaymentRequirements and prepare to pay (spec §7a.1 step 5).
232
+ * Refuses a 402 that is NOT bound to a MAGP authorization (§7a.2.1) — the agent
233
+ * must never pay for an ungoverned request — and refuses one whose authorization
234
+ * does not match the `authorizationId` the agent holds from its own authorize
235
+ * (allow) step, so a swapped 402 can't redirect the payment.
236
+ *
237
+ * @param {object} requirements the x402 PaymentRequirements from the 402 response
238
+ * @param {string} [expectedAuthorizationId] the authorizationId from guard.authorize()
239
+ * @returns {{authorizationId:string, amountMinor:string, payTo:string, asset:string, network:string, resource:string}}
240
+ */
241
+ function preparePayment(requirements, expectedAuthorizationId) {
242
+ const a = requirements?.accepts?.[0];
243
+ if (!a?.extra?.magpAuthorizationId) {
244
+ const e = new Error('402 is not bound to a MAGP authorization — refusing to pay');
245
+ e.name = 'UnboundPayment';
246
+ throw e;
247
+ }
248
+ if (expectedAuthorizationId && a.extra.magpAuthorizationId !== expectedAuthorizationId) {
249
+ const e = new Error('402 authorization does not match the agent authorization');
250
+ e.name = 'AuthorizationMismatch';
251
+ throw e;
252
+ }
253
+ // Pay exactly the authorized amount; the binding check guards against overpay.
254
+ checkSettlementBinding(requirements, { authorizationId: a.extra.magpAuthorizationId, paidAmountMinor: a.maxAmountRequired });
255
+ return {
256
+ authorizationId: a.extra.magpAuthorizationId,
257
+ amountMinor: a.maxAmountRequired,
258
+ payTo: a.payTo,
259
+ asset: a.asset,
260
+ network: a.network,
261
+ resource: a.resource,
262
+ };
263
+ }
264
+
265
+ /**
266
+ * Poll the outcome of an escalated action (spec §9a). When authorize() returns
267
+ * `escalate`, its `escalationId` parks the action for the Owner to approve/deny.
268
+ * The agent polls this until the status is terminal; on `approved` the returned
269
+ * `authorizationId` carries into the §7a capture/pay flow. Fails soft (never throws).
270
+ * @returns {Promise<{status:string,reasonCode:string,authorizationId:string|null,expiresAt:string|null}>}
271
+ */
272
+ async function escalationStatus(escalationId) {
273
+ try {
274
+ const res = await fetch(`${base}/policy/escalations/${encodeURIComponent(escalationId)}/status`);
275
+ const body = await res.json().catch(() => null);
276
+ return body?.data ?? { status: 'unknown', reasonCode: `GATE_HTTP_${res.status}`, authorizationId: null, expiresAt: null };
277
+ } catch (err) {
278
+ return { status: 'unreachable', reasonCode: 'GATE_UNREACHABLE', authorizationId: null, expiresAt: null, error: String(err?.message ?? err) };
279
+ }
280
+ }
281
+
282
+ /**
283
+ * BYOK proof-of-possession (onboarding proposal #4). For an agent that brought its OWN key,
284
+ * MetaMynd issued the identity with a one-time `challenge` and left the key UNVERIFIED — the gate
285
+ * blocks it with AGENT_KEY_UNVERIFIED until control is proven. This signs the challenge with the
286
+ * agent's private key (the same Ed25519 the gate checks) and submits it to verify-key, flipping
287
+ * the key to verified. A one-time SETUP step: verify-key is owner-authenticated, so pass the owner
288
+ * `token` you onboarded with. `ref` defaults to the identityId; `challenge` comes from the config.
289
+ *
290
+ * @param {{ ref: string, challenge: string, token?: string }} p
291
+ * @returns {Promise<{ verified: boolean, did?: string }>}
292
+ */
293
+ async function verifyKey({ ref, challenge, token } = {}) {
294
+ if (!ref || !challenge) throw new Error('verifyKey requires { ref, challenge } (from the BYOK onboarding config)');
295
+ const res = await fetch(`${base}/agent-identity/${encodeURIComponent(ref)}/verify-key`, {
296
+ method: 'POST',
297
+ headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
298
+ body: JSON.stringify({ signature: sign(challenge) }),
299
+ });
300
+ const body = await res.json().catch(() => null);
301
+ if (!res.ok) {
302
+ const e = new Error(body?.message || `verify-key HTTP ${res.status}`);
303
+ e.name = 'KeyVerificationFailed';
304
+ throw e;
305
+ }
306
+ return body?.data ?? { verified: true };
307
+ }
308
+
309
+ /** Sign a BYOK challenge with the agent's key (hex) — for integrators who submit verify-key themselves. */
310
+ function signChallenge(challenge) {
311
+ if (!challenge) throw new Error('signChallenge requires the challenge nonce');
312
+ return sign(challenge);
313
+ }
314
+
315
+ return { authorize, buildSignedRequest, capture, guardTool, evaluateLocally, guardToolLocal, handshake, preparePayment, escalationStatus, verifyKey, signChallenge, agentDid };
316
+ }