@metamynd/agentsafe-mcp-guard 0.3.3 → 0.3.6
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 +58 -3
- package/agentsafe-mcp-guard.mjs +55 -9
- package/package.json +1 -1
- package/policy-core.mjs +8 -7
package/README.md
CHANGED
|
@@ -80,13 +80,55 @@ server time, a pre-signing window rather than ordinary clock skew. `issuedAt` ma
|
|
|
80
80
|
to the freshness window (network/processing delay) but lead by no more than 30 seconds (clock
|
|
81
81
|
skew only).
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
**0.3.4 — a mandate's currency check no longer lets a PROHIBITION be dodged by relabeling
|
|
84
|
+
the currency.** A payAmount/cumulativeSpend constraint issued with a `unit` (currency) is
|
|
85
|
+
only satisfied in that currency — correct for a PERMISSION (fail closed to deny on a
|
|
86
|
+
mismatch), but a prohibition only fires when every one of its own constraints is satisfied,
|
|
87
|
+
so the identical "mismatch → not satisfied" rule let a prohibition like `payAmount gteq 1000
|
|
88
|
+
unit USD` be silently skipped by declaring any other currency, including a mere case
|
|
89
|
+
difference (`'usd'` vs `'USD'`). The currency comparison is also now case-insensitive.
|
|
90
|
+
|
|
91
|
+
**0.3.5 — a degraded claim now warns instead of only being silently tolerated.**
|
|
92
|
+
`claimAuthorization()`'s per-field cross-checks each skip when the issuer's claim response
|
|
93
|
+
omits that field — a deliberate, documented rolling-upgrade tolerance for a Service pinned
|
|
94
|
+
against an older backend whose response predates one of these fields existing at all. That
|
|
95
|
+
tolerance was never meant to also mask a REGRESSION on an otherwise-current backend: this
|
|
96
|
+
version logs (`console.warn`) whenever a value-bearing request's claim response omits
|
|
97
|
+
`agentDid`/`amount`/`currency`, or a request that signed a real `merchant` gets a claim
|
|
98
|
+
response that omits it — the one place a future backend change could quietly re-open the
|
|
99
|
+
confused-deputy gap this check exists to close, with nothing else here able to notice. The
|
|
100
|
+
decision is unchanged (still tolerated, not blocked) — this is visibility, not a new refusal.
|
|
101
|
+
|
|
102
|
+
**0.3.6 — two gaps a security review found in what this guard actually enforces, not just what
|
|
103
|
+
its docs claimed.** First: `requireAuthorization`'s doc only ever named replay and cumulative
|
|
104
|
+
spend as what it closes, but rate limits, circuit breakers, and spend-pattern anomaly detection
|
|
105
|
+
are equally stateful and equally invisible to the stateless bundle re-check — the doc undersold
|
|
106
|
+
its own scope. A value-bearing call permitted with `requireAuthorization` off now logs a warning
|
|
107
|
+
naming all five. Second: `guardIncomingTool`'s capability check only ran when the CALLER chose to
|
|
108
|
+
include `signed.capability` — an agent could simply omit it and the "authorize $150, execute
|
|
109
|
+
$5,000" protection never engaged, verifier configured or not. New `requireCapability: true` makes
|
|
110
|
+
an omitted capability a hard block (`CAPABILITY_REQUIRED`) instead of a silent pass-through. Both
|
|
111
|
+
off by default — existing embeds are unchanged.
|
|
112
|
+
|
|
113
|
+
### Replay, cumulative spend, rate limits, breakers, spend anomalies (`requireAuthorization`)
|
|
84
114
|
|
|
85
115
|
Re-evaluating policy per request (above) proves the request is well-formed and in-policy — it
|
|
86
116
|
does **not** stop a captured, still-fresh request from being replayed, and it can't enforce the
|
|
87
117
|
mandate's TOTAL budget across many separately-legal calls (each is only checked against its own
|
|
88
|
-
per-transaction cap).
|
|
89
|
-
|
|
118
|
+
per-transaction cap). Neither is something a stateless re-check can do on its own: both, like
|
|
119
|
+
rate limits, circuit breakers, and spend-pattern anomaly detection, key off the agent's history
|
|
120
|
+
on the issuer's side, which never travels to this guard's stateless bundle re-check. **All of
|
|
121
|
+
these are the stateful issuer gate's job.** `requireAuthorization` is the one setting that closes
|
|
122
|
+
all of them at once, because it forces the exact request back through that gate before this
|
|
123
|
+
Service executes anything. Left off, a value-bearing call permitted here logs a warning saying
|
|
124
|
+
exactly that, so the gap is visible in your own logs rather than silent:
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
[mcp-guard] "flight-purchase" (amount=100) permitted in trustless mode — rate-limit,
|
|
128
|
+
circuit-breaker, replay, cumulative-spend, and spend-anomaly floors are stateful and were NOT
|
|
129
|
+
re-verified against live issuer state. Set requireAuthorization:true for custodial/value-bearing
|
|
130
|
+
surfaces.
|
|
131
|
+
```
|
|
90
132
|
|
|
91
133
|
```js
|
|
92
134
|
const guard = createMcpGuard({ serviceDid, issuerApi, requireAuthorization: true });
|
|
@@ -180,6 +222,19 @@ transaction** — the host reconstructs the tx and verifies MetaMynd's signature
|
|
|
180
222
|
`checkCapabilityBinding` from `magp-bind`), so "authorize $150, execute $5,000" is rejected in the
|
|
181
223
|
prod guard, not just the demo gateway. No verifier configured → opt-in (unchanged).
|
|
182
224
|
|
|
225
|
+
Presenting a capability is otherwise the **caller's** choice: an agent can simply omit
|
|
226
|
+
`signed.capability` and the check above never runs, verifier configured or not. Set
|
|
227
|
+
`requireCapability: true` to close that omission — a PERMIT with no capability is then blocked
|
|
228
|
+
(`CAPABILITY_REQUIRED`) instead of silently passing through unbound:
|
|
229
|
+
|
|
230
|
+
```js
|
|
231
|
+
const guard = createMcpGuard({ serviceDid, verifyCapability, requireCapability: true });
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Off by default, so an existing integration whose callers don't yet present a capability keeps
|
|
235
|
+
working unchanged. Turn it on for any Service where the decision-token binding is meant to be
|
|
236
|
+
mandatory, not opt-in.
|
|
237
|
+
|
|
183
238
|
Holds carry an expiry (§7a.4): if not captured, the reservation auto-voids and the budget returns
|
|
184
239
|
to the cap; a party can also void explicitly via `POST /policy/mandate/authorize/:id/void`.
|
|
185
240
|
|
package/agentsafe-mcp-guard.mjs
CHANGED
|
@@ -42,8 +42,22 @@ const CLOCK_SKEW_TOLERANCE_MS = 30 * 1000;
|
|
|
42
42
|
* SPEND, neither of which the stateless re-check above can enforce on its own. Off by default:
|
|
43
43
|
* it costs a network round trip per value-bearing call, so it's a deliberate choice, not a
|
|
44
44
|
* strictly-dominant one — a Service happy with per-request policy re-evaluation alone can skip it.
|
|
45
|
+
*
|
|
46
|
+
* The stateless re-check ALSO cannot see the issuer's other STATEFUL floors — rate limits,
|
|
47
|
+
* circuit breakers, and spend-pattern anomaly detection all key off the agent's server-side
|
|
48
|
+
* history, which never travels to the edge. `requireAuthorization` is the one mechanism that
|
|
49
|
+
* closes all of these at once, because it forces the exact request through the stateful gate
|
|
50
|
+
* before this Service will execute it. A value-bearing call permitted here with
|
|
51
|
+
* `requireAuthorization` OFF logs a warning for exactly this reason — see guardIncomingTool.
|
|
52
|
+
* @param {boolean} [cfg.requireCapability] when true AND `verifyCapability` is configured, a PERMIT
|
|
53
|
+
* verdict for a call with NO `signed.capability` is now BLOCKED (`CAPABILITY_REQUIRED`) rather than
|
|
54
|
+
* silently passing through unbound. Without this, capability binding is opt-in from the CALLER's
|
|
55
|
+
* side — an agent can simply omit `capability` and the "authorize $150, execute $5,000" check below
|
|
56
|
+
* never runs at all, since it only fires when the field is present. Off by default (an existing
|
|
57
|
+
* integrator's un-capability-aware callers must keep working); set true on any Service where
|
|
58
|
+
* capability binding is meant to be mandatory, not opt-in.
|
|
45
59
|
*/
|
|
46
|
-
export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle, policyPublicKey, settlementStore, verifyCapability, requireAuthorization = false } = {}) {
|
|
60
|
+
export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle, policyPublicKey, settlementStore, verifyCapability, requireAuthorization = false, requireCapability = false } = {}) {
|
|
47
61
|
if (!serviceDid) throw new Error('createMcpGuard requires { serviceDid }');
|
|
48
62
|
const base = issuerApi ? issuerApi.replace(/\/$/, '') : null;
|
|
49
63
|
const privateKey = serviceKey
|
|
@@ -229,7 +243,19 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
|
|
|
229
243
|
if (!claim.claimed) return { decision: 'block', reasonCode: claim.reasonCode };
|
|
230
244
|
// The claim alone only proves SOME real, unclaimed authorization exists — it must also
|
|
231
245
|
// be FOR this agent and these exact values, or a cheap legitimate hold's id could be
|
|
232
|
-
// presented to unlock a completely different, more expensive execution.
|
|
246
|
+
// presented to unlock a completely different, more expensive execution. Each check is
|
|
247
|
+
// skipped when the claim response omits that field — tolerated for a Service pinned
|
|
248
|
+
// against an older, not-yet-migrated issuer whose response predates the field (see
|
|
249
|
+
// backend markEffect()'s own note) — but a value-bearing request with a real signed
|
|
250
|
+
// amount/merchant omitted from the claim is exactly the "field genuinely absent vs.
|
|
251
|
+
// issuer regressed" ambiguity that note warns about, so it's surfaced rather than
|
|
252
|
+
// silently trusted: this is the ONE place a future backend change could quietly
|
|
253
|
+
// re-open the confused-deputy gap this claim exists to close, and nothing else here
|
|
254
|
+
// would notice.
|
|
255
|
+
if (claim.agentDid === undefined) console.warn('[mcp-guard] claim response omitted agentDid — binding degraded to "some valid unclaimed authorization exists"');
|
|
256
|
+
if (Number(amount) > 0 && claim.amount === undefined) console.warn('[mcp-guard] claim response omitted amount for a value-bearing request — amount binding degraded');
|
|
257
|
+
if (Number(amount) > 0 && claim.currency === undefined) console.warn('[mcp-guard] claim response omitted currency for a value-bearing request — currency binding degraded');
|
|
258
|
+
if (merchant && claim.merchant === undefined) console.warn('[mcp-guard] claim response omitted merchant for a request that signed one — merchant binding degraded');
|
|
233
259
|
if (claim.agentDid !== undefined && claim.agentDid !== agentDid) return { decision: 'block', reasonCode: 'AUTHORIZATION_AGENT_MISMATCH' };
|
|
234
260
|
if (claim.amount !== undefined && Number(claim.amount) !== Number(amount)) return { decision: 'block', reasonCode: 'AUTHORIZATION_AMOUNT_MISMATCH' };
|
|
235
261
|
if (claim.currency !== undefined && claim.currency !== currency) return { decision: 'block', reasonCode: 'AUTHORIZATION_CURRENCY_MISMATCH' };
|
|
@@ -261,20 +287,40 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
|
|
|
261
287
|
// exact transaction — the host reconstructs the tx + verifies MetaMynd's signature OFFLINE,
|
|
262
288
|
// so "authorize $150, execute $5,000" (authorize-A / execute-B) is rejected HERE, in the
|
|
263
289
|
// prod guard, not just the demo gateway. No verifier configured → unchanged (opt-in).
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
290
|
+
//
|
|
291
|
+
// Presenting a capability is the CALLER's choice, not this guard's: an agent can simply
|
|
292
|
+
// omit `signed.capability` and this whole check is skipped, verifier or not — that is
|
|
293
|
+
// exactly the omission `requireCapability` closes. Without it, capability binding is
|
|
294
|
+
// opt-in from the wrong side of the trust boundary.
|
|
295
|
+
if (typeof verifyCapability === 'function') {
|
|
296
|
+
if (signed?.capability) {
|
|
297
|
+
let bind;
|
|
298
|
+
try { bind = await verifyCapability(signed); }
|
|
299
|
+
catch (err) { bind = { ok: false, reasonCode: 'CAPABILITY_CHECK_ERROR', error: String(err?.message ?? err) }; }
|
|
300
|
+
if (!bind?.ok) {
|
|
301
|
+
const err = new Error(`MCP guard CAPABILITY "${action}": ${bind?.reasonCode ?? 'CAPABILITY_INVALID'}`);
|
|
302
|
+
err.name = 'GovernanceBlocked';
|
|
303
|
+
err.governance = { decision: 'block', reasonCode: bind?.reasonCode ?? 'CAPABILITY_INVALID' };
|
|
304
|
+
throw err;
|
|
305
|
+
}
|
|
306
|
+
} else if (requireCapability) {
|
|
307
|
+
const err = new Error(`MCP guard CAPABILITY "${action}": CAPABILITY_REQUIRED`);
|
|
270
308
|
err.name = 'GovernanceBlocked';
|
|
271
|
-
err.governance = { decision: 'block', reasonCode:
|
|
309
|
+
err.governance = { decision: 'block', reasonCode: 'CAPABILITY_REQUIRED' };
|
|
272
310
|
throw err;
|
|
273
311
|
}
|
|
274
312
|
}
|
|
275
313
|
if (decision.decision === 'observe') {
|
|
276
314
|
console.warn(`[mcp-guard] OBSERVE "${action}": ${decision.reasonCode} — served under monitoring`);
|
|
277
315
|
}
|
|
316
|
+
// Trustless mode cannot see the issuer's stateful floors (see requireAuthorization's own
|
|
317
|
+
// doc above) — surface that as a loud, per-call signal rather than a silent gap, so an
|
|
318
|
+
// operator serving real value through this path finds out from their own logs rather
|
|
319
|
+
// than from an incident. Gated on value-bearing (amount > 0): a free/read action has
|
|
320
|
+
// nothing for those floors to protect, so warning on it would just be noise.
|
|
321
|
+
if (!requireAuthorization && Number(signed?.amount) > 0) {
|
|
322
|
+
console.warn(`[mcp-guard] "${action}" (amount=${signed.amount}) permitted in trustless mode — rate-limit, circuit-breaker, replay, cumulative-spend, and spend-anomaly floors are stateful and were NOT re-verified against live issuer state. Set requireAuthorization:true for custodial/value-bearing surfaces.`);
|
|
323
|
+
}
|
|
278
324
|
return handler(signed, ...rest);
|
|
279
325
|
};
|
|
280
326
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metamynd/agentsafe-mcp-guard",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
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",
|
package/policy-core.mjs
CHANGED
|
@@ -351,13 +351,14 @@ function reasonFor(constraint) {
|
|
|
351
351
|
if (!constraint) return "CONSTRAINT_FAILED";
|
|
352
352
|
return REASON_BY_OPERAND[constraint.leftOperand] ?? `CONSTRAINT_FAILED:${constraint.leftOperand}`;
|
|
353
353
|
}
|
|
354
|
-
function constraintSatisfied(c, req) {
|
|
354
|
+
function constraintSatisfied(c, req, strict) {
|
|
355
355
|
const op = OPERATORS[c.operator];
|
|
356
356
|
if (!op) return false;
|
|
357
357
|
const left = Object.prototype.hasOwnProperty.call(req.values, c.leftOperand) ? req.values[c.leftOperand] : void 0;
|
|
358
|
-
if (!op(left, c.rightOperand)
|
|
359
|
-
|
|
360
|
-
|
|
358
|
+
if (!c.unit) return op(left, c.rightOperand);
|
|
359
|
+
const currency = req.values["mm:currency"];
|
|
360
|
+
const unitMatches = typeof currency === "string" && currency.toUpperCase() === c.unit.toUpperCase();
|
|
361
|
+
return unitMatches ? op(left, c.rightOperand) : !strict;
|
|
361
362
|
}
|
|
362
363
|
function targetOf(rule, mandate) {
|
|
363
364
|
return rule.target ?? mandate.target;
|
|
@@ -379,7 +380,7 @@ function evaluateMandate(mandate, req) {
|
|
|
379
380
|
}
|
|
380
381
|
for (const p of mandate.prohibition ?? []) {
|
|
381
382
|
if (targetOf(p, mandate) !== req.target) continue;
|
|
382
|
-
const fires = (p.constraint ?? []).every((c) => constraintSatisfied(c, req));
|
|
383
|
+
const fires = (p.constraint ?? []).every((c) => constraintSatisfied(c, req, false));
|
|
383
384
|
if (fires) {
|
|
384
385
|
return {
|
|
385
386
|
decision: p.enforcement ?? "block",
|
|
@@ -397,10 +398,10 @@ function evaluateMandate(mandate, req) {
|
|
|
397
398
|
};
|
|
398
399
|
}
|
|
399
400
|
for (const p of perms) {
|
|
400
|
-
const failing = (p.constraint ?? []).find((c) => !constraintSatisfied(c, req));
|
|
401
|
+
const failing = (p.constraint ?? []).find((c) => !constraintSatisfied(c, req, true));
|
|
401
402
|
if (!failing) return { decision: "allow", reasonCode: "AUTHORIZED" };
|
|
402
403
|
}
|
|
403
|
-
const firstFail = (perms[0].constraint ?? []).find((c) => !constraintSatisfied(c, req));
|
|
404
|
+
const firstFail = (perms[0].constraint ?? []).find((c) => !constraintSatisfied(c, req, true));
|
|
404
405
|
return {
|
|
405
406
|
decision: firstFail?.onFail ?? "block",
|
|
406
407
|
reasonCode: reasonFor(firstFail),
|