@metamynd/agentsafe-mcp-guard 0.1.0 → 0.2.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/README.md +37 -2
- package/agentsafe-mcp-guard.mjs +56 -7
- package/package.json +9 -4
- package/policy-core.mjs +17 -7
package/README.md
CHANGED
|
@@ -61,16 +61,51 @@ 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` are checked against what's actually being executed, too — a claim
|
|
87
|
+
alone only proves *some* real, unclaimed authorization exists; without this check, a cheap
|
|
88
|
+
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`).
|
|
91
|
+
|
|
92
|
+
The `authorizationId` has to come from a **real** `guard.authorize()` call on the agent side —
|
|
93
|
+
not `buildSignedRequest()`, which never talks to the network. In practice this usually needs no
|
|
94
|
+
extra agent-side plumbing: `agentsafe-guard`'s default `guardTool()` path already calls the real
|
|
95
|
+
remote `authorize()` for any value-bearing action (`sealValueActions`, on by default), so its
|
|
96
|
+
`authorizationId` is already sitting in the `decision` object `guardTool()` hands your handler —
|
|
97
|
+
thread it through to the signed request you present to this guard.
|
|
98
|
+
|
|
99
|
+
Off by default: it costs a network round trip per value-bearing call, so it's a deliberate
|
|
100
|
+
choice, not a strictly-dominant one. A Service happy with per-request policy re-evaluation alone
|
|
101
|
+
(no replay/cumulative-spend guarantee) can skip it.
|
|
102
|
+
|
|
69
103
|
Run the self-check (handshake + trustless eval, no network):
|
|
70
104
|
|
|
71
105
|
```powershell
|
|
72
106
|
cd integrations\agentsafe-mcp-guard
|
|
73
|
-
node mcp-guard.smoke.mjs
|
|
107
|
+
node mcp-guard.smoke.mjs # PASS when every case matches
|
|
108
|
+
node claim-authorization.smoke.mjs # requireAuthorization: replay, mismatch, fail-closed
|
|
74
109
|
```
|
|
75
110
|
|
|
76
111
|
## 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,34 @@ 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` — the caller MUST
|
|
94
|
+
* compare these to the request actually being executed. A claim alone only proves "some real,
|
|
95
|
+
* unclaimed authorization exists"; without this check, a legitimately-obtained authorization for
|
|
96
|
+
* a small, honest transaction could be presented to unlock a completely different one — the same
|
|
97
|
+
* confused-deputy shape payload binding closes at the request layer, recurring one layer deeper.
|
|
98
|
+
*/
|
|
99
|
+
async function claimAuthorization({ authorizationId } = {}) {
|
|
100
|
+
if (!authorizationId) return { claimed: false, reasonCode: 'AUTHORIZATION_REQUIRED' };
|
|
101
|
+
if (!base) throw new Error('issuerApi is required to claim an authorization');
|
|
102
|
+
try {
|
|
103
|
+
const res = await fetch(`${base}/policy/mandate/authorize/${encodeURIComponent(authorizationId)}/effect/dispatching`, { method: 'POST' });
|
|
104
|
+
const body = await res.json().catch(() => null);
|
|
105
|
+
if (!res.ok) return { claimed: false, reasonCode: body?.message ?? body?.data?.reasonCode ?? `AUTHORIZATION_CLAIM_HTTP_${res.status}` };
|
|
106
|
+
return { claimed: true, agentDid: body?.data?.agentDid, amount: body?.data?.amount, currency: body?.data?.currency };
|
|
107
|
+
} catch (err) {
|
|
108
|
+
return { claimed: false, reasonCode: 'AUTHORIZATION_CLAIM_UNREACHABLE', error: String(err?.message ?? err) };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
78
112
|
async function loadBundle(agentDid) {
|
|
79
113
|
if (typeof fetchBundle === 'function') return fetchBundle(agentDid);
|
|
80
114
|
if (!base) throw new Error('issuerApi (or fetchBundle) is required to load the policy bundle');
|
|
@@ -131,8 +165,9 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
|
|
|
131
165
|
if (!verifyDidSignature(agentDid, message, signature)) {
|
|
132
166
|
return { decision: 'block', reasonCode: 'SIGNATURE_INVALID' };
|
|
133
167
|
}
|
|
134
|
-
// 2. Freshness. (Single-use nonce consumption stays the gate's job — a Service
|
|
135
|
-
// re-check is verification, not a second authorization.
|
|
168
|
+
// 2. Freshness. (Single-use nonce consumption stays the gate's job by default — a Service
|
|
169
|
+
// re-check is verification, not a second authorization. requireAuthorization below is
|
|
170
|
+
// the opt-in exception: it DOES give the Service its own single-use claim.)
|
|
136
171
|
const ts = Date.parse(issuedAt);
|
|
137
172
|
if (Number.isNaN(ts) || Math.abs(Date.now() - ts) > FRESHNESS_MS) {
|
|
138
173
|
return { decision: 'block', reasonCode: 'REQUEST_EXPIRED' };
|
|
@@ -167,10 +202,24 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
|
|
|
167
202
|
const verdict = verdictFromBundle(bundle, { ...signed, itinerary: signed.itinerary ?? {} });
|
|
168
203
|
// Mode ESCALATE floor lifts an otherwise-PERMIT (allow or observe) to human review
|
|
169
204
|
// (escalate outranks observe, so a flag never masks it) — mirrors the backend gate.
|
|
170
|
-
|
|
171
|
-
|
|
205
|
+
const final = (verdict.decision === 'allow' || verdict.decision === 'observe') && modeGate.decision === 'escalate'
|
|
206
|
+
? { ...verdict, decision: 'escalate', reasonCode: modeGate.reasonCode }
|
|
207
|
+
: verdict;
|
|
208
|
+
// 4. Stateful claim (opt-in). Re-evaluating policy per request (above) does not by itself
|
|
209
|
+
// stop REPLAY or CUMULATIVE SPEND past the mandate total — both are the stateful issuer
|
|
210
|
+
// gate's job. Only claim on an actual PERMIT: escalate/block/suspend/quarantine execute
|
|
211
|
+
// nothing, so there is nothing to protect and no reason to spend the hold's single use.
|
|
212
|
+
if (requireAuthorization && (final.decision === 'allow' || final.decision === 'observe')) {
|
|
213
|
+
const claim = await claimAuthorization({ authorizationId: signed.authorizationId });
|
|
214
|
+
if (!claim.claimed) return { decision: 'block', reasonCode: claim.reasonCode };
|
|
215
|
+
// The claim alone only proves SOME real, unclaimed authorization exists — it must also
|
|
216
|
+
// be FOR this agent and these exact values, or a cheap legitimate hold's id could be
|
|
217
|
+
// presented to unlock a completely different, more expensive execution.
|
|
218
|
+
if (claim.agentDid !== undefined && claim.agentDid !== agentDid) return { decision: 'block', reasonCode: 'AUTHORIZATION_AGENT_MISMATCH' };
|
|
219
|
+
if (claim.amount !== undefined && Number(claim.amount) !== Number(amount)) return { decision: 'block', reasonCode: 'AUTHORIZATION_AMOUNT_MISMATCH' };
|
|
220
|
+
if (claim.currency !== undefined && claim.currency !== currency) return { decision: 'block', reasonCode: 'AUTHORIZATION_CURRENCY_MISMATCH' };
|
|
172
221
|
}
|
|
173
|
-
return
|
|
222
|
+
return final;
|
|
174
223
|
} catch (err) {
|
|
175
224
|
return { decision: 'block', reasonCode: 'GUARD_ERROR', error: String(err?.message ?? err) };
|
|
176
225
|
}
|
|
@@ -270,7 +319,7 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
|
|
|
270
319
|
return { settled: true, txHash: result.txHash, reasonCode: 'SETTLED' };
|
|
271
320
|
}
|
|
272
321
|
|
|
273
|
-
return { handshakeChallenge, handshakeVerify, verifyRequest, guardIncomingTool, requirePayment, settle, serviceDid };
|
|
322
|
+
return { handshakeChallenge, handshakeVerify, verifyRequest, guardIncomingTool, requirePayment, settle, claimAuthorization, serviceDid };
|
|
274
323
|
}
|
|
275
324
|
|
|
276
325
|
/**
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metamynd/agentsafe-mcp-guard",
|
|
3
|
-
"version": "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
|
|
3
|
+
"version": "0.2.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 \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",
|
|
7
7
|
"module": "./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"
|
|
@@ -49,8 +49,13 @@
|
|
|
49
49
|
"author": "MetaMynd",
|
|
50
50
|
"license": "MIT",
|
|
51
51
|
"homepage": "https://metamynd.ai/en/developers/spec",
|
|
52
|
+
"repository": {
|
|
53
|
+
"type": "git",
|
|
54
|
+
"url": "git+https://github.com/Metamynd/agentsafe-guard.git",
|
|
55
|
+
"directory": "packages/agentsafe-mcp-guard"
|
|
56
|
+
},
|
|
52
57
|
"bugs": {
|
|
53
|
-
"url": "https://
|
|
58
|
+
"url": "https://github.com/Metamynd/agentsafe-guard/issues"
|
|
54
59
|
},
|
|
55
60
|
"publishConfig": {
|
|
56
61
|
"access": "public"
|
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;
|
|
@@ -334,6 +335,13 @@ function constraintSatisfied(c, req) {
|
|
|
334
335
|
function targetOf(rule, mandate) {
|
|
335
336
|
return rule.target ?? mandate.target;
|
|
336
337
|
}
|
|
338
|
+
function isAuthorityFailure(result) {
|
|
339
|
+
return result.matched?.kind === "expiry" || result.matched?.kind === "no-permission";
|
|
340
|
+
}
|
|
341
|
+
function authorityFailure(mandate, target, now) {
|
|
342
|
+
const result = evaluateMandate(mandate, { target, now, values: {} });
|
|
343
|
+
return isAuthorityFailure(result) ? { ...result, decision: "block" } : null;
|
|
344
|
+
}
|
|
337
345
|
function evaluateMandate(mandate, req) {
|
|
338
346
|
const now = toTime(req.now);
|
|
339
347
|
if (mandate.validFrom && now < toTime(mandate.validFrom)) {
|
|
@@ -392,7 +400,7 @@ function sumEventField(events, type, field) {
|
|
|
392
400
|
}
|
|
393
401
|
|
|
394
402
|
// src/policy-core/evaluate.ts
|
|
395
|
-
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 };
|
|
396
404
|
function evaluate(input) {
|
|
397
405
|
let decision = "allow";
|
|
398
406
|
let reasonCode = "AUTHORIZED";
|
|
@@ -402,14 +410,14 @@ function evaluate(input) {
|
|
|
402
410
|
reasonCode = code;
|
|
403
411
|
}
|
|
404
412
|
};
|
|
413
|
+
const m = input.mandate && input.mandateRequest ? evaluateMandate(input.mandate, input.mandateRequest) : null;
|
|
414
|
+
const authority = m !== null && isAuthorityFailure(m);
|
|
415
|
+
if (m && authority) consider(m.decision, m.reasonCode);
|
|
405
416
|
const std = evaluateBoundStandards(input.standards ?? [], input.context);
|
|
406
417
|
if (std.decision !== "allow") consider(std.decision, std.reasonCode ?? "STANDARD_RULE");
|
|
407
418
|
const sop = evaluateBoundStandards(input.sops ?? [], input.context);
|
|
408
419
|
if (sop.decision !== "allow") consider(sop.decision, sop.reasonCode ?? "SOP_RULE");
|
|
409
|
-
if (
|
|
410
|
-
const m = evaluateMandate(input.mandate, input.mandateRequest);
|
|
411
|
-
if (m.decision !== "allow") consider(m.decision, m.reasonCode);
|
|
412
|
-
}
|
|
420
|
+
if (m && !authority && m.decision !== "allow") consider(m.decision, m.reasonCode);
|
|
413
421
|
return { decision, reasonCode, authorizationId: null, remaining: null, proofRef: null };
|
|
414
422
|
}
|
|
415
423
|
|
|
@@ -472,12 +480,14 @@ export {
|
|
|
472
480
|
applyHold,
|
|
473
481
|
applySignedLast,
|
|
474
482
|
asOperatingMode,
|
|
483
|
+
authorityFailure,
|
|
475
484
|
buildAuthMessage,
|
|
476
485
|
canAuthorize,
|
|
477
486
|
evaluate,
|
|
478
487
|
evaluateBoundStandards,
|
|
479
488
|
evaluateMandate,
|
|
480
489
|
evaluateStandardRules,
|
|
490
|
+
isAuthorityFailure,
|
|
481
491
|
isOperatingMode,
|
|
482
492
|
moleculeFires,
|
|
483
493
|
moreRestrictive,
|