@metamynd/agentsafe-guard 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MetaMynd
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,223 @@
1
+ # AgentSafe Guard — runtime governance for OpenClaw (and any Node agent)
2
+
3
+ Drop-in middleware that gates an agent's actions through the AgentSafe **authorize** endpoint
4
+ before they run. The gate deterministically returns `allow` / `block` / `escalate` after checking
5
+ the agent's **mandate**, its enforced **Standards**, and its assigned **SOPs** — the same rules a
6
+ compliance team edits in the dashboard, changeable live with no redeploy.
7
+
8
+ - **Zero dependencies.** Uses Node's built-in Ed25519 (`node:crypto`) + `fetch` (Node 18+), plus
9
+ `policy-core.mjs` — the deterministic evaluator, itself dependency-free, generated from
10
+ `backend/src/policy-core` (regenerate with `npm run build:guard-core`).
11
+ - **Deterministic + tamper-resistant.** Every call is Ed25519-signed over a canonical message with a
12
+ single-use nonce; the gate is server-authoritative and cannot be bypassed. No LLM at the gate.
13
+ - **Fail-closed.** A network/gate failure returns `block`, so the agent never proceeds blind.
14
+ - **Local evaluation.** The guard can also evaluate a signed policy bundle **locally** with the same
15
+ `policy-core` the gate runs (MAGP §9.2 cooperative mode) — identical inputs give the identical
16
+ verdict, with no network round-trip. See §4.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ npm i @metamynd/agentsafe-guard
22
+ ```
23
+
24
+ Requires Node ≥ 18 (built-in `fetch` + Ed25519). The package has **no dependencies**.
25
+
26
+ ### Fastest start — scaffold a governed agent in one command
27
+
28
+ If you don't have an agent config yet, let the scaffolder log you in, provision the agent
29
+ (identity + mandate + starter SOP + Standards in one call), write `agent.metamynd.json`, and drop a
30
+ runnable example:
31
+
32
+ ```bash
33
+ npm create metamynd-agent@latest # or: npx create-metamynd-agent
34
+ ```
35
+
36
+ Then:
37
+
38
+ ```js
39
+ import { createGuardFromConfig } from '@metamynd/agentsafe-guard';
40
+ const guard = await createGuardFromConfig('./agent.metamynd.json'); // no env vars
41
+ ```
42
+
43
+ The rest of this guide shows the manual path (seed → wire) and the advanced features
44
+ (local eval, handshake, escalation, payments).
45
+
46
+ ### Bring your own key (BYOK)
47
+
48
+ Provision the agent with your **own** public key so MetaMynd never sees the private key. The identity
49
+ is issued unverified with a one-time `challenge`; the gate blocks it (`AGENT_KEY_UNVERIFIED`) until you
50
+ prove control. The guard signs the challenge with your key and submits it:
51
+
52
+ ```js
53
+ const guard = await createGuardFromConfig('./agent.metamynd.json', { agentKey: myPrivateKey });
54
+ await guard.verifyKey({ ref: config.identityId, challenge: config.challenge, token: ownerToken }); // one-time
55
+ ```
56
+
57
+ `guard.signChallenge(challenge)` returns just the hex signature if you'd rather submit verify-key
58
+ yourself. (Fastest path: `npm create metamynd-agent@latest -- --byok` does all of this for you.)
59
+
60
+ ## 1. Seed a bound agent (once)
61
+
62
+ Run the seed against a running stack — it prints the agent's `DID` and `KEY`:
63
+
64
+ ```powershell
65
+ cd backend
66
+ $env:API_BASE="http://localhost:9926/api/v1" # or https://metamynd.ai/api/v1
67
+ node_modules\.bin\tsx scripts\demo-seed-governance.ts
68
+ # → AGENT_DID = did:hedera:testnet:...
69
+ # → AGENT_KEY = 302e0201...
70
+ ```
71
+
72
+ The agent is now bound to a mandate (`flight-purchase`), an **enforced Standard** (EU AI Act) and an
73
+ **active SOP** (spend cap + approved tools). Manage/toggle these from the dashboard:
74
+ Super Admin → Standards, Legal Entity → SOPs.
75
+
76
+ ## 2. Try the example
77
+
78
+ ```powershell
79
+ cd integrations\agentsafe-guard
80
+ $env:AGENTSAFE_API="http://localhost:9926/api/v1"
81
+ $env:AGENT_DID="did:hedera:testnet:..."
82
+ $env:AGENT_KEY="302e0201..."
83
+ node example-openclaw-agent.mjs
84
+ ```
85
+
86
+ ```
87
+ ✅ ALLOW $150 book-flight, low risk → booked PNR-DEMO (remaining $200)
88
+ ⛔ BLOCK $600 book-flight → SOP_SPEND_CAP
89
+ ⛔ BLOCK $100 wire-transfer tool → SOP_TOOL_BLOCKED
90
+ ⚠ ESCALATE $100 high-risk decision → RISK_REVIEW
91
+ ```
92
+
93
+ Now flip the SOP's **active → inactive** toggle in the UI (or edit a rule) and re-run — the decision
94
+ changes in real time.
95
+
96
+ ## 3. Wire it into your OpenClaw agent
97
+
98
+ Wrap each governed tool's handler with `guardTool(...)`. The wrapped handler only runs when the gate
99
+ allows; otherwise it throws a `GovernanceBlocked` error your agent surfaces to the user.
100
+
101
+ ```js
102
+ import { createGuard, createGuardFromConfig } from '@metamynd/agentsafe-guard';
103
+
104
+ // Preferred: load the portable config the one-call onboarding endpoint returns (no env vars).
105
+ const guard = await createGuardFromConfig('./agent.metamynd.json');
106
+
107
+ // Or configure explicitly:
108
+ const guardExplicit = createGuard({
109
+ api: process.env.AGENTSAFE_API,
110
+ agentDid: process.env.AGENT_DID,
111
+ agentKey: process.env.AGENT_KEY, // held only by the agent
112
+ });
113
+
114
+ // Your existing OpenClaw tool handler:
115
+ async function bookFlight(args) { /* …call the airline… */ return { pnr: 'ABC123' }; }
116
+
117
+ // Register the GATED version with OpenClaw instead of the raw handler:
118
+ const gatedBookFlight = guard.guardTool(
119
+ 'flight-purchase', // the governed action (matches the mandate scope)
120
+ bookFlight,
121
+ (a) => ({ // map tool args → gate inputs
122
+ amount: a.amount,
123
+ currency: 'USD',
124
+ merchant: a.merchant,
125
+ context: { tool: 'book-flight', jurisdiction: a.jurisdiction, riskLevel: a.riskLevel },
126
+ }),
127
+ );
128
+ ```
129
+
130
+ - If your OpenClaw build has a **pre-tool hook / middleware** instead of raw handlers, call
131
+ `await guard.authorize({ action, amount, merchant, context })` there and refuse on any non-`allow`.
132
+ - **`context`** is what the Standard/SOP atoms read (jurisdiction, model, tool, PII, risk, …). Each
133
+ atom declares what it needs — fetch the catalog at `GET /api/v1/standards/atoms` to see the exact
134
+ fields (`requiredContext`) for the rules your agent is bound to.
135
+ - For **payment** tools (x402, §7a): after `authorize` allows, the Service returns a 402 bound to
136
+ your `authorizationId`. Call `guard.preparePayment(requirements, authorizationId)` — it refuses an
137
+ unbound or mismatched 402 — pay via x402, then reconcile the hold with
138
+ `await guard.capture(authorizationId, amountCharged, bookingRef, settlementTxHash)`. An
139
+ uncaptured hold auto-voids at its expiry (`POST /policy/mandate/authorize/:id/void` to release early).
140
+
141
+ ## 4. Evaluate locally (no network)
142
+
143
+ For low-latency, cooperative-mode governance the guard can evaluate a **policy bundle** locally with
144
+ the same deterministic `policy-core` the gate runs — no round-trip. Given the same rule packs,
145
+ mandate, and request, it returns the **identical** `allow` / `block` / `escalate` verdict.
146
+
147
+ ```js
148
+ const verdict = guard.evaluateLocally({
149
+ standards: [{ standardKey: 'eu-ai-act', document: { molecules: [/* … */] } }],
150
+ sops: [{ standardKey: 'sop:travel', document: { molecules: [/* … */] } }],
151
+ mandate: { permission: [/* ODRL constraints … */] },
152
+ request: { action: 'flight-purchase', amount: 600, merchant: 'amadeus',
153
+ context: { riskLevel: 'low' } },
154
+ });
155
+ // → { decision: 'block', reasonCode: 'SOP_SPEND_CAP', authorizationId: null, remaining: null, proofRef: null }
156
+ ```
157
+
158
+ Or wrap a tool to gate it against a local bundle (fails closed like `guardTool`):
159
+
160
+ ```js
161
+ const gated = guard.guardToolLocal('flight-purchase', bookFlight, mapArgs, { standards, sops, mandate });
162
+ ```
163
+
164
+ Signed request fields (`amount`, `merchant`) are always applied over the unsigned `context`, so a
165
+ forged context key can never shadow them (MAGP §6.4.2). Run the self-check:
166
+
167
+ ```powershell
168
+ cd integrations\agentsafe-guard
169
+ node local-eval.smoke.mjs # PASS when every local verdict matches the gate
170
+ ```
171
+
172
+ Local evaluation is the cooperative-mode pre-check; the gate still owns the stateful parts
173
+ (single-use nonce, atomic spend-cap reservation, evidence anchoring), so value-bearing actions should
174
+ still settle through the gate / `capture` flow.
175
+
176
+ ## 5. Mutual handshake with a Service (§8.2)
177
+
178
+ Before transacting with a Service (an MCP), the agent and Service prove control of their DIDs
179
+ to each other — no issuer calls, because the keys are embedded in the DIDs (§4.1.2). The agent
180
+ drives the initiator side:
181
+
182
+ ```js
183
+ const hs = guard.handshake();
184
+ const { nonceA, message } = hs.hello(); // → send HELLO to the Service
185
+ // Service replies with CHALLENGE { toDid, nonceB, sigB(nonceA) }
186
+ const { sigA, handshakeId } = hs.prove({ nonceA, challenge }); // verifies the Service, → send PROVE
187
+ // Service replies READY { channelId }
188
+ ```
189
+
190
+ `prove()` throws `HandshakeFailed` if the Service's CHALLENGE does not verify against the key in
191
+ its DID. The Service side uses [`agentsafe-mcp-guard`](../agentsafe-mcp-guard), which also
192
+ re-evaluates the agent's signed request trustlessly (§9.6). Discover a Service's endpoint and
193
+ confirm its key via the public resolver `GET /did/:did` (§4.4).
194
+
195
+ ## 6. Escalation — human-in-the-loop (§9a)
196
+
197
+ An `escalate` verdict is **not a denial** — the action is *held* pending the Owner's approval. The
198
+ verdict carries an `escalationId`; no budget is reserved and (for payments) nothing settles until it
199
+ is approved. The Owner resolves it in the dashboard / via `POST /policy/escalations/:id/resolve`;
200
+ the agent polls the outcome:
201
+
202
+ ```js
203
+ const d = await guard.authorize({ action: 'flight-purchase', amount: 5000, context: { riskLevel: 'high' } });
204
+ if (d.decision === 'escalate') {
205
+ // parked for review — d.escalationId, d.expiresAt
206
+ const outcome = await guard.escalationStatus(d.escalationId);
207
+ // → { status: 'approved' | 'denied' | 'expired' | 'pending', authorizationId, reasonCode }
208
+ // on 'approved', outcome.authorizationId carries into the §7a capture/pay flow.
209
+ }
210
+ ```
211
+
212
+ On approval the budget/cap gate re-runs (§9a.3), so an approval still can't overspend; an
213
+ unresolved escalation lapses to denied after its TTL (§9a.4).
214
+
215
+ ## Trust model
216
+
217
+ The gate is a **checkpoint** — enforcement is real when it's actually called.
218
+ - **Trustless:** the counterparty the agent transacts with calls the *public* authorize endpoint and
219
+ only proceeds on `allow`. A rogue agent that skips the call can't get the counterparty to act.
220
+ - **Cooperative:** the agent's own tool layer (this guard) calls the gate and refuses on block/escalate.
221
+
222
+ Either way, every decision is Ed25519-authenticated, deterministic, and anchored as evidence
223
+ (visible in the dashboard's Regulator log and on HashScan).
@@ -0,0 +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
+ }
@@ -0,0 +1,47 @@
1
+ // example-openclaw-agent.mjs — a runnable demo of the guard against a seeded bound agent.
2
+ //
3
+ // Simulates how an OpenClaw agent's TOOL is wrapped: the raw tool only runs if the
4
+ // AgentSafe gate returns `allow`. Run it after seeding an agent with
5
+ // backend/scripts/demo-seed-governance.ts:
6
+ //
7
+ // $env:AGENTSAFE_API="http://localhost:9926/api/v1"
8
+ // $env:AGENT_DID="did:hedera:testnet:..."
9
+ // $env:AGENT_KEY="302e0201..."
10
+ // node example-openclaw-agent.mjs
11
+ import { createGuard } from './agentsafe-guard.mjs';
12
+
13
+ const guard = createGuard({
14
+ api: process.env.AGENTSAFE_API ?? 'http://localhost:9926/api/v1',
15
+ agentDid: process.env.AGENT_DID,
16
+ agentKey: process.env.AGENT_KEY,
17
+ });
18
+
19
+ // --- The agent's tool. In OpenClaw you register this handler for the tool; here we
20
+ // wrap it with guard.guardTool so every call is gated first. ---
21
+ const bookFlight = guard.guardTool(
22
+ 'flight-purchase', // the governed action (matches the mandate scope)
23
+ async (args, decision) => {
24
+ // Only reached when the gate ALLOWED. Real booking would go here.
25
+ return { booked: true, pnr: 'PNR-DEMO', remaining: decision.remaining, ...args };
26
+ },
27
+ // Map the tool args → gate inputs. `context` carries what the Standard/SOP rules need.
28
+ (a) => ({ amount: a.amount, currency: 'USD', merchant: a.merchant, context: { tool: a.tool ?? 'book-flight', riskLevel: a.riskLevel ?? 'low' } }),
29
+ );
30
+
31
+ async function ask(label, args) {
32
+ try {
33
+ const r = await bookFlight(args);
34
+ console.log(` \x1b[32m✅ ALLOW\x1b[0m ${label.padEnd(30)} → booked ${r.pnr} (remaining $${r.remaining})`);
35
+ } catch (e) {
36
+ const g = e.governance ?? {};
37
+ const tag = g.decision === 'escalate' ? '\x1b[33m⚠ ESCALATE\x1b[0m' : '\x1b[31m⛔ BLOCK\x1b[0m';
38
+ console.log(` ${tag} ${label.padEnd(30)} → ${g.reasonCode ?? e.message}`);
39
+ }
40
+ }
41
+
42
+ console.log(`\n OpenClaw agent — every booking passes through the AgentSafe gate\n ${'─'.repeat(60)}`);
43
+ await ask('$150 book-flight, low risk', { amount: 150, merchant: 'skyward-air', tool: 'book-flight', riskLevel: 'low' });
44
+ await ask('$600 book-flight', { amount: 600, merchant: 'skyward-air', tool: 'book-flight', riskLevel: 'low' });
45
+ await ask('$100 wire-transfer tool', { amount: 100, merchant: 'skyward-air', tool: 'wire-transfer', riskLevel: 'low' });
46
+ await ask('$100 high-risk decision', { amount: 100, merchant: 'skyward-air', tool: 'book-flight', riskLevel: 'high' });
47
+ console.log(`\n The agent refuses blocked/escalated actions itself — governance decided, not the LLM.\n`);
package/magp-did.mjs ADDED
@@ -0,0 +1,126 @@
1
+ // GENERATED from backend/src/features/magp/did.ts — do not edit. Regenerate: npm run build:guard-core
2
+
3
+ // src/features/magp/did.ts
4
+ import crypto from "node:crypto";
5
+
6
+ // src/features/agent-identity/did.util.ts
7
+ var BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
8
+ function base58(bytes) {
9
+ let zeros = 0;
10
+ while (zeros < bytes.length && bytes[zeros] === 0) zeros++;
11
+ const digits = [];
12
+ for (let i = zeros; i < bytes.length; i++) {
13
+ let carry = bytes[i];
14
+ for (let j = 0; j < digits.length; j++) {
15
+ carry += digits[j] << 8;
16
+ digits[j] = carry % 58;
17
+ carry = carry / 58 | 0;
18
+ }
19
+ while (carry > 0) {
20
+ digits.push(carry % 58);
21
+ carry = carry / 58 | 0;
22
+ }
23
+ }
24
+ let out = "";
25
+ for (let k = 0; k < zeros; k++) out += BASE58_ALPHABET[0];
26
+ for (let q = digits.length - 1; q >= 0; q--) out += BASE58_ALPHABET[digits[q]];
27
+ return out;
28
+ }
29
+ function multibaseBase58btc(bytes) {
30
+ return "z" + base58(bytes);
31
+ }
32
+ function buildHederaDid(network, publicKeyBytes, topicId) {
33
+ return `did:hedera:${network}:${multibaseBase58btc(publicKeyBytes)}_${topicId}`;
34
+ }
35
+ function base58Decode(str) {
36
+ const bytes = [0];
37
+ for (const ch of str) {
38
+ const value = BASE58_ALPHABET.indexOf(ch);
39
+ if (value === -1) throw new Error(`invalid base58 character '${ch}'`);
40
+ let carry = value;
41
+ for (let j = 0; j < bytes.length; j++) {
42
+ carry += bytes[j] * 58;
43
+ bytes[j] = carry & 255;
44
+ carry >>= 8;
45
+ }
46
+ while (carry > 0) {
47
+ bytes.push(carry & 255);
48
+ carry >>= 8;
49
+ }
50
+ }
51
+ let zeros = 0;
52
+ for (let k = 0; k < str.length && str[k] === BASE58_ALPHABET[0]; k++) zeros++;
53
+ const out = new Uint8Array(zeros + bytes.length);
54
+ for (let i = 0; i < bytes.length; i++) out[zeros + i] = bytes[bytes.length - 1 - i];
55
+ return out;
56
+ }
57
+ function parseHederaDid(did) {
58
+ const m = /^did:hedera:(mainnet|testnet|previewnet|devnet):(z[1-9A-HJ-NP-Za-km-z]+)_(\d+\.\d+\.\d+)$/.exec(did ?? "");
59
+ if (!m) return null;
60
+ const [, network, publicKeyMultibase, topicId] = m;
61
+ let publicKeyBytes;
62
+ try {
63
+ publicKeyBytes = base58Decode(publicKeyMultibase.slice(1));
64
+ } catch {
65
+ return null;
66
+ }
67
+ if (publicKeyBytes.length !== 32) return null;
68
+ return { network, publicKeyMultibase, publicKeyBytes, topicId };
69
+ }
70
+
71
+ // src/features/magp/did.ts
72
+ var ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
73
+ function ed25519KeyFromRaw(raw) {
74
+ const der = Buffer.concat([ED25519_SPKI_PREFIX, Buffer.from(raw)]);
75
+ return crypto.createPublicKey({ key: der, format: "der", type: "spki" });
76
+ }
77
+ function verifyDidSignature(did, message, signatureHex) {
78
+ const parsed = parseHederaDid(did);
79
+ if (!parsed) return false;
80
+ try {
81
+ const key = ed25519KeyFromRaw(parsed.publicKeyBytes);
82
+ return crypto.verify(null, Buffer.from(message, "utf8"), key, Buffer.from(signatureHex, "hex"));
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+ function buildDidDocument(did, service) {
88
+ const parsed = parseHederaDid(did);
89
+ if (!parsed) return null;
90
+ const doc = {
91
+ "@context": ["https://www.w3.org/ns/did/v1"],
92
+ id: did,
93
+ controller: did,
94
+ verificationMethod: [
95
+ {
96
+ id: `${did}#did-root-key`,
97
+ type: "Ed25519VerificationKey2020",
98
+ controller: did,
99
+ publicKeyMultibase: parsed.publicKeyMultibase
100
+ }
101
+ ],
102
+ authentication: [`${did}#did-root-key`]
103
+ };
104
+ if (service) {
105
+ doc.service = [
106
+ {
107
+ id: `${did}#magp`,
108
+ type: "MAGPEndpoint",
109
+ serviceEndpoint: service.serviceEndpoint,
110
+ channels: service.channels,
111
+ protoVersions: service.protoVersions
112
+ }
113
+ ];
114
+ }
115
+ return doc;
116
+ }
117
+ export {
118
+ base58,
119
+ base58Decode,
120
+ buildDidDocument,
121
+ buildHederaDid,
122
+ ed25519KeyFromRaw,
123
+ multibaseBase58btc,
124
+ parseHederaDid,
125
+ verifyDidSignature
126
+ };
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@metamynd/agentsafe-guard",
3
+ "version": "0.1.0",
4
+ "description": "Zero-dependency runtime governance for any Node AI agent — gate tool calls through MetaMynd/AgentSafe (allow / block / escalate) against the agent's mandate, enforced Standards, and SOPs. Ed25519-signed, deterministic, fail-closed.",
5
+ "type": "module",
6
+ "main": "./agentsafe-guard.mjs",
7
+ "module": "./agentsafe-guard.mjs",
8
+ "exports": {
9
+ ".": "./agentsafe-guard.mjs",
10
+ "./policy-core": "./policy-core.mjs",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "agentsafe-guard.mjs",
15
+ "policy-core.mjs",
16
+ "magp-did.mjs",
17
+ "x402.mjs",
18
+ "example-openclaw-agent.mjs",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "scripts": {
23
+ "test": "node local-eval.smoke.mjs"
24
+ },
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "sideEffects": false,
29
+ "keywords": [
30
+ "ai",
31
+ "agent",
32
+ "agents",
33
+ "governance",
34
+ "ai-safety",
35
+ "guardrails",
36
+ "policy",
37
+ "mandate",
38
+ "authorization",
39
+ "ed25519",
40
+ "magp",
41
+ "metamynd",
42
+ "agentsafe",
43
+ "compliance"
44
+ ],
45
+ "author": "MetaMynd",
46
+ "license": "MIT",
47
+ "homepage": "https://github.com/jasimp18/AgentSafe/tree/main/integrations/agentsafe-guard#readme",
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/jasimp18/AgentSafe.git",
51
+ "directory": "integrations/agentsafe-guard"
52
+ },
53
+ "bugs": {
54
+ "url": "https://github.com/jasimp18/AgentSafe/issues"
55
+ },
56
+ "publishConfig": {
57
+ "access": "public"
58
+ }
59
+ }
@@ -0,0 +1,389 @@
1
+ // GENERATED from backend/src/policy-core — do not edit. Regenerate: npm run build:guard-core
2
+
3
+ // src/policy-core/atom-registry.ts
4
+ var RISK_RANK = { low: 0, medium: 1, high: 2, critical: 3 };
5
+ var ATOM_REGISTRY = {
6
+ "data-source-not-approved": (c, cfg) => !!c.dataSourceId && !(cfg?.approved ?? []).includes(String(c.dataSourceId)),
7
+ "consent-missing": (c) => c.consent === false,
8
+ "risk-at-or-above": (c, cfg) => {
9
+ const have = RISK_RANK[String(c.riskLevel)];
10
+ const need = RISK_RANK[String(cfg?.level ?? "high")];
11
+ return have !== void 0 && need !== void 0 && have >= need;
12
+ },
13
+ "amount-over": (c, cfg) => typeof c.amount === "number" && c.amount > Number(cfg?.limit ?? 0),
14
+ // Fires if any configured term appears in the prompt and/or output text.
15
+ // Used to govern agent responses on content (prohibited claims, sensitive advice).
16
+ "text-matches": (c, cfg) => {
17
+ const hay = `${c.prompt ?? ""}
18
+ ${c.output ?? ""}`.toLowerCase();
19
+ const terms = (cfg?.terms ?? []).map((t) => String(t).toLowerCase());
20
+ return terms.some((t) => t.length > 0 && hay.includes(t));
21
+ },
22
+ // --- Compliance atoms. Allow-list atoms fire when the context field is PRESENT
23
+ // and NOT allowed (consistent with data-source-not-approved: a missing field
24
+ // does not fire — the atom's requiredContext documents what to supply). ---
25
+ "jurisdiction-not-allowed": (c, cfg) => notInAllowList(c.jurisdiction, cfg?.allowed),
26
+ "data-residency-violation": (c, cfg) => notInAllowList(c.dataResidency, cfg?.allowedRegions),
27
+ "model-not-allowed": (c, cfg) => notInAllowList(c.model, cfg?.allowed),
28
+ "tool-not-allowed": (c, cfg) => notInAllowList(c.tool, cfg?.allowed),
29
+ "pii-present": (c) => c.piiPresent === true,
30
+ "rate-limit-exceeded": (c, cfg) => typeof c.callCount === "number" && c.callCount > Number(cfg?.max ?? 0)
31
+ };
32
+ function notInAllowList(value, allowList) {
33
+ const v = value != null ? String(value).toLowerCase().trim() : "";
34
+ const allowed = (Array.isArray(allowList) ? allowList : []).map((x) => String(x).toLowerCase().trim());
35
+ return v !== "" && allowed.length > 0 && !allowed.includes(v);
36
+ }
37
+
38
+ // src/policy-core/atom-catalog.ts
39
+ var ATOM_SPECS = [
40
+ {
41
+ predicate: "amount-over",
42
+ label: "Amount over limit",
43
+ description: "Fires when the action amount exceeds a configured limit.",
44
+ config: [{ key: "limit", type: "number", required: true, description: "Maximum allowed amount" }],
45
+ requiredContext: ["amount"]
46
+ },
47
+ {
48
+ predicate: "risk-at-or-above",
49
+ label: "Risk at or above level",
50
+ description: "Fires when the assessed risk level is at or above the configured threshold.",
51
+ config: [
52
+ {
53
+ key: "level",
54
+ type: "enum",
55
+ required: true,
56
+ description: "Threshold risk level",
57
+ options: ["low", "medium", "high", "critical"]
58
+ }
59
+ ],
60
+ requiredContext: ["riskLevel"]
61
+ },
62
+ {
63
+ predicate: "data-source-not-approved",
64
+ label: "Data source not approved",
65
+ description: "Fires when the action uses a data source not on the approved list.",
66
+ config: [
67
+ { key: "approved", type: "string[]", required: true, description: "Allow-list of approved data source ids" }
68
+ ],
69
+ requiredContext: ["dataSourceId"]
70
+ },
71
+ {
72
+ predicate: "consent-missing",
73
+ label: "Consent missing",
74
+ description: "Fires when explicit consent is absent for the action.",
75
+ config: [],
76
+ requiredContext: ["consent"]
77
+ },
78
+ {
79
+ predicate: "text-matches",
80
+ label: "Text contains prohibited terms",
81
+ description: "Fires when the prompt or output contains any of the configured terms.",
82
+ config: [{ key: "terms", type: "string[]", required: true, description: "Terms that must not appear" }],
83
+ requiredContext: ["prompt", "output"]
84
+ },
85
+ {
86
+ predicate: "jurisdiction-not-allowed",
87
+ label: "Jurisdiction not allowed",
88
+ description: "Fires when the action's jurisdiction is not on the allow-list.",
89
+ config: [{ key: "allowed", type: "string[]", required: true, description: "Allowed jurisdictions (e.g. US, MY, EU)" }],
90
+ requiredContext: ["jurisdiction"]
91
+ },
92
+ {
93
+ predicate: "data-residency-violation",
94
+ label: "Data residency violation",
95
+ description: "Fires when data would be processed in a region not on the allow-list.",
96
+ config: [{ key: "allowedRegions", type: "string[]", required: true, description: "Allowed processing regions" }],
97
+ requiredContext: ["dataResidency"]
98
+ },
99
+ {
100
+ predicate: "model-not-allowed",
101
+ label: "LLM model not allowed",
102
+ description: "Fires when the agent uses an LLM model not on the approved list.",
103
+ config: [{ key: "allowed", type: "string[]", required: true, description: "Approved model ids" }],
104
+ requiredContext: ["model"]
105
+ },
106
+ {
107
+ predicate: "tool-not-allowed",
108
+ label: "Tool not allowed",
109
+ description: "Fires when the agent invokes a tool/function not on the approved list.",
110
+ config: [{ key: "allowed", type: "string[]", required: true, description: "Approved tool names" }],
111
+ requiredContext: ["tool"]
112
+ },
113
+ {
114
+ predicate: "pii-present",
115
+ label: "PII present",
116
+ description: "Fires when the action is flagged as involving personal data (PII).",
117
+ config: [],
118
+ requiredContext: ["piiPresent"]
119
+ },
120
+ {
121
+ predicate: "rate-limit-exceeded",
122
+ label: "Rate limit exceeded",
123
+ description: "Fires when the rolling call count exceeds a configured maximum.",
124
+ config: [{ key: "max", type: "number", required: true, description: "Maximum allowed calls" }],
125
+ requiredContext: ["callCount"]
126
+ }
127
+ ];
128
+ var CATALOGUED_ATOMS = ATOM_SPECS.filter((s) => !!ATOM_REGISTRY[s.predicate]);
129
+ function requiredContextFor(predicates) {
130
+ const fields = /* @__PURE__ */ new Set();
131
+ for (const p of predicates) {
132
+ const spec = ATOM_SPECS.find((s) => s.predicate === p);
133
+ for (const f of spec?.requiredContext ?? []) fields.add(f);
134
+ }
135
+ return [...fields].sort();
136
+ }
137
+
138
+ // src/policy-core/standards-rules.ts
139
+ var PRECEDENCE = { allow: 0, escalate: 1, block: 2 };
140
+ function atomFires(atom, ctx) {
141
+ const pred = ATOM_REGISTRY[atom.predicate];
142
+ if (!pred) return false;
143
+ try {
144
+ return !!pred(ctx, atom.config);
145
+ } catch (err) {
146
+ console.warn(
147
+ `[standards] atom '${atom.predicate}' threw during evaluation (treated as not-firing):`,
148
+ err instanceof Error ? err.message : err
149
+ );
150
+ return false;
151
+ }
152
+ }
153
+ function moleculeFires(m, ctx) {
154
+ if (!m.atoms || m.atoms.length === 0) return false;
155
+ const results = m.atoms.map((a) => atomFires(a, ctx));
156
+ switch (m.combinator) {
157
+ case "all":
158
+ return results.every(Boolean);
159
+ case "any":
160
+ return results.some(Boolean);
161
+ case "none":
162
+ return !results.some(Boolean);
163
+ default:
164
+ return false;
165
+ }
166
+ }
167
+ function evaluateStandardRules(molecules, ctx, standardKey = null) {
168
+ let best = null;
169
+ for (const m of molecules ?? []) {
170
+ if (moleculeFires(m, ctx)) {
171
+ if (!best || PRECEDENCE[m.decision] > PRECEDENCE[best.decision]) {
172
+ best = { decision: m.decision, reasonCode: m.reasonCode, id: m.id };
173
+ }
174
+ }
175
+ }
176
+ if (!best) return { decision: "allow", reasonCode: null, firedMoleculeId: null, standardKey };
177
+ return { decision: best.decision, reasonCode: best.reasonCode, firedMoleculeId: best.id, standardKey };
178
+ }
179
+ function evaluateBoundStandards(standards, ctx) {
180
+ let best = { decision: "allow", reasonCode: null, firedMoleculeId: null, standardKey: null };
181
+ for (const s of standards) {
182
+ const r = evaluateStandardRules(s.document?.molecules, ctx, s.standardKey);
183
+ if (PRECEDENCE[r.decision] > PRECEDENCE[best.decision]) best = r;
184
+ }
185
+ return best;
186
+ }
187
+ function configValueValid(field, value) {
188
+ switch (field.type) {
189
+ case "number":
190
+ return typeof value === "number" && Number.isFinite(value);
191
+ case "string":
192
+ return typeof value === "string";
193
+ case "string[]":
194
+ return Array.isArray(value) && value.every((v) => typeof v === "string");
195
+ case "enum":
196
+ return typeof value === "string" && (field.options ?? []).includes(value);
197
+ default:
198
+ return true;
199
+ }
200
+ }
201
+ function validateAtomConfig(predicate, config) {
202
+ const spec = ATOM_SPECS.find((s) => s.predicate === predicate);
203
+ if (!spec) return [];
204
+ const errors = [];
205
+ const cfg = config ?? {};
206
+ for (const field of spec.config) {
207
+ const present = cfg[field.key] !== void 0 && cfg[field.key] !== null;
208
+ if (!present) {
209
+ if (field.required) errors.push(`atom '${predicate}' missing required config '${field.key}'`);
210
+ continue;
211
+ }
212
+ if (!configValueValid(field, cfg[field.key])) {
213
+ errors.push(`atom '${predicate}' config '${field.key}' must be a ${field.type}`);
214
+ }
215
+ }
216
+ return errors;
217
+ }
218
+ function validateMolecules(molecules) {
219
+ const issues = [];
220
+ for (const m of molecules ?? []) {
221
+ if (!m.id) issues.push({ moleculeId: "(missing id)", message: "molecule is missing an id" });
222
+ if (!["all", "any", "none"].includes(m.combinator)) {
223
+ issues.push({ moleculeId: m.id, message: `invalid combinator '${m.combinator}' (all|any|none)` });
224
+ }
225
+ if (!["block", "escalate"].includes(m.decision)) {
226
+ issues.push({ moleculeId: m.id, message: `invalid decision '${m.decision}' (block|escalate)` });
227
+ }
228
+ if (!m.reasonCode) issues.push({ moleculeId: m.id, message: "molecule is missing a reasonCode" });
229
+ if (!m.atoms || m.atoms.length === 0) {
230
+ issues.push({ moleculeId: m.id, message: "molecule has no atoms" });
231
+ }
232
+ for (const a of m.atoms ?? []) {
233
+ if (!ATOM_REGISTRY[a.predicate]) {
234
+ issues.push({ moleculeId: m.id, message: `unknown atom predicate '${a.predicate}'` });
235
+ continue;
236
+ }
237
+ for (const err of validateAtomConfig(a.predicate, a.config)) {
238
+ issues.push({ moleculeId: m.id, message: err });
239
+ }
240
+ }
241
+ }
242
+ return { ok: issues.length === 0, issues };
243
+ }
244
+
245
+ // src/policy-core/mandate-eval.ts
246
+ var toNum = (v) => typeof v === "number" ? v : Number(v);
247
+ var toArray = (v) => Array.isArray(v) ? v : v === void 0 || v === null ? [] : [v];
248
+ var toTime = (v) => Date.parse(String(v));
249
+ var OPERATORS = {
250
+ eq: (l, r) => l === r,
251
+ neq: (l, r) => l !== r,
252
+ lt: (l, r) => toNum(l) < toNum(r),
253
+ lteq: (l, r) => toNum(l) <= toNum(r),
254
+ gt: (l, r) => toNum(l) > toNum(r),
255
+ gteq: (l, r) => toNum(l) >= toNum(r),
256
+ isAnyOf: (l, r) => toArray(r).includes(l),
257
+ isNoneOf: (l, r) => !toArray(r).includes(l),
258
+ isPartOf: (l, r) => toArray(r).includes(l),
259
+ before: (l, r) => toTime(l) < toTime(r),
260
+ after: (l, r) => toTime(l) > toTime(r)
261
+ };
262
+ var REASON_BY_OPERAND = {
263
+ "mm:payAmount": "SPEND_LIMIT_EXCEEDED",
264
+ "mm:cumulativeSpend": "SPEND_LIMIT_EXCEEDED",
265
+ "mm:merchant": "MERCHANT_NOT_ALLOWED",
266
+ "mm:route": "ROUTE_NOT_ALLOWED",
267
+ "mm:counterparty": "COUNTERPARTY_NOT_ALLOWED"
268
+ };
269
+ function reasonFor(constraint) {
270
+ if (!constraint) return "CONSTRAINT_FAILED";
271
+ return REASON_BY_OPERAND[constraint.leftOperand] ?? `CONSTRAINT_FAILED:${constraint.leftOperand}`;
272
+ }
273
+ function constraintSatisfied(c, req) {
274
+ const op = OPERATORS[c.operator];
275
+ if (!op) return false;
276
+ const left = Object.prototype.hasOwnProperty.call(req.values, c.leftOperand) ? req.values[c.leftOperand] : void 0;
277
+ return op(left, c.rightOperand);
278
+ }
279
+ function targetOf(rule, mandate) {
280
+ return rule.target ?? mandate.target;
281
+ }
282
+ function evaluateMandate(mandate, req) {
283
+ const now = toTime(req.now);
284
+ if (mandate.validFrom && now < toTime(mandate.validFrom)) {
285
+ return { decision: "block", reasonCode: "MANDATE_NOT_YET_VALID", matched: { kind: "expiry" } };
286
+ }
287
+ if (mandate.validUntil && now > toTime(mandate.validUntil)) {
288
+ return { decision: "block", reasonCode: "MANDATE_EXPIRED", matched: { kind: "expiry" } };
289
+ }
290
+ for (const p of mandate.prohibition ?? []) {
291
+ if (targetOf(p, mandate) !== req.target) continue;
292
+ const fires = (p.constraint ?? []).every((c) => constraintSatisfied(c, req));
293
+ if (fires) {
294
+ return {
295
+ decision: p.enforcement ?? "block",
296
+ reasonCode: p.reasonCode ?? "PROHIBITED",
297
+ matched: { kind: "prohibition", target: p.target }
298
+ };
299
+ }
300
+ }
301
+ const perms = (mandate.permission ?? []).filter((p) => targetOf(p, mandate) === req.target);
302
+ if (perms.length === 0) {
303
+ return {
304
+ decision: "block",
305
+ reasonCode: "NO_PERMISSION_FOR_ACTION",
306
+ matched: { kind: "no-permission", target: req.target }
307
+ };
308
+ }
309
+ for (const p of perms) {
310
+ const failing = (p.constraint ?? []).find((c) => !constraintSatisfied(c, req));
311
+ if (!failing) return { decision: "allow", reasonCode: "AUTHORIZED" };
312
+ }
313
+ const firstFail = (perms[0].constraint ?? []).find((c) => !constraintSatisfied(c, req));
314
+ return {
315
+ decision: firstFail?.onFail ?? "block",
316
+ reasonCode: reasonFor(firstFail),
317
+ matched: { kind: "permission", target: perms[0].target, constraint: firstFail }
318
+ };
319
+ }
320
+ function remainingBudget(b) {
321
+ return Math.max(0, b.cap - b.spent - b.held);
322
+ }
323
+ function canAuthorize(b, amount) {
324
+ return amount >= 0 && amount <= remainingBudget(b);
325
+ }
326
+ function applyHold(b, amount) {
327
+ return { ...b, held: b.held + amount };
328
+ }
329
+ function applyCapture(b, amount) {
330
+ return { cap: b.cap, spent: b.spent + amount, held: Math.max(0, b.held - amount) };
331
+ }
332
+ function releaseHold(b, amount) {
333
+ return { ...b, held: Math.max(0, b.held - amount) };
334
+ }
335
+ function sumEventField(events, type, field) {
336
+ return events.filter((e) => e.type === type).reduce((acc, e) => acc + (typeof e.payload[field] === "number" ? e.payload[field] : 0), 0);
337
+ }
338
+
339
+ // src/policy-core/evaluate.ts
340
+ var PRECEDENCE2 = { allow: 0, escalate: 1, block: 2 };
341
+ function evaluate(input) {
342
+ let decision = "allow";
343
+ let reasonCode = "AUTHORIZED";
344
+ const consider = (d, code) => {
345
+ if (PRECEDENCE2[d] > PRECEDENCE2[decision]) {
346
+ decision = d;
347
+ reasonCode = code;
348
+ }
349
+ };
350
+ const std = evaluateBoundStandards(input.standards ?? [], input.context);
351
+ if (std.decision !== "allow") consider(std.decision, std.reasonCode ?? "STANDARD_RULE");
352
+ const sop = evaluateBoundStandards(input.sops ?? [], input.context);
353
+ if (sop.decision !== "allow") consider(sop.decision, sop.reasonCode ?? "SOP_RULE");
354
+ if (input.mandate && input.mandateRequest) {
355
+ const m = evaluateMandate(input.mandate, input.mandateRequest);
356
+ if (m.decision !== "allow") consider(m.decision, m.reasonCode);
357
+ }
358
+ return { decision, reasonCode, authorizationId: null, remaining: null, proofRef: null };
359
+ }
360
+
361
+ // src/policy-core/canonical.ts
362
+ function buildAuthMessage(f) {
363
+ return `${f.agentDid}|${f.action}|${f.amount}|${f.currency}|${f.merchant ?? ""}|${f.nonce}|${f.issuedAt}`;
364
+ }
365
+
366
+ // src/policy-core/context.ts
367
+ function applySignedLast(unsigned, signed) {
368
+ return { ...unsigned ?? {}, ...signed };
369
+ }
370
+ export {
371
+ ATOM_REGISTRY,
372
+ ATOM_SPECS,
373
+ CATALOGUED_ATOMS,
374
+ applyCapture,
375
+ applyHold,
376
+ applySignedLast,
377
+ buildAuthMessage,
378
+ canAuthorize,
379
+ evaluate,
380
+ evaluateBoundStandards,
381
+ evaluateMandate,
382
+ evaluateStandardRules,
383
+ moleculeFires,
384
+ releaseHold,
385
+ remainingBudget,
386
+ requiredContextFor,
387
+ sumEventField,
388
+ validateMolecules
389
+ };
package/x402.mjs ADDED
@@ -0,0 +1,43 @@
1
+ // GENERATED from backend/src/features/magp/x402-binding.ts — do not edit. Regenerate: npm run build:guard-core
2
+
3
+ // src/features/magp/x402-binding.ts
4
+ function toMinorUnits(amount, decimals) {
5
+ if (!Number.isFinite(amount) || amount < 0) throw new Error("amount must be a non-negative number");
6
+ const factor = 10 ** decimals;
7
+ return String(Math.round(amount * factor));
8
+ }
9
+ function buildPaymentRequirements(input) {
10
+ if (!input.authorizationId) throw new Error("buildPaymentRequirements requires an authorizationId");
11
+ const decimals = input.decimals ?? 6;
12
+ return {
13
+ x402Version: 1,
14
+ accepts: [
15
+ {
16
+ scheme: "exact",
17
+ network: input.network ?? "hedera-testnet",
18
+ maxAmountRequired: toMinorUnits(input.amount, decimals),
19
+ payTo: input.payTo,
20
+ asset: input.asset,
21
+ resource: input.resource,
22
+ extra: { magpAuthorizationId: input.authorizationId, magpAgentDid: input.agentDid }
23
+ }
24
+ ]
25
+ };
26
+ }
27
+ function checkSettlementBinding(requirements, claim) {
28
+ const accept = requirements?.accepts?.[0];
29
+ if (!accept?.extra?.magpAuthorizationId) return { ok: false, reasonCode: "MISSING_BINDING" };
30
+ if (accept.extra.magpAuthorizationId !== claim.authorizationId) {
31
+ return { ok: false, reasonCode: "AUTHORIZATION_MISMATCH" };
32
+ }
33
+ const authorizedMinor = Number(accept.maxAmountRequired);
34
+ const paidMinor = Number(claim.paidAmountMinor);
35
+ if (!Number.isFinite(paidMinor) || paidMinor < 0) return { ok: false, reasonCode: "AMOUNT_INVALID" };
36
+ if (paidMinor > authorizedMinor) return { ok: false, reasonCode: "AMOUNT_MISMATCH" };
37
+ return { ok: true, reasonCode: "BINDING_OK" };
38
+ }
39
+ export {
40
+ buildPaymentRequirements,
41
+ checkSettlementBinding,
42
+ toMinorUnits
43
+ };