@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 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
- ### Replay and cumulative spend (`requireAuthorization`)
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). Both are the stateful issuer gate's job, not something a stateless re-check
89
- can do on its own.
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
 
@@ -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
- if (signed?.capability && typeof verifyCapability === 'function') {
265
- let bind;
266
- try { bind = await verifyCapability(signed); }
267
- catch (err) { bind = { ok: false, reasonCode: 'CAPABILITY_CHECK_ERROR', error: String(err?.message ?? err) }; }
268
- if (!bind?.ok) {
269
- const err = new Error(`MCP guard CAPABILITY "${action}": ${bind?.reasonCode ?? 'CAPABILITY_INVALID'}`);
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: bind?.reasonCode ?? 'CAPABILITY_INVALID' };
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",
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)) return false;
359
- if (c.unit && req.values["mm:currency"] !== c.unit) return false;
360
- return true;
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),