@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 +21 -0
- package/README.md +148 -0
- package/agentsafe-mcp-guard.mjs +303 -0
- package/magp-did.mjs +157 -0
- package/magp-policy.mjs +104 -0
- package/package.json +58 -0
- package/policy-core.mjs +490 -0
- package/x402.mjs +43 -0
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
|
+
};
|
package/magp-policy.mjs
ADDED
|
@@ -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
|
+
}
|
package/policy-core.mjs
ADDED
|
@@ -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
|
+
};
|