@metamynd/agentsafe-mcp-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,148 @@
1
+ # AgentSafe MCP Guard — the service side of MAGP governance
2
+
3
+ The counterpart to [`agentsafe-guard`](../agentsafe-guard) (the agent side). A Service
4
+ (an MCP, e.g. an Amadeus flight API) is an **identity-bearing peer** under MAGP (§4.6): it
5
+ holds its own `did:hedera` + key, completes a **mutual handshake** with the agent, and
6
+ **enforces governance trustlessly** — it independently re-verifies and re-evaluates the
7
+ agent's request instead of trusting the agent's own guard.
8
+
9
+ - **Zero external dependencies.** Node's built-in Ed25519 (`node:crypto`) + `fetch`, plus two
10
+ generated, dependency-free bundles: `policy-core.mjs` (the deterministic evaluator) and
11
+ `magp-did.mjs` (key-in-DID verification). Regenerate with `npm run build:mcp-guard-core`.
12
+ - **No issuer round-trip to verify identity.** The verification key is embedded in the DID
13
+ (§4.1.2), so the guard verifies signatures and handshakes offline.
14
+ - **Fail-closed.** A bad signature, a stale request, a failed bundle fetch, or any error
15
+ yields `block`.
16
+
17
+ ## 1. Mutual handshake (§8.2)
18
+
19
+ Each side proves control of its DID; neither calls the issuer (keys are in the DIDs).
20
+
21
+ ```
22
+ A → B HELLO { fromDid, nonceA }
23
+ B → A CHALLENGE { toDid, nonceB, sigB(nonceA) } ← B proves it controls toDid
24
+ A → B PROVE { sigA(nonceB) } ← A proves it controls fromDid
25
+ B → A READY { channelId }
26
+ ```
27
+
28
+ ```js
29
+ import { createMcpGuard } from './agentsafe-mcp-guard.mjs';
30
+
31
+ const guard = createMcpGuard({
32
+ serviceDid: process.env.SERVICE_DID,
33
+ serviceKey: process.env.SERVICE_KEY, // the MCP's Ed25519 DER key (held only by the MCP)
34
+ issuerApi: 'https://metamynd.ai/api/v1', // where policy bundles are fetched from
35
+ });
36
+
37
+ // Responder side, over your HTTP transport:
38
+ const challenge = guard.handshakeChallenge(hello); // POST /magp/handshake (HELLO → CHALLENGE)
39
+ const ready = guard.handshakeVerify(prove); // POST /magp/handshake (PROVE → READY, or throws)
40
+ ```
41
+
42
+ The agent drives the initiator side with `createGuard(...).handshake()` from `agentsafe-guard`.
43
+
44
+ ## 2. Trustless enforcement (§9.3, §9.6)
45
+
46
+ For a value-bearing tool call the Service re-checks the agent's **signed authorize request**
47
+ and re-evaluates policy against the agent's **issuer-hosted bundle** — the same deterministic
48
+ `policy-core` the gate runs. It never trusts the agent's guard.
49
+
50
+ ```js
51
+ // The agent presents its signed authorize request alongside the tool call.
52
+ const decision = await guard.verifyRequest({
53
+ agentDid, action: 'flight-purchase', amount: 150, currency: 'USD', merchant: 'amadeus',
54
+ itinerary: { riskLevel: 'low' }, nonce, issuedAt, signature,
55
+ });
56
+ // { decision: 'allow' | 'block' | 'escalate', reasonCode }
57
+
58
+ // Or wrap a tool so it runs only after verification allows (throws GovernanceBlocked otherwise):
59
+ const bookFlight = guard.guardIncomingTool('flight-purchase', rawBookFlight);
60
+ ```
61
+
62
+ `verifyRequest`:
63
+ 1. rebuilds the canonical message (§7.3) and verifies the Ed25519 signature via **key-in-DID**;
64
+ 2. checks freshness (single-use nonce stays the gate's job);
65
+ 3. fetches the agent's policy bundle from the issuer (`GET /policy/bundle/:did`, over TLS);
66
+ 4. evaluates Standards → SOPs → mandate with `policy-core` — signed fields applied last, so a
67
+ forged `itinerary` key can't shadow the signed amount/merchant (§6.4.2).
68
+
69
+ Run the self-check (handshake + trustless eval, no network):
70
+
71
+ ```powershell
72
+ cd integrations\agentsafe-mcp-guard
73
+ node mcp-guard.smoke.mjs # PASS when every case matches
74
+ ```
75
+
76
+ ## 3. Payment binding (x402, §7a)
77
+
78
+ MAGP authorizes and reserves budget; it never custodies funds (§7a.5). Value moves over
79
+ **x402**, and this guard **binds each settlement to exactly one authorization** so a payment
80
+ can't be reused, can't exceed the authorized amount, and can't settle without a governance
81
+ authorization behind it. The order is **authorize-before-pay** (§7a.1):
82
+
83
+ ```
84
+ 1. authorize agent → gate: reserve amount → authorizationId (allow)
85
+ 2. request agent → Service tool call
86
+ 3. 402 Service → agent: guard.requirePayment(...) — bound to authorizationId
87
+ 4. verify guard.verifyRequest(...) re-checks the authorization trustlessly (§2)
88
+ 5. pay agent → Service: X-PAYMENT; guard.settle(...) verifies + settles
89
+ 6. fulfil Service performs the action → PNR + tx hash
90
+ 7. capture agent → gate: capture(authorizationId, amountCharged, settlementTxHash)
91
+ ```
92
+
93
+ ```js
94
+ // 3 — demand payment bound to the MAGP authorization (§7a.2):
95
+ const requirements = guard.requirePayment({
96
+ authorizationId, agentDid, amount: 150, payTo: SERVICE_ADDR, asset: 'USDC', resource: '/book-flight',
97
+ });
98
+
99
+ // 5 — verify the binding, then settle via your x402 facilitator (injected):
100
+ const { settled, txHash, reasonCode } = await guard.settle({
101
+ requirements, authorizationId, paidAmountMinor, xPayment,
102
+ settleFn: async ({ xPayment }) => facilitator.settle(xPayment), // returns { settled, txHash }
103
+ });
104
+ ```
105
+
106
+ `settle` returns `AMOUNT_MISMATCH` for an overpayment (§7a.2.2), `SETTLEMENT_REUSED` if the
107
+ authorization already settled (anti-reuse), and `SETTLEMENT_FAILED` if the facilitator can't
108
+ settle — all fail-closed. `requirePayment` / `settle` don't move money; the injected facilitator
109
+ does. On the agent side, `guard.preparePayment(requirements, authorizationId)` refuses an
110
+ **unbound** 402 and one whose authorization doesn't match the agent's own hold.
111
+
112
+ **Durable anti-reuse (SAFR §34).** By default `settle` tracks settled ids in-process (single
113
+ instance). For HA, inject a `settlementStore` that persists the claim — e.g. one backed by
114
+ `POST /magp/settlement/{reserve,finalize,release}` — so "one settlement per authorization" holds
115
+ across instances + restarts. `settle` **reserves before settling** (atomic claim) and **releases**
116
+ a claim whose settlement failed, so a legitimate retry can proceed:
117
+
118
+ ```js
119
+ const guard = createMcpGuard({ serviceDid, settlementStore: {
120
+ reserve: (id) => post('/magp/settlement/reserve', { authorizationId: id }).then(r => ({ ok: r.reserved, reasonCode: r.reasonCode })),
121
+ release: (id) => post('/magp/settlement/release', { authorizationId: id }),
122
+ finalize: (id, { txHash, amountMinor }) => post('/magp/settlement/finalize', { authorizationId: id, txHash, amountMinor }),
123
+ }});
124
+ ```
125
+
126
+ **Commitment-bound capability (decision token, §7.7/§20).** Inject `verifyCapability(signed)` and,
127
+ when a request carries a signed capability, `guardIncomingTool` requires it to authorize **this exact
128
+ transaction** — the host reconstructs the tx and verifies MetaMynd's signature offline (via
129
+ `checkCapabilityBinding` from `magp-bind`), so "authorize $150, execute $5,000" is rejected in the
130
+ prod guard, not just the demo gateway. No verifier configured → opt-in (unchanged).
131
+
132
+ Holds carry an expiry (§7a.4): if not captured, the reservation auto-voids and the budget returns
133
+ to the cap; a party can also void explicitly via `POST /policy/mandate/authorize/:id/void`.
134
+
135
+ Run the self-check:
136
+
137
+ ```powershell
138
+ node pay.smoke.mjs # bound / exact / single-use / fail-closed
139
+ ```
140
+
141
+ ## Where this fits
142
+
143
+ MetaMynd is the **control plane** (issuer/anchor): it exposes the public DID resolver
144
+ (`GET /did/:did`, §4.4) and the policy bundle (`GET /policy/bundle/:did`, §5.3.2). The agent
145
+ guard and this MCP guard are the **data plane**: they discover each other via DID, verify
146
+ mutually, and evaluate governance at the edge — no per-action call to the issuer for a routine
147
+ decision. A value-bearing action is governed by BOTH sides (§9.6): the agent's guard, then this
148
+ guard's trustless re-check.
@@ -0,0 +1,303 @@
1
+ // agentsafe-mcp-guard.mjs — the SERVICE (MCP) side of MAGP governance.
2
+ //
3
+ // A Service is an identity-bearing peer (MAGP §4.6). This guard lets an MCP:
4
+ // 1. complete the mutual handshake (§8.2) — prove it controls its DID and verify
5
+ // the counterparty controls theirs, with NO calls to the issuer (keys are in
6
+ // the DIDs, §4.1.2); and
7
+ // 2. enforce TRUSTLESSLY (§9.3, §9.6) — independently re-evaluate the agent's
8
+ // signed authorize request against the agent's issuer-hosted policy bundle
9
+ // using the same deterministic policy-core the gate runs. The Service never
10
+ // has to trust the agent's own guard.
11
+ //
12
+ // Dependencies are the two generated, zero-external-dependency bundles:
13
+ // policy-core.mjs (deterministic evaluator) and magp-did.mjs (key-in-DID verify).
14
+ import crypto from 'node:crypto';
15
+ import { evaluate, buildAuthMessage, applySignedLast, operatingModeGate } from './policy-core.mjs';
16
+ import { verifyDidSignature } from './magp-did.mjs';
17
+ import { buildPaymentRequirements, checkSettlementBinding } from './x402.mjs';
18
+ import { verifyBundle } from './magp-policy.mjs';
19
+
20
+ /** Freshness window for signed requests and handshake nonces (spec §7.7). */
21
+ const FRESHNESS_MS = 5 * 60 * 1000;
22
+
23
+ /**
24
+ * @param {object} cfg
25
+ * @param {string} cfg.serviceDid the MCP's own did:hedera
26
+ * @param {string} [cfg.serviceKey] the MCP's Ed25519 private key (Hedera DER hex) — needed to sign handshakes
27
+ * @param {string} [cfg.issuerApi] the issuer API base (e.g. https://metamynd.ai/api/v1) to fetch policy bundles
28
+ * @param {(agentDid:string)=>Promise<object>} [cfg.fetchBundle] override bundle loading (tests / caching)
29
+ * @param {string} [cfg.policyPublicKey] MetaMynd's Ed25519 policy-signing key (hex, from
30
+ * GET /magp/policy/pubkey). When set, the guard VERIFIES the bundle signature + freshness (Phase F,
31
+ * §5.3.2/§5.3.3) and fails closed for value-bearing actions on an unsigned/tampered/stale bundle —
32
+ * so per-request enforcement needs no live MetaMynd. Omit for the legacy hash-addressed + TLS mode.
33
+ */
34
+ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle, policyPublicKey, settlementStore, verifyCapability } = {}) {
35
+ if (!serviceDid) throw new Error('createMcpGuard requires { serviceDid }');
36
+ const base = issuerApi ? issuerApi.replace(/\/$/, '') : null;
37
+ const privateKey = serviceKey
38
+ ? crypto.createPrivateKey({ key: Buffer.from(serviceKey, 'hex'), format: 'der', type: 'pkcs8' })
39
+ : null;
40
+ const pending = new Map(); // handshakeId -> { fromDid, nonceB, expiresAt }
41
+
42
+ function sign(message) {
43
+ if (!privateKey) throw new Error('serviceKey is required to sign handshake messages');
44
+ return crypto.sign(null, Buffer.from(message, 'utf8'), privateKey).toString('hex');
45
+ }
46
+
47
+ // --- Mutual handshake, RESPONDER side (spec §8.2) ---
48
+ // A → B HELLO { fromDid, nonceA }
49
+ // B → A CHALLENGE { toDid, nonceB, sigB(nonceA) } ← proves B controls toDid
50
+ // A → B PROVE { sigA(nonceB) } ← proves A controls fromDid
51
+ // B → A READY { channelId }
52
+
53
+ /** Step 1 (B): on HELLO, sign nonceA to prove control of serviceDid, issue nonceB. */
54
+ function handshakeChallenge({ fromDid, nonceA, protoVersion } = {}) {
55
+ if (!fromDid || !nonceA) throw new Error('HELLO requires { fromDid, nonceA }');
56
+ const handshakeId = crypto.randomUUID();
57
+ const nonceB = crypto.randomUUID();
58
+ pending.set(handshakeId, { fromDid, nonceB, expiresAt: Date.now() + FRESHNESS_MS });
59
+ return { handshakeId, toDid: serviceDid, nonceB, sigB: sign(nonceA), protoVersion: protoVersion ?? '1.0' };
60
+ }
61
+
62
+ /** Step 2 (B): on PROVE, verify sigA over nonceB against fromDid's key-in-DID. */
63
+ function handshakeVerify({ handshakeId, sigA } = {}) {
64
+ const st = pending.get(handshakeId);
65
+ pending.delete(handshakeId); // single-use, whatever the outcome
66
+ if (!st) throw new Error('unknown or already-used handshake');
67
+ if (Date.now() > st.expiresAt) throw new Error('handshake expired');
68
+ if (!verifyDidSignature(st.fromDid, st.nonceB, sigA)) {
69
+ const e = new Error('handshake PROVE signature invalid');
70
+ e.name = 'HandshakeFailed';
71
+ throw e;
72
+ }
73
+ return { channelId: crypto.randomUUID(), remoteDid: st.fromDid };
74
+ }
75
+
76
+ // --- Trustless re-evaluation (spec §9.3, §9.6) ---
77
+
78
+ async function loadBundle(agentDid) {
79
+ if (typeof fetchBundle === 'function') return fetchBundle(agentDid);
80
+ if (!base) throw new Error('issuerApi (or fetchBundle) is required to load the policy bundle');
81
+ const res = await fetch(`${base}/policy/bundle/${encodeURIComponent(agentDid)}`);
82
+ const body = await res.json().catch(() => null);
83
+ if (!res.ok || !body?.data) throw new Error(`policy bundle fetch failed (HTTP ${res.status})`);
84
+ // Live containment (Phase 2.4) + operating mode (Phase 2.5b) ride as SIBLINGS of the
85
+ // signed bundle. Expose them as NON-ENUMERABLE props so they never enter the
86
+ // canonicalization verifyBundle signs over (Object.keys skips them) — the signature
87
+ // stays valid, the flags are readable.
88
+ Object.defineProperty(body.data, '__contained', { value: body?.contained ?? null, enumerable: false, configurable: true });
89
+ Object.defineProperty(body.data, '__operatingMode', { value: body?.operatingMode ?? null, enumerable: false, configurable: true });
90
+ return body.data;
91
+ }
92
+
93
+ /** Evaluate the agent's bundle against the request via policy-core (signed fields last). */
94
+ function verdictFromBundle(bundle, req) {
95
+ const { agentDid, action, amount = 0, merchant = '', itinerary = {}, cumulativeSpend = amount, now } = req;
96
+ const mandate = (bundle.mandates ?? []).find((m) => m.action === action)?.document;
97
+ return evaluate({
98
+ standards: (bundle.standards ?? []).map((s) => ({ standardKey: s.key, document: s.document })),
99
+ sops: (bundle.sops ?? []).map((s) => ({ standardKey: `sop:${s.id}`, document: s.document })),
100
+ mandate,
101
+ context: applySignedLast(itinerary, { action, agentDid, amount }),
102
+ mandateRequest: mandate
103
+ ? {
104
+ target: action,
105
+ now: now ?? new Date().toISOString(),
106
+ values: applySignedLast(itinerary, {
107
+ 'mm:payAmount': amount,
108
+ 'mm:cumulativeSpend': cumulativeSpend,
109
+ 'mm:merchant': merchant,
110
+ }),
111
+ }
112
+ : undefined,
113
+ });
114
+ }
115
+
116
+ /**
117
+ * Verify an agent's presented signed authorize request, then re-evaluate policy
118
+ * locally against the agent's issuer-hosted bundle. Fails CLOSED: any bad
119
+ * signature, staleness, fetch error, or evaluation error returns a block.
120
+ * @param {{agentDid,action,amount?,currency?,merchant?,itinerary?,nonce,issuedAt,signature}} signed
121
+ * @returns {Promise<{decision:'allow'|'observe'|'block'|'escalate'|'suspend'|'quarantine',reasonCode:string|null}>}
122
+ */
123
+ async function verifyRequest(signed = {}) {
124
+ try {
125
+ const { agentDid, action, amount = 0, currency = 'USD', merchant = '', nonce, issuedAt, signature } = signed;
126
+ if (!agentDid || !action || !nonce || !issuedAt || !signature) {
127
+ return { decision: 'block', reasonCode: 'MALFORMED_REQUEST' };
128
+ }
129
+ // 1. Signature over the canonical message (§7.3), verified via key-in-DID (§4.1.2).
130
+ const message = buildAuthMessage({ agentDid, action, amount, currency, merchant, nonce, issuedAt });
131
+ if (!verifyDidSignature(agentDid, message, signature)) {
132
+ return { decision: 'block', reasonCode: 'SIGNATURE_INVALID' };
133
+ }
134
+ // 2. Freshness. (Single-use nonce consumption stays the gate's job — a Service
135
+ // re-check is verification, not a second authorization.)
136
+ const ts = Date.parse(issuedAt);
137
+ if (Number.isNaN(ts) || Math.abs(Date.now() - ts) > FRESHNESS_MS) {
138
+ return { decision: 'block', reasonCode: 'REQUEST_EXPIRED' };
139
+ }
140
+ // 3. Re-evaluate against the issuer-hosted bundle (fetched over TLS from the issuer).
141
+ const bundle = await loadBundle(agentDid);
142
+ if (bundle?.subject && bundle.subject !== agentDid) {
143
+ return { decision: 'block', reasonCode: 'BUNDLE_SUBJECT_MISMATCH' };
144
+ }
145
+ // 3a. Containment (Phase 2.4): a server-contained agent is refused regardless of
146
+ // the action — the tool provider will not serve a suspended/quarantined agent.
147
+ const contained = bundle?.__contained;
148
+ if (contained && contained.status) {
149
+ const decision = contained.status === 'quarantined' ? 'quarantine' : 'suspend';
150
+ const reasonCode = contained.status === 'quarantined' ? 'AGENT_QUARANTINED' : 'AGENT_SUSPENDED';
151
+ return { decision, reasonCode };
152
+ }
153
+ // 3a.5. Operating-mode autonomy ladder (Phase 2.5b): the trust-driven posture rides
154
+ // as a non-enumerable sibling. READ_ONLY refuses a value-bearing action up-front;
155
+ // SUPERVISED/RESTRICTED only ESCALATE, applied to the verdict below so a rule block
156
+ // still outranks the floor (most-restrictive-wins, mirroring the gate).
157
+ const modeGate = operatingModeGate(bundle?.__operatingMode?.mode, { amount, riskLevel: signed?.itinerary?.riskLevel });
158
+ if (modeGate.decision === 'block') return { decision: 'block', reasonCode: modeGate.reasonCode };
159
+ // 3b. Signed-bundle verification + risk-tiered fail-closed (Phase F, §5.3.2/§5.3.3). When a
160
+ // policy key is configured, a value-bearing action (amount > 0) MUST fail closed on an
161
+ // unsigned / tampered / stale bundle — so enforcement needs no live MetaMynd. A bad SIGNATURE
162
+ // is a hard fail even for non-value reads.
163
+ if (policyPublicKey) {
164
+ const v = verifyBundle(bundle, { publicKey: policyPublicKey, valueBearing: Number(amount) > 0 });
165
+ if (!v.ok) return { decision: 'block', reasonCode: v.reasonCode };
166
+ }
167
+ const verdict = verdictFromBundle(bundle, { ...signed, itinerary: signed.itinerary ?? {} });
168
+ // Mode ESCALATE floor lifts an otherwise-PERMIT (allow or observe) to human review
169
+ // (escalate outranks observe, so a flag never masks it) — mirrors the backend gate.
170
+ if ((verdict.decision === 'allow' || verdict.decision === 'observe') && modeGate.decision === 'escalate') {
171
+ return { ...verdict, decision: 'escalate', reasonCode: modeGate.reasonCode };
172
+ }
173
+ return verdict;
174
+ } catch (err) {
175
+ return { decision: 'block', reasonCode: 'GUARD_ERROR', error: String(err?.message ?? err) };
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Wrap a Service tool so it runs only after trustless verification allows. The
181
+ * agent's signed request must be passed as the first argument. Throws
182
+ * GovernanceBlocked on any non-allow decision.
183
+ */
184
+ function guardIncomingTool(action, handler) {
185
+ return async (signed, ...rest) => {
186
+ const decision = await verifyRequest({ ...signed, action: signed?.action ?? action });
187
+ // allow/observe both PERMIT the tool call; observe is permit-but-flag (SAFR §11).
188
+ if (decision.decision !== 'allow' && decision.decision !== 'observe') {
189
+ const err = new Error(`MCP guard ${decision.decision.toUpperCase()} "${action}": ${decision.reasonCode}`);
190
+ err.name = 'GovernanceBlocked';
191
+ err.governance = decision;
192
+ throw err;
193
+ }
194
+ // Commitment-bound capability (decision token, §7.7/§20, Phase-4 PR-5). When the request
195
+ // carries a signed capability AND a verifier is configured, the token must authorize THIS
196
+ // exact transaction — the host reconstructs the tx + verifies MetaMynd's signature OFFLINE,
197
+ // so "authorize $150, execute $5,000" (authorize-A / execute-B) is rejected HERE, in the
198
+ // prod guard, not just the demo gateway. No verifier configured → unchanged (opt-in).
199
+ if (signed?.capability && typeof verifyCapability === 'function') {
200
+ let bind;
201
+ try { bind = await verifyCapability(signed); }
202
+ catch (err) { bind = { ok: false, reasonCode: 'CAPABILITY_CHECK_ERROR', error: String(err?.message ?? err) }; }
203
+ if (!bind?.ok) {
204
+ const err = new Error(`MCP guard CAPABILITY "${action}": ${bind?.reasonCode ?? 'CAPABILITY_INVALID'}`);
205
+ err.name = 'GovernanceBlocked';
206
+ err.governance = { decision: 'block', reasonCode: bind?.reasonCode ?? 'CAPABILITY_INVALID' };
207
+ throw err;
208
+ }
209
+ }
210
+ if (decision.decision === 'observe') {
211
+ console.warn(`[mcp-guard] OBSERVE "${action}": ${decision.reasonCode} — served under monitoring`);
212
+ }
213
+ return handler(signed, ...rest);
214
+ };
215
+ }
216
+
217
+ // --- x402 payment binding (spec §7a). MAGP authorizes; x402 moves the money;
218
+ // this binds a settlement to exactly one authorization. No custody (§7a.5). ---
219
+ // Durable anti-reuse (Phase-4 PR-5): a `settlementStore` may be injected to persist the
220
+ // "one settlement per authorization" invariant across instances + restarts (SAFR §34) —
221
+ // e.g. one backed by POST /magp/settlement/{reserve,finalize,release}. The DEFAULT keeps the
222
+ // original in-process behaviour so existing embeds are unchanged; it is single-instance only.
223
+ const store = settlementStore ?? (() => {
224
+ const claimed = new Set();
225
+ return {
226
+ async reserve(id) { if (claimed.has(id)) return { ok: false, reasonCode: 'SETTLEMENT_REUSED' }; claimed.add(id); return { ok: true }; },
227
+ async release(id) { claimed.delete(id); },
228
+ async finalize() { /* the id stays claimed */ },
229
+ };
230
+ })();
231
+
232
+ /**
233
+ * Build the 402 PaymentRequirements bound to a MAGP authorization (§7a.2). The
234
+ * Service returns this after a value-bearing tool call whose authorization it has
235
+ * verified (step 4 of §7a.1), before it will settle.
236
+ * @param {{authorizationId,agentDid,amount,payTo,asset,resource,network?,decimals?}} p
237
+ */
238
+ function requirePayment(p) {
239
+ return buildPaymentRequirements(p);
240
+ }
241
+
242
+ /**
243
+ * Verify a presented settlement is bound to the authorization, then settle via the
244
+ * injected facilitator (§7a.3). Enforces the amount binding (§7a.2.2) and anti-reuse
245
+ * (one settlement per authorizationId). `settleFn` performs the actual x402
246
+ * verify+settle and MUST return { settled:true, txHash } on success.
247
+ * @returns {Promise<{settled:boolean, txHash?:string, reasonCode:string}>}
248
+ */
249
+ async function settle({ requirements, authorizationId, paidAmountMinor, xPayment, settleFn } = {}) {
250
+ const binding = checkSettlementBinding(requirements, { authorizationId, paidAmountMinor });
251
+ if (!binding.ok) return { settled: false, reasonCode: binding.reasonCode };
252
+ if (typeof settleFn !== 'function') return { settled: false, reasonCode: 'NO_FACILITATOR' };
253
+ // ATOMICALLY claim the authorization BEFORE settling (closes the check-then-settle race that
254
+ // the old in-memory Set had: two concurrent settles could both pass a read-only check). A
255
+ // durable store makes this correct across instances/restarts.
256
+ const reserved = await store.reserve(authorizationId);
257
+ if (!reserved?.ok) return { settled: false, reasonCode: reserved?.reasonCode ?? 'SETTLEMENT_REUSED' };
258
+ let result;
259
+ try {
260
+ result = await settleFn({ requirements, authorizationId, paidAmountMinor, xPayment });
261
+ } catch (err) {
262
+ await store.release(authorizationId); // settle threw → free the claim so a legit retry works
263
+ return { settled: false, reasonCode: 'SETTLEMENT_FAILED', error: String(err?.message ?? err) };
264
+ }
265
+ if (!result?.settled || !result?.txHash) {
266
+ await store.release(authorizationId); // facilitator declined → free the claim
267
+ return { settled: false, reasonCode: 'SETTLEMENT_FAILED' };
268
+ }
269
+ await store.finalize?.(authorizationId, { txHash: result.txHash, amountMinor: paidAmountMinor });
270
+ return { settled: true, txHash: result.txHash, reasonCode: 'SETTLED' };
271
+ }
272
+
273
+ return { handshakeChallenge, handshakeVerify, verifyRequest, guardIncomingTool, requirePayment, settle, serviceDid };
274
+ }
275
+
276
+ /**
277
+ * Mutual-handshake INITIATOR helper (the agent/peer side of §8.2). Drives HELLO
278
+ * then, given the responder's CHALLENGE, verifies the responder proved control of
279
+ * its DID before producing PROVE.
280
+ *
281
+ * @param {{fromDid:string, sign:(msg:string)=>string}} p sign() uses the initiator's own key
282
+ */
283
+ export function createHandshakeInitiator({ fromDid, sign } = {}) {
284
+ if (!fromDid || typeof sign !== 'function') throw new Error('createHandshakeInitiator requires { fromDid, sign }');
285
+ return {
286
+ /** Step 0 (A): build HELLO; keep nonceA to bind the responder's CHALLENGE. */
287
+ hello() {
288
+ const nonceA = crypto.randomUUID();
289
+ return { nonceA, message: { fromDid, nonceA, protoVersion: '1.0' } };
290
+ },
291
+ /** Step 3 (A): verify CHALLENGE proves the responder controls toDid, then PROVE. */
292
+ prove({ nonceA, challenge } = {}) {
293
+ const { toDid, nonceB, sigB, handshakeId } = challenge ?? {};
294
+ if (!toDid || !nonceB || !sigB) throw new Error('malformed CHALLENGE');
295
+ if (!verifyDidSignature(toDid, nonceA, sigB)) {
296
+ const e = new Error('responder failed to prove control of its DID');
297
+ e.name = 'HandshakeFailed';
298
+ throw e;
299
+ }
300
+ return { handshakeId, sigA: sign(nonceB), remoteDid: toDid };
301
+ },
302
+ };
303
+ }
package/magp-did.mjs ADDED
@@ -0,0 +1,157 @@
1
+ // GENERATED from backend/src/features/magp/did.ts — do not edit. Regenerate: npm run build:mcp-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
+ var ED25519_MULTICODEC = Uint8Array.of(237, 1);
71
+ function buildDidKey(publicKeyBytes) {
72
+ const prefixed = new Uint8Array(ED25519_MULTICODEC.length + publicKeyBytes.length);
73
+ prefixed.set(ED25519_MULTICODEC, 0);
74
+ prefixed.set(publicKeyBytes, ED25519_MULTICODEC.length);
75
+ return `did:key:${multibaseBase58btc(prefixed)}`;
76
+ }
77
+ function parseDidKey(did) {
78
+ const m = /^did:key:(z[1-9A-HJ-NP-Za-km-z]+)$/.exec(did ?? "");
79
+ if (!m) return null;
80
+ const multibase = m[1];
81
+ let decoded;
82
+ try {
83
+ decoded = base58Decode(multibase.slice(1));
84
+ } catch {
85
+ return null;
86
+ }
87
+ if (decoded.length !== ED25519_MULTICODEC.length + 32) return null;
88
+ if (decoded[0] !== ED25519_MULTICODEC[0] || decoded[1] !== ED25519_MULTICODEC[1]) return null;
89
+ return { method: "key", publicKeyMultibase: multibase, publicKeyBytes: decoded.slice(ED25519_MULTICODEC.length) };
90
+ }
91
+ function didPublicKeyBytes(did) {
92
+ return parseHederaDid(did)?.publicKeyBytes ?? parseDidKey(did)?.publicKeyBytes ?? null;
93
+ }
94
+
95
+ // src/features/magp/did.ts
96
+ var ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
97
+ function ed25519KeyFromRaw(raw) {
98
+ const der = Buffer.concat([ED25519_SPKI_PREFIX, Buffer.from(raw)]);
99
+ return crypto.createPublicKey({ key: der, format: "der", type: "spki" });
100
+ }
101
+ function verifyDidSignature(did, message, signatureHex) {
102
+ const publicKeyBytes = didPublicKeyBytes(did);
103
+ if (!publicKeyBytes) return false;
104
+ try {
105
+ const key = ed25519KeyFromRaw(publicKeyBytes);
106
+ return crypto.verify(null, Buffer.from(message, "utf8"), key, Buffer.from(signatureHex, "hex"));
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
111
+ function buildDidDocument(did, service) {
112
+ const hedera = parseHederaDid(did);
113
+ const key = hedera ? null : parseDidKey(did);
114
+ if (!hedera && !key) return null;
115
+ const publicKeyMultibase = hedera ? hedera.publicKeyMultibase : key.publicKeyMultibase;
116
+ const fragment = hedera ? "#did-root-key" : `#${key.publicKeyMultibase}`;
117
+ const vmId = `${did}${fragment}`;
118
+ const doc = {
119
+ "@context": ["https://www.w3.org/ns/did/v1"],
120
+ id: did,
121
+ controller: did,
122
+ verificationMethod: [
123
+ {
124
+ id: vmId,
125
+ type: "Ed25519VerificationKey2020",
126
+ controller: did,
127
+ publicKeyMultibase
128
+ }
129
+ ],
130
+ authentication: [vmId]
131
+ };
132
+ if (service) {
133
+ doc.service = [
134
+ {
135
+ id: `${did}#magp`,
136
+ type: "MAGPEndpoint",
137
+ serviceEndpoint: service.serviceEndpoint,
138
+ channels: service.channels,
139
+ protoVersions: service.protoVersions
140
+ }
141
+ ];
142
+ }
143
+ return doc;
144
+ }
145
+ export {
146
+ base58,
147
+ base58Decode,
148
+ buildDidDocument,
149
+ buildDidKey,
150
+ buildHederaDid,
151
+ didPublicKeyBytes,
152
+ ed25519KeyFromRaw,
153
+ multibaseBase58btc,
154
+ parseDidKey,
155
+ parseHederaDid,
156
+ verifyDidSignature
157
+ };
@@ -0,0 +1,104 @@
1
+ // magp-policy.mjs — MAGP Phase F: signed policy bundles + staleness + risk-tiered fail-closed.
2
+ //
3
+ // The policy bundle (§5.3.2) binds an agent's DID to the exact Standards/SOPs/mandate governing it,
4
+ // so a guard fetches ONE document and evaluates locally (trustless re-eval, §9.6). Until now the
5
+ // bundle was only hash-addressed + TLS-trusted; this adds a real **Ed25519 signature** over the
6
+ // bundle, so a guard can verify authenticity + integrity + freshness from ANY source — a cache, a
7
+ // CDN, or a Hedera mirror — with **no live MetaMynd call** (the Phase F exit: no MetaMynd dependence
8
+ // for per-request enforcement).
9
+ //
10
+ // Risk-tiered fail-closed (§5.3.3): a value-bearing action MUST fail closed on an unsigned,
11
+ // tampered, or stale (past `maxStaleness`) bundle; a non-value read may proceed (a bad *signature*
12
+ // is always a hard fail). Zero dependencies — node:crypto Ed25519 only.
13
+ import crypto from 'node:crypto';
14
+
15
+ // ── canonicalization (deterministic bytes for signing) ────────────────────────────────────────
16
+ function canonicalJson(v) {
17
+ if (v === null || typeof v !== 'object') return JSON.stringify(v ?? null);
18
+ if (Array.isArray(v)) return '[' + v.map(canonicalJson).join(',') + ']';
19
+ const keys = Object.keys(v).filter((k) => v[k] !== undefined).sort();
20
+ return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(v[k])).join(',') + '}';
21
+ }
22
+ /** The exact bytes signed/verified: the bundle WITHOUT its `proof` field, canonicalized. */
23
+ function signingBytes(bundle) {
24
+ const { proof, ...rest } = bundle;
25
+ return Buffer.from('MAGP-POLICY-BUNDLE-v1\x00' + canonicalJson(rest), 'utf8');
26
+ }
27
+
28
+ // ── keys (node:crypto Ed25519; accept KeyObject or raw hex) ───────────────────────────────────
29
+ const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
30
+ /** Build an Ed25519 public KeyObject from a raw 32-byte hex key (as published by the pubkey endpoint). */
31
+ export function publicKeyFromRawHex(hex) {
32
+ const raw = Buffer.from(String(hex).replace(/^0x/, ''), 'hex');
33
+ return crypto.createPublicKey({ key: Buffer.concat([ED25519_SPKI_PREFIX, raw]), format: 'der', type: 'spki' });
34
+ }
35
+ const toPublicKey = (k) => (typeof k === 'string' ? publicKeyFromRawHex(k) : k);
36
+ /** The raw 32-byte public key (hex) for an Ed25519 KeyObject — what a service publishes. */
37
+ export function rawPublicKeyHex(keyObject) {
38
+ const der = keyObject.export({ format: 'der', type: 'spki' });
39
+ return Buffer.from(der.subarray(der.length - 32)).toString('hex');
40
+ }
41
+
42
+ // ── sign / verify ─────────────────────────────────────────────────────────────────────────────
43
+ /**
44
+ * Sign a policy bundle (issuer side). `privateKey` is an Ed25519 KeyObject. Returns the bundle with
45
+ * a real `proof`: an Ed25519 signature over the bundle, plus the verification key + timestamps.
46
+ */
47
+ export function signBundle(bundle, privateKey, { verificationMethod = null } = {}) {
48
+ const base = { ...bundle };
49
+ delete base.proof;
50
+ const signature = crypto.sign(null, signingBytes(base), privateKey).toString('hex');
51
+ return {
52
+ ...base,
53
+ proof: {
54
+ type: 'Ed25519Signature2020',
55
+ created: bundle.issuedAt ?? null,
56
+ verificationMethod,
57
+ publicKeyHex: rawPublicKeyHex(crypto.createPublicKey(privateKey)),
58
+ signature,
59
+ },
60
+ };
61
+ }
62
+
63
+ /** Parse an ISO-8601 duration like `PT10M` / `PT1H30M` / `PT45S` to milliseconds. */
64
+ export function parseDurationMs(iso) {
65
+ const m = /^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$/.exec(String(iso ?? ''));
66
+ if (!m) return null;
67
+ return ((Number(m[1] || 0) * 3600) + (Number(m[2] || 0) * 60) + Number(m[3] || 0)) * 1000;
68
+ }
69
+
70
+ /**
71
+ * Verify a signed bundle and apply the risk tier. Returns
72
+ * `{ ok, reasonCode, ageMs, maxStalenessMs, signed }`.
73
+ * • a present-but-invalid signature → POLICY_BUNDLE_SIGNATURE_INVALID (hard fail, any tier)
74
+ * • unsigned → POLICY_BUNDLE_UNSIGNED (fail closed only for value-bearing)
75
+ * • stale past maxStaleness → POLICY_BUNDLE_STALE (fail closed only for value-bearing)
76
+ * • otherwise → POLICY_BUNDLE_OK
77
+ *
78
+ * @param {object} opts { publicKey?, nowMs?, valueBearing?, maxStalenessMs? }
79
+ */
80
+ export function verifyBundle(bundle, opts = {}) {
81
+ const nowMs = opts.nowMs ?? Date.now();
82
+ const valueBearing = !!opts.valueBearing;
83
+ const sig = bundle?.proof?.signature;
84
+ // The verifier's trusted key wins; else fall back to the key the bundle advertises (still checked
85
+ // for freshness, but a caller SHOULD pin publicKey to a MetaMynd key it trusts).
86
+ const keyMaterial = opts.publicKey ?? bundle?.proof?.publicKeyHex ?? null;
87
+
88
+ if (!sig || !keyMaterial) {
89
+ return { ok: !valueBearing, reasonCode: 'POLICY_BUNDLE_UNSIGNED', ageMs: null, maxStalenessMs: null, signed: false };
90
+ }
91
+ let sigOk = false;
92
+ try { sigOk = crypto.verify(null, signingBytes(bundle), toPublicKey(keyMaterial), Buffer.from(sig, 'hex')); } catch { sigOk = false; }
93
+ if (!sigOk) return { ok: false, reasonCode: 'POLICY_BUNDLE_SIGNATURE_INVALID', ageMs: null, maxStalenessMs: null, signed: true };
94
+
95
+ const maxStalenessMs = opts.maxStalenessMs ?? parseDurationMs(bundle.maxStaleness) ?? null;
96
+ const issuedMs = Date.parse(bundle.issuedAt);
97
+ const ageMs = Number.isFinite(issuedMs) ? nowMs - issuedMs : null;
98
+ const stale = maxStalenessMs != null && ageMs != null && ageMs > maxStalenessMs;
99
+ if (stale) return { ok: !valueBearing, reasonCode: 'POLICY_BUNDLE_STALE', ageMs, maxStalenessMs, signed: true };
100
+
101
+ return { ok: true, reasonCode: 'POLICY_BUNDLE_OK', ageMs, maxStalenessMs, signed: true };
102
+ }
103
+
104
+ export const _policy = { canonicalJson, signingBytes };
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@metamynd/agentsafe-mcp-guard",
3
+ "version": "0.1.0",
4
+ "description": "Zero-dependency trustless governance for the SERVICE side. An MCP server or API re-verifies a calling agent's signed request against the agent's own published policy — so an agent that ignores its own guard still cannot make your service act.",
5
+ "type": "module",
6
+ "main": "./agentsafe-mcp-guard.mjs",
7
+ "module": "./agentsafe-mcp-guard.mjs",
8
+ "exports": {
9
+ ".": "./agentsafe-mcp-guard.mjs",
10
+ "./policy": "./magp-policy.mjs",
11
+ "./policy-core": "./policy-core.mjs",
12
+ "./did": "./magp-did.mjs",
13
+ "./x402": "./x402.mjs",
14
+ "./package.json": "./package.json"
15
+ },
16
+ "files": [
17
+ "agentsafe-mcp-guard.mjs",
18
+ "magp-policy.mjs",
19
+ "magp-did.mjs",
20
+ "policy-core.mjs",
21
+ "x402.mjs",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "scripts": {
26
+ "test": "node mcp-guard.smoke.mjs && node pay.smoke.mjs && node decision-token.smoke.mjs"
27
+ },
28
+ "engines": {
29
+ "node": ">=18"
30
+ },
31
+ "sideEffects": false,
32
+ "keywords": [
33
+ "mcp",
34
+ "model-context-protocol",
35
+ "ai",
36
+ "agent",
37
+ "agents",
38
+ "governance",
39
+ "authorization",
40
+ "trustless",
41
+ "zero-trust",
42
+ "ed25519",
43
+ "did",
44
+ "x402",
45
+ "magp",
46
+ "metamynd",
47
+ "agentsafe"
48
+ ],
49
+ "author": "MetaMynd",
50
+ "license": "MIT",
51
+ "homepage": "https://metamynd.ai/en/developers/spec",
52
+ "bugs": {
53
+ "url": "https://metamynd.ai/en/support/contact"
54
+ },
55
+ "publishConfig": {
56
+ "access": "public"
57
+ }
58
+ }
@@ -0,0 +1,490 @@
1
+ // GENERATED from backend/src/policy-core — do not edit. Regenerate: npm run build:mcp-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
+ // Total budget: cumulativeSpend is a SERVER-derived, signed-last context field (never
15
+ // shadowable by the agent's itinerary), so this compares already-spent + this amount.
16
+ "cumulative-over": (c, cfg) => Number(c.cumulativeSpend ?? 0) + Number(c.amount ?? 0) > Number(cfg?.limit ?? 0),
17
+ // Fires if any configured term appears in the prompt and/or output text.
18
+ // Used to govern agent responses on content (prohibited claims, sensitive advice).
19
+ "text-matches": (c, cfg) => {
20
+ const hay = `${c.prompt ?? ""}
21
+ ${c.output ?? ""}`.toLowerCase();
22
+ const terms = (cfg?.terms ?? []).map((t) => String(t).toLowerCase());
23
+ return terms.some((t) => t.length > 0 && hay.includes(t));
24
+ },
25
+ // --- Compliance atoms. Allow-list atoms fire when the context field is PRESENT
26
+ // and NOT allowed (consistent with data-source-not-approved: a missing field
27
+ // does not fire — the atom's requiredContext documents what to supply). ---
28
+ "jurisdiction-not-allowed": (c, cfg) => notInAllowList(c.jurisdiction, cfg?.allowed),
29
+ "data-residency-violation": (c, cfg) => notInAllowList(c.dataResidency, cfg?.allowedRegions),
30
+ "model-not-allowed": (c, cfg) => notInAllowList(c.model, cfg?.allowed),
31
+ "tool-not-allowed": (c, cfg) => notInAllowList(c.tool, cfg?.allowed),
32
+ "pii-present": (c) => c.piiPresent === true,
33
+ "rate-limit-exceeded": (c, cfg) => typeof c.callCount === "number" && c.callCount > Number(cfg?.max ?? 0),
34
+ // --- Evidence-quality atoms (SAFR §24). Unlike the allow-list atoms, these fire on ABSENCE:
35
+ // a REQUIRE semantic — "the action must be backed by this evidence; if it isn't, fire"
36
+ // (author with escalate/block). Opt-in: they only run when a rule keys them. ---
37
+ // Fires when any REQUIRED evidence type is not among the attested `evidenceTypes` (missing
38
+ // evidence — including none supplied at all → all required missing → fires).
39
+ "evidence-requirement": (c, cfg) => {
40
+ const required = (cfg?.required ?? []).map((t) => String(t).toLowerCase().trim()).filter(Boolean);
41
+ if (required.length === 0) return false;
42
+ const have = new Set((Array.isArray(c.evidenceTypes) ? c.evidenceTypes : []).map((t) => String(t).toLowerCase().trim()));
43
+ return required.some((r) => !have.has(r));
44
+ },
45
+ // Fires when a required minimum confidence (min > 0) is not met — the attested confidence is
46
+ // below it, or absent (a required confidence that was never supplied fails the bar). A min of
47
+ // 0 / unset is no requirement and never fires.
48
+ "evidence-confidence-below": (c, cfg) => {
49
+ const min = Number(cfg?.min ?? 0);
50
+ if (!(min > 0)) return false;
51
+ return typeof c.evidenceConfidence !== "number" || c.evidenceConfidence < min;
52
+ },
53
+ // Trust guidance (MetaMynd Trust Index / HCS-28). Fires when the counterparty's trust score is
54
+ // below a soft REVIEW line — intended to author an ESCALATE (route to a human), NOT a hard block.
55
+ // The score is server-derived (signed-last) so the agent's itinerary can't fake it; when no score
56
+ // is present (e.g. no counterparty resolved) the atom simply does not fire — no guidance.
57
+ "hol-trust-below-review": (c, cfg) => typeof c.holTrustScore === "number" && c.holTrustScore < Number(cfg?.reviewBelow ?? 60)
58
+ };
59
+ function notInAllowList(value, allowList) {
60
+ const v = value != null ? String(value).toLowerCase().trim() : "";
61
+ const allowed = (Array.isArray(allowList) ? allowList : []).map((x) => String(x).toLowerCase().trim());
62
+ return v !== "" && allowed.length > 0 && !allowed.includes(v);
63
+ }
64
+
65
+ // src/policy-core/atom-catalog.ts
66
+ var ATOM_SPECS = [
67
+ {
68
+ predicate: "amount-over",
69
+ label: "Per-transaction amount over limit",
70
+ description: "Fires when a single action amount exceeds a configured limit (per-transaction cap).",
71
+ config: [{ key: "limit", type: "number", required: true, description: "Maximum allowed amount for one transaction" }],
72
+ requiredContext: ["amount"]
73
+ },
74
+ {
75
+ predicate: "cumulative-over",
76
+ label: "Total budget over limit",
77
+ description: "Fires when cumulative spend (already-spent + this transaction) exceeds a configured total budget.",
78
+ config: [{ key: "limit", type: "number", required: true, description: "Maximum total budget across all transactions" }],
79
+ requiredContext: ["amount"]
80
+ },
81
+ {
82
+ predicate: "risk-at-or-above",
83
+ label: "Risk at or above level",
84
+ description: "Fires when the assessed risk level is at or above the configured threshold.",
85
+ config: [
86
+ {
87
+ key: "level",
88
+ type: "enum",
89
+ required: true,
90
+ description: "Threshold risk level",
91
+ options: ["low", "medium", "high", "critical"]
92
+ }
93
+ ],
94
+ requiredContext: ["riskLevel"]
95
+ },
96
+ {
97
+ predicate: "data-source-not-approved",
98
+ label: "Data source not approved",
99
+ description: "Fires when the action uses a data source not on the approved list.",
100
+ config: [
101
+ { key: "approved", type: "string[]", required: true, description: "Allow-list of approved data source ids" }
102
+ ],
103
+ requiredContext: ["dataSourceId"]
104
+ },
105
+ {
106
+ predicate: "consent-missing",
107
+ label: "Consent missing",
108
+ description: "Fires when explicit consent is absent for the action.",
109
+ config: [],
110
+ requiredContext: ["consent"]
111
+ },
112
+ {
113
+ predicate: "text-matches",
114
+ label: "Text contains prohibited terms",
115
+ description: "Fires when the prompt or output contains any of the configured terms.",
116
+ config: [{ key: "terms", type: "string[]", required: true, description: "Terms that must not appear" }],
117
+ requiredContext: ["prompt", "output"]
118
+ },
119
+ {
120
+ predicate: "jurisdiction-not-allowed",
121
+ label: "Jurisdiction not allowed",
122
+ description: "Fires when the action's jurisdiction is not on the allow-list.",
123
+ config: [{ key: "allowed", type: "string[]", required: true, description: "Allowed jurisdictions (e.g. US, MY, EU)" }],
124
+ requiredContext: ["jurisdiction"]
125
+ },
126
+ {
127
+ predicate: "data-residency-violation",
128
+ label: "Data residency violation",
129
+ description: "Fires when data would be processed in a region not on the allow-list.",
130
+ config: [{ key: "allowedRegions", type: "string[]", required: true, description: "Allowed processing regions" }],
131
+ requiredContext: ["dataResidency"]
132
+ },
133
+ {
134
+ predicate: "model-not-allowed",
135
+ label: "LLM model not allowed",
136
+ description: "Fires when the agent uses an LLM model not on the approved list.",
137
+ config: [{ key: "allowed", type: "string[]", required: true, description: "Approved model ids" }],
138
+ requiredContext: ["model"]
139
+ },
140
+ {
141
+ predicate: "tool-not-allowed",
142
+ label: "Tool not allowed",
143
+ description: "Fires when the agent invokes a tool/function not on the approved list.",
144
+ config: [{ key: "allowed", type: "string[]", required: true, description: "Approved tool names" }],
145
+ requiredContext: ["tool"]
146
+ },
147
+ {
148
+ predicate: "pii-present",
149
+ label: "PII present",
150
+ description: "Fires when the action is flagged as involving personal data (PII).",
151
+ config: [],
152
+ requiredContext: ["piiPresent"]
153
+ },
154
+ {
155
+ predicate: "rate-limit-exceeded",
156
+ label: "Rate limit exceeded",
157
+ description: "Fires when the rolling call count exceeds a configured maximum.",
158
+ config: [{ key: "max", type: "number", required: true, description: "Maximum allowed calls" }],
159
+ requiredContext: ["callCount"]
160
+ },
161
+ {
162
+ predicate: "hol-trust-below-review",
163
+ label: "Counterparty trust below review line",
164
+ description: "Routes to human review when the counterparty's MetaMynd Trust Index (HCS-28) score is below a soft review line. Guidance, not a hard block \u2014 author it with an ESCALATE decision. The score is resolved server-side; no counterparty score \u2192 the atom does not fire.",
165
+ config: [{ key: "reviewBelow", type: "number", required: true, description: "Trust score (0\u2013100) below which a human is asked to decide" }],
166
+ requiredContext: ["holTrustScore"]
167
+ },
168
+ {
169
+ predicate: "evidence-requirement",
170
+ label: "Required evidence missing",
171
+ description: "Fires when the action is not backed by every REQUIRED evidence type the agent attests to in `evidenceTypes` (missing evidence \u2014 including none supplied). A REQUIRE control (SAFR \xA724): author it with ESCALATE or BLOCK so an under-evidenced action is stopped or reviewed.",
172
+ config: [{ key: "required", type: "string[]", required: true, description: "Evidence types that must all be present (e.g. kyc, source-doc, signature)" }],
173
+ requiredContext: ["evidenceTypes"]
174
+ },
175
+ {
176
+ predicate: "evidence-confidence-below",
177
+ label: "Evidence confidence below minimum",
178
+ description: "Fires when the attested evidence confidence is below a required minimum \u2014 or absent (SAFR \xA724). A min of 0 / unset is no requirement. Author with ESCALATE to route low-confidence actions to review.",
179
+ config: [{ key: "min", type: "number", required: true, description: "Minimum evidence confidence (0\u20131) required" }],
180
+ requiredContext: ["evidenceConfidence"]
181
+ }
182
+ ];
183
+ var CATALOGUED_ATOMS = ATOM_SPECS.filter((s) => !!ATOM_REGISTRY[s.predicate]);
184
+ function requiredContextFor(predicates) {
185
+ const fields = /* @__PURE__ */ new Set();
186
+ for (const p of predicates) {
187
+ const spec = ATOM_SPECS.find((s) => s.predicate === p);
188
+ for (const f of spec?.requiredContext ?? []) fields.add(f);
189
+ }
190
+ return [...fields].sort();
191
+ }
192
+
193
+ // src/policy-core/standards-rules.ts
194
+ var PRECEDENCE = { allow: 0, observe: 1, escalate: 2, block: 3, suspend: 4, quarantine: 5 };
195
+ function atomFires(atom, ctx) {
196
+ const pred = ATOM_REGISTRY[atom.predicate];
197
+ if (!pred) return false;
198
+ try {
199
+ return !!pred(ctx, atom.config);
200
+ } catch (err) {
201
+ console.warn(
202
+ `[standards] atom '${atom.predicate}' threw during evaluation (treated as not-firing):`,
203
+ err instanceof Error ? err.message : err
204
+ );
205
+ return false;
206
+ }
207
+ }
208
+ function moleculeFires(m, ctx) {
209
+ if (!m.atoms || m.atoms.length === 0) return false;
210
+ const results = m.atoms.map((a) => atomFires(a, ctx));
211
+ switch (m.combinator) {
212
+ case "all":
213
+ return results.every(Boolean);
214
+ case "any":
215
+ return results.some(Boolean);
216
+ case "none":
217
+ return !results.some(Boolean);
218
+ default:
219
+ return false;
220
+ }
221
+ }
222
+ function evaluateStandardRules(molecules, ctx, standardKey = null) {
223
+ let best = null;
224
+ for (const m of molecules ?? []) {
225
+ if (moleculeFires(m, ctx)) {
226
+ if (!best || PRECEDENCE[m.decision] > PRECEDENCE[best.decision]) {
227
+ best = { decision: m.decision, reasonCode: m.reasonCode, id: m.id };
228
+ }
229
+ }
230
+ }
231
+ if (!best) return { decision: "allow", reasonCode: null, firedMoleculeId: null, standardKey };
232
+ return { decision: best.decision, reasonCode: best.reasonCode, firedMoleculeId: best.id, standardKey };
233
+ }
234
+ function evaluateBoundStandards(standards, ctx) {
235
+ let best = { decision: "allow", reasonCode: null, firedMoleculeId: null, standardKey: null };
236
+ for (const s of standards) {
237
+ const r = evaluateStandardRules(s.document?.molecules, ctx, s.standardKey);
238
+ if (PRECEDENCE[r.decision] > PRECEDENCE[best.decision]) best = r;
239
+ }
240
+ return best;
241
+ }
242
+ function configValueValid(field, value) {
243
+ switch (field.type) {
244
+ case "number":
245
+ return typeof value === "number" && Number.isFinite(value);
246
+ case "string":
247
+ return typeof value === "string";
248
+ case "string[]":
249
+ return Array.isArray(value) && value.every((v) => typeof v === "string");
250
+ case "enum":
251
+ return typeof value === "string" && (field.options ?? []).includes(value);
252
+ default:
253
+ return true;
254
+ }
255
+ }
256
+ function validateAtomConfig(predicate, config) {
257
+ const spec = ATOM_SPECS.find((s) => s.predicate === predicate);
258
+ if (!spec) return [];
259
+ const errors = [];
260
+ const cfg = config ?? {};
261
+ for (const field of spec.config) {
262
+ const present = cfg[field.key] !== void 0 && cfg[field.key] !== null;
263
+ if (!present) {
264
+ if (field.required) errors.push(`atom '${predicate}' missing required config '${field.key}'`);
265
+ continue;
266
+ }
267
+ if (!configValueValid(field, cfg[field.key])) {
268
+ errors.push(`atom '${predicate}' config '${field.key}' must be a ${field.type}`);
269
+ }
270
+ }
271
+ return errors;
272
+ }
273
+ function validateMolecules(molecules) {
274
+ const issues = [];
275
+ for (const m of molecules ?? []) {
276
+ if (!m.id) issues.push({ moleculeId: "(missing id)", message: "molecule is missing an id" });
277
+ if (!["all", "any", "none"].includes(m.combinator)) {
278
+ issues.push({ moleculeId: m.id, message: `invalid combinator '${m.combinator}' (all|any|none)` });
279
+ }
280
+ if (!["observe", "block", "escalate", "suspend", "quarantine"].includes(m.decision)) {
281
+ issues.push({ moleculeId: m.id, message: `invalid decision '${m.decision}' (observe|block|escalate|suspend|quarantine)` });
282
+ }
283
+ if (!m.reasonCode) issues.push({ moleculeId: m.id, message: "molecule is missing a reasonCode" });
284
+ if (!m.atoms || m.atoms.length === 0) {
285
+ issues.push({ moleculeId: m.id, message: "molecule has no atoms" });
286
+ }
287
+ for (const a of m.atoms ?? []) {
288
+ if (!ATOM_REGISTRY[a.predicate]) {
289
+ issues.push({ moleculeId: m.id, message: `unknown atom predicate '${a.predicate}'` });
290
+ continue;
291
+ }
292
+ for (const err of validateAtomConfig(a.predicate, a.config)) {
293
+ issues.push({ moleculeId: m.id, message: err });
294
+ }
295
+ }
296
+ }
297
+ return { ok: issues.length === 0, issues };
298
+ }
299
+
300
+ // src/policy-core/mandate-eval.ts
301
+ var toNum = (v) => typeof v === "number" ? v : Number(v);
302
+ var toArray = (v) => Array.isArray(v) ? v : v === void 0 || v === null ? [] : [v];
303
+ var toTime = (v) => Date.parse(String(v));
304
+ var OPERATORS = {
305
+ eq: (l, r) => l === r,
306
+ neq: (l, r) => l !== r,
307
+ lt: (l, r) => toNum(l) < toNum(r),
308
+ lteq: (l, r) => toNum(l) <= toNum(r),
309
+ gt: (l, r) => toNum(l) > toNum(r),
310
+ gteq: (l, r) => toNum(l) >= toNum(r),
311
+ isAnyOf: (l, r) => toArray(r).includes(l),
312
+ isNoneOf: (l, r) => !toArray(r).includes(l),
313
+ isPartOf: (l, r) => toArray(r).includes(l),
314
+ before: (l, r) => toTime(l) < toTime(r),
315
+ after: (l, r) => toTime(l) > toTime(r)
316
+ };
317
+ var REASON_BY_OPERAND = {
318
+ "mm:payAmount": "SPEND_LIMIT_EXCEEDED",
319
+ "mm:cumulativeSpend": "SPEND_LIMIT_EXCEEDED",
320
+ "mm:merchant": "MERCHANT_NOT_ALLOWED",
321
+ "mm:route": "ROUTE_NOT_ALLOWED",
322
+ "mm:counterparty": "COUNTERPARTY_NOT_ALLOWED"
323
+ };
324
+ function reasonFor(constraint) {
325
+ if (!constraint) return "CONSTRAINT_FAILED";
326
+ return REASON_BY_OPERAND[constraint.leftOperand] ?? `CONSTRAINT_FAILED:${constraint.leftOperand}`;
327
+ }
328
+ function constraintSatisfied(c, req) {
329
+ const op = OPERATORS[c.operator];
330
+ if (!op) return false;
331
+ const left = Object.prototype.hasOwnProperty.call(req.values, c.leftOperand) ? req.values[c.leftOperand] : void 0;
332
+ return op(left, c.rightOperand);
333
+ }
334
+ function targetOf(rule, mandate) {
335
+ return rule.target ?? mandate.target;
336
+ }
337
+ function evaluateMandate(mandate, req) {
338
+ const now = toTime(req.now);
339
+ if (mandate.validFrom && now < toTime(mandate.validFrom)) {
340
+ return { decision: "block", reasonCode: "MANDATE_NOT_YET_VALID", matched: { kind: "expiry" } };
341
+ }
342
+ if (mandate.validUntil && now > toTime(mandate.validUntil)) {
343
+ return { decision: "block", reasonCode: "MANDATE_EXPIRED", matched: { kind: "expiry" } };
344
+ }
345
+ for (const p of mandate.prohibition ?? []) {
346
+ if (targetOf(p, mandate) !== req.target) continue;
347
+ const fires = (p.constraint ?? []).every((c) => constraintSatisfied(c, req));
348
+ if (fires) {
349
+ return {
350
+ decision: p.enforcement ?? "block",
351
+ reasonCode: p.reasonCode ?? "PROHIBITED",
352
+ matched: { kind: "prohibition", target: p.target }
353
+ };
354
+ }
355
+ }
356
+ const perms = (mandate.permission ?? []).filter((p) => targetOf(p, mandate) === req.target);
357
+ if (perms.length === 0) {
358
+ return {
359
+ decision: "block",
360
+ reasonCode: "NO_PERMISSION_FOR_ACTION",
361
+ matched: { kind: "no-permission", target: req.target }
362
+ };
363
+ }
364
+ for (const p of perms) {
365
+ const failing = (p.constraint ?? []).find((c) => !constraintSatisfied(c, req));
366
+ if (!failing) return { decision: "allow", reasonCode: "AUTHORIZED" };
367
+ }
368
+ const firstFail = (perms[0].constraint ?? []).find((c) => !constraintSatisfied(c, req));
369
+ return {
370
+ decision: firstFail?.onFail ?? "block",
371
+ reasonCode: reasonFor(firstFail),
372
+ matched: { kind: "permission", target: perms[0].target, constraint: firstFail }
373
+ };
374
+ }
375
+ function remainingBudget(b) {
376
+ return Math.max(0, b.cap - b.spent - b.held);
377
+ }
378
+ function canAuthorize(b, amount) {
379
+ return amount >= 0 && amount <= remainingBudget(b);
380
+ }
381
+ function applyHold(b, amount) {
382
+ return { ...b, held: b.held + amount };
383
+ }
384
+ function applyCapture(b, amount) {
385
+ return { cap: b.cap, spent: b.spent + amount, held: Math.max(0, b.held - amount) };
386
+ }
387
+ function releaseHold(b, amount) {
388
+ return { ...b, held: Math.max(0, b.held - amount) };
389
+ }
390
+ function sumEventField(events, type, field) {
391
+ return events.filter((e) => e.type === type).reduce((acc, e) => acc + (typeof e.payload[field] === "number" ? e.payload[field] : 0), 0);
392
+ }
393
+
394
+ // src/policy-core/evaluate.ts
395
+ var PRECEDENCE2 = { allow: 0, observe: 1, escalate: 2, block: 3, suspend: 4, quarantine: 5 };
396
+ function evaluate(input) {
397
+ let decision = "allow";
398
+ let reasonCode = "AUTHORIZED";
399
+ const consider = (d, code) => {
400
+ if (PRECEDENCE2[d] > PRECEDENCE2[decision]) {
401
+ decision = d;
402
+ reasonCode = code;
403
+ }
404
+ };
405
+ const std = evaluateBoundStandards(input.standards ?? [], input.context);
406
+ if (std.decision !== "allow") consider(std.decision, std.reasonCode ?? "STANDARD_RULE");
407
+ const sop = evaluateBoundStandards(input.sops ?? [], input.context);
408
+ if (sop.decision !== "allow") consider(sop.decision, sop.reasonCode ?? "SOP_RULE");
409
+ if (input.mandate && input.mandateRequest) {
410
+ const m = evaluateMandate(input.mandate, input.mandateRequest);
411
+ if (m.decision !== "allow") consider(m.decision, m.reasonCode);
412
+ }
413
+ return { decision, reasonCode, authorizationId: null, remaining: null, proofRef: null };
414
+ }
415
+
416
+ // src/policy-core/canonical.ts
417
+ function buildAuthMessage(f) {
418
+ return `${f.agentDid}|${f.action}|${f.amount}|${f.currency}|${f.merchant ?? ""}|${f.nonce}|${f.issuedAt}`;
419
+ }
420
+
421
+ // src/policy-core/context.ts
422
+ function applySignedLast(unsigned, signed) {
423
+ return { ...unsigned ?? {}, ...signed };
424
+ }
425
+
426
+ // src/policy-core/operating-mode.ts
427
+ var MODE_RANK = {
428
+ read_only: 0,
429
+ restricted: 1,
430
+ supervised: 2,
431
+ autonomous: 3
432
+ };
433
+ var MODES_BY_RANK = ["read_only", "restricted", "supervised", "autonomous"];
434
+ function isOperatingMode(v) {
435
+ return typeof v === "string" && Object.prototype.hasOwnProperty.call(MODE_RANK, v);
436
+ }
437
+ function asOperatingMode(v) {
438
+ return isOperatingMode(v) ? v : "autonomous";
439
+ }
440
+ function moreRestrictive(a, b) {
441
+ return MODE_RANK[a] <= MODE_RANK[b] ? a : b;
442
+ }
443
+ var SUPERVISED_AMOUNT_CAP = 100;
444
+ var RISK_RANK2 = { low: 0, medium: 1, high: 2, critical: 3 };
445
+ function riskAtOrAboveHigh(riskLevel) {
446
+ const r = typeof riskLevel === "string" ? RISK_RANK2[riskLevel.toLowerCase()] : void 0;
447
+ return r !== void 0 && r >= RISK_RANK2.high;
448
+ }
449
+ function operatingModeGate(mode, ctx) {
450
+ const m = asOperatingMode(mode);
451
+ const valueBearing = (ctx.amount ?? 0) > 0;
452
+ if (!valueBearing || m === "autonomous") return { decision: "allow", reasonCode: null };
453
+ switch (m) {
454
+ case "read_only":
455
+ return { decision: "block", reasonCode: "MODE_READ_ONLY" };
456
+ case "restricted":
457
+ return { decision: "escalate", reasonCode: "MODE_RESTRICTED_REVIEW" };
458
+ case "supervised":
459
+ return riskAtOrAboveHigh(ctx.riskLevel) || (ctx.amount ?? 0) >= SUPERVISED_AMOUNT_CAP ? { decision: "escalate", reasonCode: "MODE_SUPERVISED_REVIEW" } : { decision: "allow", reasonCode: null };
460
+ default:
461
+ return { decision: "allow", reasonCode: null };
462
+ }
463
+ }
464
+ export {
465
+ ATOM_REGISTRY,
466
+ ATOM_SPECS,
467
+ CATALOGUED_ATOMS,
468
+ MODES_BY_RANK,
469
+ MODE_RANK,
470
+ SUPERVISED_AMOUNT_CAP,
471
+ applyCapture,
472
+ applyHold,
473
+ applySignedLast,
474
+ asOperatingMode,
475
+ buildAuthMessage,
476
+ canAuthorize,
477
+ evaluate,
478
+ evaluateBoundStandards,
479
+ evaluateMandate,
480
+ evaluateStandardRules,
481
+ isOperatingMode,
482
+ moleculeFires,
483
+ moreRestrictive,
484
+ operatingModeGate,
485
+ releaseHold,
486
+ remainingBudget,
487
+ requiredContextFor,
488
+ sumEventField,
489
+ validateMolecules
490
+ };
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:mcp-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
+ };