@metamynd/agentsafe-mcp-guard 0.1.2 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -2
- package/agentsafe-mcp-guard.mjs +58 -7
- package/package.json +2 -2
- package/policy-core.mjs +4 -3
package/README.md
CHANGED
|
@@ -61,16 +61,54 @@ const bookFlight = guard.guardIncomingTool('flight-purchase', rawBookFlight);
|
|
|
61
61
|
|
|
62
62
|
`verifyRequest`:
|
|
63
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
|
|
64
|
+
2. checks freshness (single-use nonce stays the gate's job **unless `requireAuthorization` is
|
|
65
|
+
set** — see below, that's the exception);
|
|
65
66
|
3. fetches the agent's policy bundle from the issuer (`GET /policy/bundle/:did`, over TLS);
|
|
66
67
|
4. evaluates Standards → SOPs → mandate with `policy-core` — signed fields applied last, so a
|
|
67
68
|
forged `itinerary` key can't shadow the signed amount/merchant (§6.4.2).
|
|
68
69
|
|
|
70
|
+
### Replay and cumulative spend (`requireAuthorization`)
|
|
71
|
+
|
|
72
|
+
Re-evaluating policy per request (above) proves the request is well-formed and in-policy — it
|
|
73
|
+
does **not** stop a captured, still-fresh request from being replayed, and it can't enforce the
|
|
74
|
+
mandate's TOTAL budget across many separately-legal calls (each is only checked against its own
|
|
75
|
+
per-transaction cap). Both are the stateful issuer gate's job, not something a stateless re-check
|
|
76
|
+
can do on its own.
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
const guard = createMcpGuard({ serviceDid, issuerApi, requireAuthorization: true });
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
When set, a PERMIT verdict (allow/observe) additionally requires `signed.authorizationId` to
|
|
83
|
+
**atomically claim single-use execution** against the issuer (`AUTHORIZED → DISPATCHING`, the
|
|
84
|
+
effect-safety state machine) — a second claim of the same id, whether a genuine replay or a race,
|
|
85
|
+
fails, because that transition is legal exactly once. The claimed hold's own bound
|
|
86
|
+
`agentDid`/`amount`/`currency`/`merchant` are checked against what's actually being executed, too
|
|
87
|
+
— a claim alone only proves *some* real, unclaimed authorization exists; without this check, a
|
|
88
|
+
cheap legitimate hold's id could be presented to unlock a completely different, more expensive
|
|
89
|
+
execution (`AUTHORIZATION_AGENT_MISMATCH` / `AUTHORIZATION_AMOUNT_MISMATCH` /
|
|
90
|
+
`AUTHORIZATION_CURRENCY_MISMATCH` / `AUTHORIZATION_MERCHANT_MISMATCH`). A field the backend
|
|
91
|
+
response omits (e.g. an older, not-yet-migrated deployment with no `merchant` column) is skipped,
|
|
92
|
+
not treated as a mismatch — this degrades gracefully, it doesn't silently under-check going
|
|
93
|
+
forward once the backend does report it.
|
|
94
|
+
|
|
95
|
+
The `authorizationId` has to come from a **real** `guard.authorize()` call on the agent side —
|
|
96
|
+
not `buildSignedRequest()`, which never talks to the network. In practice this usually needs no
|
|
97
|
+
extra agent-side plumbing: `agentsafe-guard`'s default `guardTool()` path already calls the real
|
|
98
|
+
remote `authorize()` for any value-bearing action (`sealValueActions`, on by default), so its
|
|
99
|
+
`authorizationId` is already sitting in the `decision` object `guardTool()` hands your handler —
|
|
100
|
+
thread it through to the signed request you present to this guard.
|
|
101
|
+
|
|
102
|
+
Off by default: it costs a network round trip per value-bearing call, so it's a deliberate
|
|
103
|
+
choice, not a strictly-dominant one. A Service happy with per-request policy re-evaluation alone
|
|
104
|
+
(no replay/cumulative-spend guarantee) can skip it.
|
|
105
|
+
|
|
69
106
|
Run the self-check (handshake + trustless eval, no network):
|
|
70
107
|
|
|
71
108
|
```powershell
|
|
72
109
|
cd integrations\agentsafe-mcp-guard
|
|
73
|
-
node mcp-guard.smoke.mjs
|
|
110
|
+
node mcp-guard.smoke.mjs # PASS when every case matches
|
|
111
|
+
node claim-authorization.smoke.mjs # requireAuthorization: replay, mismatch, fail-closed
|
|
74
112
|
```
|
|
75
113
|
|
|
76
114
|
## 3. Payment binding (x402, §7a)
|
package/agentsafe-mcp-guard.mjs
CHANGED
|
@@ -30,8 +30,14 @@ const FRESHNESS_MS = 5 * 60 * 1000;
|
|
|
30
30
|
* GET /magp/policy/pubkey). When set, the guard VERIFIES the bundle signature + freshness (Phase F,
|
|
31
31
|
* §5.3.2/§5.3.3) and fails closed for value-bearing actions on an unsigned/tampered/stale bundle —
|
|
32
32
|
* so per-request enforcement needs no live MetaMynd. Omit for the legacy hash-addressed + TLS mode.
|
|
33
|
+
* @param {boolean} [cfg.requireAuthorization] when true, a PERMIT verdict (allow/observe) is only
|
|
34
|
+
* actually granted if `signed.authorizationId` atomically claims single-use execution against the
|
|
35
|
+
* stateful issuer gate (see claimAuthorization below) — this is what closes REPLAY and CUMULATIVE
|
|
36
|
+
* SPEND, neither of which the stateless re-check above can enforce on its own. Off by default:
|
|
37
|
+
* it costs a network round trip per value-bearing call, so it's a deliberate choice, not a
|
|
38
|
+
* strictly-dominant one — a Service happy with per-request policy re-evaluation alone can skip it.
|
|
33
39
|
*/
|
|
34
|
-
export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle, policyPublicKey, settlementStore, verifyCapability } = {}) {
|
|
40
|
+
export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle, policyPublicKey, settlementStore, verifyCapability, requireAuthorization = false } = {}) {
|
|
35
41
|
if (!serviceDid) throw new Error('createMcpGuard requires { serviceDid }');
|
|
36
42
|
const base = issuerApi ? issuerApi.replace(/\/$/, '') : null;
|
|
37
43
|
const privateKey = serviceKey
|
|
@@ -75,6 +81,35 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
|
|
|
75
81
|
|
|
76
82
|
// --- Trustless re-evaluation (spec §9.3, §9.6) ---
|
|
77
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Atomically claim a stateful authorization for single execution (AUTHORIZED -> DISPATCHING,
|
|
86
|
+
* the effect-safety state machine in backend/src/features/policy/mandate/effect-machine.ts). A
|
|
87
|
+
* SECOND claim of the SAME authorizationId — a replay, or a race — fails: that transition is only
|
|
88
|
+
* legal once. This is what actually stops replay and enforces the mandate's cumulative budget at
|
|
89
|
+
* a Service, because the authorizationId only exists because the agent's own authorize() call
|
|
90
|
+
* against the stateful issuer gate already checked both (agentsafe-guard.mjs's authorizeLocal()
|
|
91
|
+
* seals any value-bearing action through the real remote authorize() by default).
|
|
92
|
+
*
|
|
93
|
+
* On success, also returns the hold's OWN bound `agentDid`/`amount`/`currency`/`merchant` — the
|
|
94
|
+
* caller MUST compare these to the request actually being executed. A claim alone only proves
|
|
95
|
+
* "some real, unclaimed authorization exists"; without this check, a legitimately-obtained
|
|
96
|
+
* authorization for a small, honest transaction could be presented to unlock a completely
|
|
97
|
+
* different one — the same confused-deputy shape payload binding closes at the request layer,
|
|
98
|
+
* recurring one layer deeper.
|
|
99
|
+
*/
|
|
100
|
+
async function claimAuthorization({ authorizationId } = {}) {
|
|
101
|
+
if (!authorizationId) return { claimed: false, reasonCode: 'AUTHORIZATION_REQUIRED' };
|
|
102
|
+
if (!base) throw new Error('issuerApi is required to claim an authorization');
|
|
103
|
+
try {
|
|
104
|
+
const res = await fetch(`${base}/policy/mandate/authorize/${encodeURIComponent(authorizationId)}/effect/dispatching`, { method: 'POST' });
|
|
105
|
+
const body = await res.json().catch(() => null);
|
|
106
|
+
if (!res.ok) return { claimed: false, reasonCode: body?.message ?? body?.data?.reasonCode ?? `AUTHORIZATION_CLAIM_HTTP_${res.status}` };
|
|
107
|
+
return { claimed: true, agentDid: body?.data?.agentDid, amount: body?.data?.amount, currency: body?.data?.currency, merchant: body?.data?.merchant };
|
|
108
|
+
} catch (err) {
|
|
109
|
+
return { claimed: false, reasonCode: 'AUTHORIZATION_CLAIM_UNREACHABLE', error: String(err?.message ?? err) };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
78
113
|
async function loadBundle(agentDid) {
|
|
79
114
|
if (typeof fetchBundle === 'function') return fetchBundle(agentDid);
|
|
80
115
|
if (!base) throw new Error('issuerApi (or fetchBundle) is required to load the policy bundle');
|
|
@@ -131,8 +166,9 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
|
|
|
131
166
|
if (!verifyDidSignature(agentDid, message, signature)) {
|
|
132
167
|
return { decision: 'block', reasonCode: 'SIGNATURE_INVALID' };
|
|
133
168
|
}
|
|
134
|
-
// 2. Freshness. (Single-use nonce consumption stays the gate's job — a Service
|
|
135
|
-
// re-check is verification, not a second authorization.
|
|
169
|
+
// 2. Freshness. (Single-use nonce consumption stays the gate's job by default — a Service
|
|
170
|
+
// re-check is verification, not a second authorization. requireAuthorization below is
|
|
171
|
+
// the opt-in exception: it DOES give the Service its own single-use claim.)
|
|
136
172
|
const ts = Date.parse(issuedAt);
|
|
137
173
|
if (Number.isNaN(ts) || Math.abs(Date.now() - ts) > FRESHNESS_MS) {
|
|
138
174
|
return { decision: 'block', reasonCode: 'REQUEST_EXPIRED' };
|
|
@@ -167,10 +203,25 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
|
|
|
167
203
|
const verdict = verdictFromBundle(bundle, { ...signed, itinerary: signed.itinerary ?? {} });
|
|
168
204
|
// Mode ESCALATE floor lifts an otherwise-PERMIT (allow or observe) to human review
|
|
169
205
|
// (escalate outranks observe, so a flag never masks it) — mirrors the backend gate.
|
|
170
|
-
|
|
171
|
-
|
|
206
|
+
const final = (verdict.decision === 'allow' || verdict.decision === 'observe') && modeGate.decision === 'escalate'
|
|
207
|
+
? { ...verdict, decision: 'escalate', reasonCode: modeGate.reasonCode }
|
|
208
|
+
: verdict;
|
|
209
|
+
// 4. Stateful claim (opt-in). Re-evaluating policy per request (above) does not by itself
|
|
210
|
+
// stop REPLAY or CUMULATIVE SPEND past the mandate total — both are the stateful issuer
|
|
211
|
+
// gate's job. Only claim on an actual PERMIT: escalate/block/suspend/quarantine execute
|
|
212
|
+
// nothing, so there is nothing to protect and no reason to spend the hold's single use.
|
|
213
|
+
if (requireAuthorization && (final.decision === 'allow' || final.decision === 'observe')) {
|
|
214
|
+
const claim = await claimAuthorization({ authorizationId: signed.authorizationId });
|
|
215
|
+
if (!claim.claimed) return { decision: 'block', reasonCode: claim.reasonCode };
|
|
216
|
+
// The claim alone only proves SOME real, unclaimed authorization exists — it must also
|
|
217
|
+
// be FOR this agent and these exact values, or a cheap legitimate hold's id could be
|
|
218
|
+
// presented to unlock a completely different, more expensive execution.
|
|
219
|
+
if (claim.agentDid !== undefined && claim.agentDid !== agentDid) return { decision: 'block', reasonCode: 'AUTHORIZATION_AGENT_MISMATCH' };
|
|
220
|
+
if (claim.amount !== undefined && Number(claim.amount) !== Number(amount)) return { decision: 'block', reasonCode: 'AUTHORIZATION_AMOUNT_MISMATCH' };
|
|
221
|
+
if (claim.currency !== undefined && claim.currency !== currency) return { decision: 'block', reasonCode: 'AUTHORIZATION_CURRENCY_MISMATCH' };
|
|
222
|
+
if (claim.merchant !== undefined && claim.merchant !== merchant) return { decision: 'block', reasonCode: 'AUTHORIZATION_MERCHANT_MISMATCH' };
|
|
172
223
|
}
|
|
173
|
-
return
|
|
224
|
+
return final;
|
|
174
225
|
} catch (err) {
|
|
175
226
|
return { decision: 'block', reasonCode: 'GUARD_ERROR', error: String(err?.message ?? err) };
|
|
176
227
|
}
|
|
@@ -270,7 +321,7 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
|
|
|
270
321
|
return { settled: true, txHash: result.txHash, reasonCode: 'SETTLED' };
|
|
271
322
|
}
|
|
272
323
|
|
|
273
|
-
return { handshakeChallenge, handshakeVerify, verifyRequest, guardIncomingTool, requirePayment, settle, serviceDid };
|
|
324
|
+
return { handshakeChallenge, handshakeVerify, verifyRequest, guardIncomingTool, requirePayment, settle, claimAuthorization, serviceDid };
|
|
274
325
|
}
|
|
275
326
|
|
|
276
327
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metamynd/agentsafe-mcp-guard",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
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 \u00e2\u20ac\u201d so an agent that ignores its own guard still cannot make your service act.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./agentsafe-mcp-guard.mjs",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"LICENSE"
|
|
24
24
|
],
|
|
25
25
|
"scripts": {
|
|
26
|
-
"test": "node mcp-guard.smoke.mjs && node pay.smoke.mjs && node decision-token.smoke.mjs"
|
|
26
|
+
"test": "node mcp-guard.smoke.mjs && node pay.smoke.mjs && node decision-token.smoke.mjs && node claim-authorization.smoke.mjs"
|
|
27
27
|
},
|
|
28
28
|
"engines": {
|
|
29
29
|
"node": ">=18"
|
package/policy-core.mjs
CHANGED
|
@@ -58,8 +58,9 @@ ${c.output ?? ""}`.toLowerCase();
|
|
|
58
58
|
};
|
|
59
59
|
function notInAllowList(value, allowList) {
|
|
60
60
|
const v = value != null ? String(value).toLowerCase().trim() : "";
|
|
61
|
+
if (v === "") return false;
|
|
61
62
|
const allowed = (Array.isArray(allowList) ? allowList : []).map((x) => String(x).toLowerCase().trim());
|
|
62
|
-
return
|
|
63
|
+
return !allowed.includes(v);
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
// src/policy-core/atom-catalog.ts
|
|
@@ -191,7 +192,7 @@ function requiredContextFor(predicates) {
|
|
|
191
192
|
}
|
|
192
193
|
|
|
193
194
|
// src/policy-core/standards-rules.ts
|
|
194
|
-
var PRECEDENCE = { allow: 0, observe: 1, escalate: 2, block: 3, suspend: 4, quarantine: 5 };
|
|
195
|
+
var PRECEDENCE = { allow: 0, observe: 1, escalate: 2, block: 3, suspend: 4, quarantine: 5, decommission: 6 };
|
|
195
196
|
function atomFires(atom, ctx) {
|
|
196
197
|
const pred = ATOM_REGISTRY[atom.predicate];
|
|
197
198
|
if (!pred) return false;
|
|
@@ -399,7 +400,7 @@ function sumEventField(events, type, field) {
|
|
|
399
400
|
}
|
|
400
401
|
|
|
401
402
|
// src/policy-core/evaluate.ts
|
|
402
|
-
var PRECEDENCE2 = { allow: 0, observe: 1, escalate: 2, block: 3, suspend: 4, quarantine: 5 };
|
|
403
|
+
var PRECEDENCE2 = { allow: 0, observe: 1, escalate: 2, block: 3, suspend: 4, quarantine: 5, decommission: 6 };
|
|
403
404
|
function evaluate(input) {
|
|
404
405
|
let decision = "allow";
|
|
405
406
|
let reasonCode = "AUTHORIZED";
|