@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.
- package/README.md +223 -223
- package/agentsafe-guard.mjs +316 -316
- package/example-openclaw-agent.mjs +47 -47
- package/magp-did.mjs +39 -8
- package/package.json +1 -1
- package/policy-core.mjs +26 -4
package/agentsafe-guard.mjs
CHANGED
|
@@ -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
|
+
}
|