@metamynd/agentsafe-mcp-guard 0.3.5 → 0.4.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 +39 -3
- package/agentsafe-mcp-guard.mjs +42 -8
- package/package.json +1 -1
- package/policy-core.mjs +66 -6
package/README.md
CHANGED
|
@@ -99,13 +99,36 @@ response that omits it — the one place a future backend change could quietly r
|
|
|
99
99
|
confused-deputy gap this check exists to close, with nothing else here able to notice. The
|
|
100
100
|
decision is unchanged (still tolerated, not blocked) — this is visibility, not a new refusal.
|
|
101
101
|
|
|
102
|
-
|
|
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`)
|
|
103
114
|
|
|
104
115
|
Re-evaluating policy per request (above) proves the request is well-formed and in-policy — it
|
|
105
116
|
does **not** stop a captured, still-fresh request from being replayed, and it can't enforce the
|
|
106
117
|
mandate's TOTAL budget across many separately-legal calls (each is only checked against its own
|
|
107
|
-
per-transaction cap).
|
|
108
|
-
|
|
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
|
+
```
|
|
109
132
|
|
|
110
133
|
```js
|
|
111
134
|
const guard = createMcpGuard({ serviceDid, issuerApi, requireAuthorization: true });
|
|
@@ -199,6 +222,19 @@ transaction** — the host reconstructs the tx and verifies MetaMynd's signature
|
|
|
199
222
|
`checkCapabilityBinding` from `magp-bind`), so "authorize $150, execute $5,000" is rejected in the
|
|
200
223
|
prod guard, not just the demo gateway. No verifier configured → opt-in (unchanged).
|
|
201
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
|
+
|
|
202
238
|
Holds carry an expiry (§7a.4): if not captured, the reservation auto-voids and the budget returns
|
|
203
239
|
to the cap; a party can also void explicitly via `POST /policy/mandate/authorize/:id/void`.
|
|
204
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
|
|
@@ -273,20 +287,40 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
|
|
|
273
287
|
// exact transaction — the host reconstructs the tx + verifies MetaMynd's signature OFFLINE,
|
|
274
288
|
// so "authorize $150, execute $5,000" (authorize-A / execute-B) is rejected HERE, in the
|
|
275
289
|
// prod guard, not just the demo gateway. No verifier configured → unchanged (opt-in).
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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`);
|
|
282
308
|
err.name = 'GovernanceBlocked';
|
|
283
|
-
err.governance = { decision: 'block', reasonCode:
|
|
309
|
+
err.governance = { decision: 'block', reasonCode: 'CAPABILITY_REQUIRED' };
|
|
284
310
|
throw err;
|
|
285
311
|
}
|
|
286
312
|
}
|
|
287
313
|
if (decision.decision === 'observe') {
|
|
288
314
|
console.warn(`[mcp-guard] OBSERVE "${action}": ${decision.reasonCode} — served under monitoring`);
|
|
289
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
|
+
}
|
|
290
324
|
return handler(signed, ...rest);
|
|
291
325
|
};
|
|
292
326
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metamynd/agentsafe-mcp-guard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
// src/policy-core/atom-registry.ts
|
|
4
4
|
var RISK_RANK = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
5
|
+
function currencyOutOfScope(ctx, cfgCurrency) {
|
|
6
|
+
if (cfgCurrency === void 0 || cfgCurrency === null) return false;
|
|
7
|
+
const allowed = Array.isArray(cfgCurrency) ? cfgCurrency : [cfgCurrency];
|
|
8
|
+
if (allowed.length === 0) return false;
|
|
9
|
+
const currency = ctx.currency;
|
|
10
|
+
const matches = typeof currency === "string" && allowed.some((u) => typeof u === "string" && u.toUpperCase() === currency.toUpperCase());
|
|
11
|
+
return !matches;
|
|
12
|
+
}
|
|
5
13
|
var ATOM_REGISTRY = {
|
|
6
14
|
"data-source-not-approved": (c, cfg) => !!c.dataSourceId && !(cfg?.approved ?? []).includes(String(c.dataSourceId)),
|
|
7
15
|
"consent-missing": (c) => c.consent === false,
|
|
@@ -10,7 +18,11 @@ var ATOM_REGISTRY = {
|
|
|
10
18
|
const need = RISK_RANK[String(cfg?.level ?? "high")];
|
|
11
19
|
return have !== void 0 && need !== void 0 && have >= need;
|
|
12
20
|
},
|
|
13
|
-
"amount-over": (c, cfg) =>
|
|
21
|
+
"amount-over": (c, cfg) => {
|
|
22
|
+
if (typeof c.amount !== "number") return false;
|
|
23
|
+
if (currencyOutOfScope(c, cfg?.currency)) return true;
|
|
24
|
+
return c.amount > Number(cfg?.limit ?? 0);
|
|
25
|
+
},
|
|
14
26
|
// Deny-by-default primitive for value-moving actions. Fires on ABSENCE (like the
|
|
15
27
|
// evidence atoms below, and unlike `amount-over`) OR on a NEGATIVE amount: true when
|
|
16
28
|
// the context carries no usable amount, or one that cannot be trusted for capping —
|
|
@@ -31,7 +43,12 @@ var ATOM_REGISTRY = {
|
|
|
31
43
|
"amount-unknown": (c) => !(typeof c.amount === "number" && Number.isFinite(c.amount) && c.amount >= 0),
|
|
32
44
|
// Total budget: cumulativeSpend is a SERVER-derived, signed-last context field (never
|
|
33
45
|
// shadowable by the agent's itinerary), so this compares already-spent + this amount.
|
|
34
|
-
|
|
46
|
+
// See `currencyOutOfScope` above: a configured currency scope that this request's
|
|
47
|
+
// currency doesn't match fires the cap outright, same fail-closed reasoning as `amount-over`.
|
|
48
|
+
"cumulative-over": (c, cfg) => {
|
|
49
|
+
if (currencyOutOfScope(c, cfg?.currency)) return true;
|
|
50
|
+
return Number(c.cumulativeSpend ?? 0) + Number(c.amount ?? 0) > Number(cfg?.limit ?? 0);
|
|
51
|
+
},
|
|
35
52
|
// Fires if any configured term appears in the prompt and/or output text.
|
|
36
53
|
// Used to govern agent responses on content (prohibited claims, sensitive advice).
|
|
37
54
|
"text-matches": (c, cfg) => {
|
|
@@ -87,7 +104,25 @@ var ATOM_SPECS = [
|
|
|
87
104
|
predicate: "amount-over",
|
|
88
105
|
label: "Per-transaction amount over limit",
|
|
89
106
|
description: "Fires when a single action amount exceeds a configured limit (per-transaction cap).",
|
|
90
|
-
config: [
|
|
107
|
+
config: [
|
|
108
|
+
{ key: "limit", type: "number", required: true, description: "Maximum allowed amount for one transaction" },
|
|
109
|
+
{
|
|
110
|
+
key: "currency",
|
|
111
|
+
type: "string[]",
|
|
112
|
+
required: false,
|
|
113
|
+
description: `Optional currency scope for the limit (e.g. ['USD'], or ['USD','GBP'] for several). Leave empty to keep the limit currency-blind \u2014 the historical default: the raw number is compared regardless of currency. Once set, a request in a currency outside this list \u2014 or with none supplied at all \u2014 fires this atom regardless of amount (unverifiable is treated as unsafe, not as "smaller"), so the cap can't be cleared by naming a cheaper-looking currency (e.g. 200 JPY vs 200 USD).`
|
|
114
|
+
}
|
|
115
|
+
],
|
|
116
|
+
// `currency` is NOT listed here even though the executable atom conditionally reads it:
|
|
117
|
+
// unlike `limit`, the `currency` config is OPTIONAL per atom instance, so whether an agent
|
|
118
|
+
// needs to supply it depends on how a given molecule configures this atom — something
|
|
119
|
+
// `requiredContextFor`'s per-predicate (not per-instance) model can't express. Every
|
|
120
|
+
// authorize request already carries `currency` unconditionally regardless (see
|
|
121
|
+
// AuthorizeInput), so nothing is actually left unfed by omitting it here — this only
|
|
122
|
+
// controls the Scenario Bank simulate form / docs "context contract" surfacing, and
|
|
123
|
+
// forcing it onto every amount-over molecule would spuriously mark scenarios that never
|
|
124
|
+
// configure a currency scope as unexercised (see cumulative-over-atom.test.ts's sibling
|
|
125
|
+
// comment below for the same reasoning applied there).
|
|
91
126
|
requiredContext: ["amount"]
|
|
92
127
|
},
|
|
93
128
|
{
|
|
@@ -101,8 +136,32 @@ var ATOM_SPECS = [
|
|
|
101
136
|
predicate: "cumulative-over",
|
|
102
137
|
label: "Total budget over limit",
|
|
103
138
|
description: "Fires when cumulative spend (already-spent + this transaction) exceeds a configured total budget.",
|
|
104
|
-
config: [
|
|
105
|
-
|
|
139
|
+
config: [
|
|
140
|
+
{ key: "limit", type: "number", required: true, description: "Maximum total budget across all transactions" },
|
|
141
|
+
{
|
|
142
|
+
key: "currency",
|
|
143
|
+
type: "string[]",
|
|
144
|
+
required: false,
|
|
145
|
+
description: "Optional currency scope for the budget (e.g. ['USD'], or ['USD','GBP'] for several). Leave empty to keep it currency-blind \u2014 the historical default. Once set, a request in a currency outside this list \u2014 or with none supplied at all \u2014 fires this atom regardless of amount, same fail-closed design as amount-over's currency scope."
|
|
146
|
+
}
|
|
147
|
+
],
|
|
148
|
+
// The executable atom (atom-registry.ts) reads BOTH fields: `cumulativeSpend + amount >
|
|
149
|
+
// limit`. Omitting `cumulativeSpend` here silently broke two downstream consumers this
|
|
150
|
+
// catalog is the single source of truth for (see file header): the Scenario Bank's
|
|
151
|
+
// simulate form never rendered an "already spent" field for any set using this atom —
|
|
152
|
+
// including its own seeded preset, which supplied `cumulativeSpend` for a form field
|
|
153
|
+
// that didn't exist — so the control could never actually be exercised from the UI; and
|
|
154
|
+
// the integration docs' generated "context contract" told real SDK integrators this
|
|
155
|
+
// atom only needs `amount`, so an agent that never sends `cumulativeSpend` gets it
|
|
156
|
+
// silently treated as 0 and the total-budget cap never fires in production either.
|
|
157
|
+
//
|
|
158
|
+
// `currency`, by contrast, is deliberately NOT added here even though the executable atom
|
|
159
|
+
// conditionally reads it — see the sibling comment on `amount-over`'s currency config
|
|
160
|
+
// above: it is optional PER ATOM INSTANCE (only read when a molecule configures a
|
|
161
|
+
// currency scope), so unlike `cumulativeSpend` (always read), a static per-predicate
|
|
162
|
+
// requiredContext can't represent it without forcing every set using this atom to demand
|
|
163
|
+
// a currency it may never need.
|
|
164
|
+
requiredContext: ["amount", "cumulativeSpend"]
|
|
106
165
|
},
|
|
107
166
|
{
|
|
108
167
|
predicate: "risk-at-or-above",
|
|
@@ -357,7 +416,8 @@ function constraintSatisfied(c, req, strict) {
|
|
|
357
416
|
const left = Object.prototype.hasOwnProperty.call(req.values, c.leftOperand) ? req.values[c.leftOperand] : void 0;
|
|
358
417
|
if (!c.unit) return op(left, c.rightOperand);
|
|
359
418
|
const currency = req.values["mm:currency"];
|
|
360
|
-
const
|
|
419
|
+
const allowedUnits = Array.isArray(c.unit) ? c.unit : [c.unit];
|
|
420
|
+
const unitMatches = typeof currency === "string" && allowedUnits.some((u) => u.toUpperCase() === currency.toUpperCase());
|
|
361
421
|
return unitMatches ? op(left, c.rightOperand) : !strict;
|
|
362
422
|
}
|
|
363
423
|
function targetOf(rule, mandate) {
|