@metamynd/agentsafe-mcp-guard 0.3.1 → 0.3.5
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 +28 -1
- package/agentsafe-mcp-guard.mjs +30 -4
- package/package.json +1 -1
- package/policy-core.mjs +30 -14
package/README.md
CHANGED
|
@@ -70,7 +70,34 @@ const bookFlight = guard.guardIncomingTool('flight-purchase', rawBookFlight);
|
|
|
70
70
|
`policy-core`'s `amount-unknown` atom (0.3.0) is a deny-by-default check for any value-moving
|
|
71
71
|
tool call whose amount the guard can't determine — a signed-transaction or nested x402 payload
|
|
72
72
|
can carry its value somewhere a naive spend cap never looks, and this blocks that case instead
|
|
73
|
-
of letting it slip past the cap untested.
|
|
73
|
+
of letting it slip past the cap untested. As of 0.3.2 it also fires on a **negative** amount,
|
|
74
|
+
which previously read as "a real, known number" and could clear a spend cap for free.
|
|
75
|
+
|
|
76
|
+
**0.3.2 — freshness is no longer symmetric.** The staleness check used to compare
|
|
77
|
+
`|now - issuedAt|` against the freshness window, which treated a request timestamped in the
|
|
78
|
+
*future* the same as one from the past — accepting anything signed up to 5 minutes ahead of
|
|
79
|
+
server time, a pre-signing window rather than ordinary clock skew. `issuedAt` may now lag by up
|
|
80
|
+
to the freshness window (network/processing delay) but lead by no more than 30 seconds (clock
|
|
81
|
+
skew only).
|
|
82
|
+
|
|
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.
|
|
74
101
|
|
|
75
102
|
### Replay and cumulative spend (`requireAuthorization`)
|
|
76
103
|
|
package/agentsafe-mcp-guard.mjs
CHANGED
|
@@ -17,8 +17,14 @@ import { verifyDidSignature } from './magp-did.mjs';
|
|
|
17
17
|
import { buildPaymentRequirements, checkSettlementBinding } from './x402.mjs';
|
|
18
18
|
import { verifyBundle } from './magp-policy.mjs';
|
|
19
19
|
|
|
20
|
-
/** Freshness window for signed requests and handshake nonces (spec §7.7).
|
|
20
|
+
/** Freshness window for signed requests and handshake nonces (spec §7.7). How far `issuedAt`
|
|
21
|
+
* may be BEHIND server time — network/processing delay. */
|
|
21
22
|
const FRESHNESS_MS = 5 * 60 * 1000;
|
|
23
|
+
/** How far a signed request's `issuedAt` may be AHEAD of server time — clock skew, not a window
|
|
24
|
+
* to pre-sign a request for later use. Checked separately from FRESHNESS_MS so `Math.abs()`
|
|
25
|
+
* can't fold both directions into one 10-minute window (found live: a request signed up to 5
|
|
26
|
+
* minutes in the future was accepted). Mirrors mandate.service.ts's CLOCK_SKEW_TOLERANCE_MS. */
|
|
27
|
+
const CLOCK_SKEW_TOLERANCE_MS = 30 * 1000;
|
|
22
28
|
|
|
23
29
|
/**
|
|
24
30
|
* @param {object} cfg
|
|
@@ -127,7 +133,7 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
|
|
|
127
133
|
|
|
128
134
|
/** Evaluate the agent's bundle against the request via policy-core (signed fields last). */
|
|
129
135
|
function verdictFromBundle(bundle, req) {
|
|
130
|
-
const { agentDid, action, amount = 0, merchant = '', itinerary = {}, cumulativeSpend = amount, now } = req;
|
|
136
|
+
const { agentDid, action, amount = 0, currency = 'USD', merchant = '', itinerary = {}, cumulativeSpend = amount, now } = req;
|
|
131
137
|
const mandate = (bundle.mandates ?? []).find((m) => m.action === action)?.document;
|
|
132
138
|
return evaluate({
|
|
133
139
|
standards: (bundle.standards ?? []).map((s) => ({ standardKey: s.key, document: s.document })),
|
|
@@ -142,6 +148,11 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
|
|
|
142
148
|
'mm:payAmount': amount,
|
|
143
149
|
'mm:cumulativeSpend': cumulativeSpend,
|
|
144
150
|
'mm:merchant': merchant,
|
|
151
|
+
// A payAmount/cumulativeSpend constraint issued with a `unit` (currency) is
|
|
152
|
+
// only satisfied in that currency (see mandate-eval.ts's constraintSatisfied)
|
|
153
|
+
// — omitting this would make EVERY unit-bearing cap fail regardless of amount.
|
|
154
|
+
// Defaults to 'USD', matching verifyRequest()'s own default for this field.
|
|
155
|
+
'mm:currency': currency,
|
|
145
156
|
}),
|
|
146
157
|
}
|
|
147
158
|
: undefined,
|
|
@@ -169,8 +180,11 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
|
|
|
169
180
|
// 2. Freshness. (Single-use nonce consumption stays the gate's job by default — a Service
|
|
170
181
|
// re-check is verification, not a second authorization. requireAuthorization below is
|
|
171
182
|
// the opt-in exception: it DOES give the Service its own single-use claim.)
|
|
183
|
+
// Asymmetric: `age` positive = issuedAt in the past (tolerate FRESHNESS_MS); negative =
|
|
184
|
+
// issuedAt in the future (tolerate only CLOCK_SKEW_TOLERANCE_MS) — see its own comment.
|
|
172
185
|
const ts = Date.parse(issuedAt);
|
|
173
|
-
|
|
186
|
+
const age = Date.now() - ts;
|
|
187
|
+
if (Number.isNaN(ts) || age > FRESHNESS_MS || age < -CLOCK_SKEW_TOLERANCE_MS) {
|
|
174
188
|
return { decision: 'block', reasonCode: 'REQUEST_EXPIRED' };
|
|
175
189
|
}
|
|
176
190
|
// 3. Re-evaluate against the issuer-hosted bundle (fetched over TLS from the issuer).
|
|
@@ -215,7 +229,19 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
|
|
|
215
229
|
if (!claim.claimed) return { decision: 'block', reasonCode: claim.reasonCode };
|
|
216
230
|
// The claim alone only proves SOME real, unclaimed authorization exists — it must also
|
|
217
231
|
// 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.
|
|
232
|
+
// presented to unlock a completely different, more expensive execution. Each check is
|
|
233
|
+
// skipped when the claim response omits that field — tolerated for a Service pinned
|
|
234
|
+
// against an older, not-yet-migrated issuer whose response predates the field (see
|
|
235
|
+
// backend markEffect()'s own note) — but a value-bearing request with a real signed
|
|
236
|
+
// amount/merchant omitted from the claim is exactly the "field genuinely absent vs.
|
|
237
|
+
// issuer regressed" ambiguity that note warns about, so it's surfaced rather than
|
|
238
|
+
// silently trusted: this is the ONE place a future backend change could quietly
|
|
239
|
+
// re-open the confused-deputy gap this claim exists to close, and nothing else here
|
|
240
|
+
// would notice.
|
|
241
|
+
if (claim.agentDid === undefined) console.warn('[mcp-guard] claim response omitted agentDid — binding degraded to "some valid unclaimed authorization exists"');
|
|
242
|
+
if (Number(amount) > 0 && claim.amount === undefined) console.warn('[mcp-guard] claim response omitted amount for a value-bearing request — amount binding degraded');
|
|
243
|
+
if (Number(amount) > 0 && claim.currency === undefined) console.warn('[mcp-guard] claim response omitted currency for a value-bearing request — currency binding degraded');
|
|
244
|
+
if (merchant && claim.merchant === undefined) console.warn('[mcp-guard] claim response omitted merchant for a request that signed one — merchant binding degraded');
|
|
219
245
|
if (claim.agentDid !== undefined && claim.agentDid !== agentDid) return { decision: 'block', reasonCode: 'AUTHORIZATION_AGENT_MISMATCH' };
|
|
220
246
|
if (claim.amount !== undefined && Number(claim.amount) !== Number(amount)) return { decision: 'block', reasonCode: 'AUTHORIZATION_AMOUNT_MISMATCH' };
|
|
221
247
|
if (claim.currency !== undefined && claim.currency !== currency) return { decision: 'block', reasonCode: 'AUTHORIZATION_CURRENCY_MISMATCH' };
|
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.5",
|
|
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
|
@@ -12,13 +12,23 @@ var ATOM_REGISTRY = {
|
|
|
12
12
|
},
|
|
13
13
|
"amount-over": (c, cfg) => typeof c.amount === "number" && c.amount > Number(cfg?.limit ?? 0),
|
|
14
14
|
// Deny-by-default primitive for value-moving actions. Fires on ABSENCE (like the
|
|
15
|
-
// evidence atoms below, and unlike `amount-over`)
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
// amount
|
|
21
|
-
|
|
15
|
+
// evidence atoms below, and unlike `amount-over`) OR on a NEGATIVE amount: true when
|
|
16
|
+
// the context carries no usable amount, or one that cannot be trusted for capping —
|
|
17
|
+
// the gate cannot tell how much value the call would move, so a spend cap authored
|
|
18
|
+
// next to it would silently never fire. `amount-over` only ever fires on `> limit`,
|
|
19
|
+
// so a negative amount clears every positive cap by construction, and on a system
|
|
20
|
+
// that tracks committed spend ADDITIVELY (reserved += amount), a negative claim can
|
|
21
|
+
// net-reduce what's already committed rather than add to it — the same "cap never
|
|
22
|
+
// fires" failure as a missing amount, reached from the other side of zero. Zero
|
|
23
|
+
// itself is NOT covered here: a genuine $0 action (a read, a no-op) is a valid,
|
|
24
|
+
// known amount, not an unknown one. Author this with BLOCK as the FIRST rule of a
|
|
25
|
+
// spend policy; the cap that follows then only ever judges a known, non-negative
|
|
26
|
+
// number. Opt-in: only a rule that keys it runs it, so actions that carry no amount
|
|
27
|
+
// by nature are unaffected. The public authorize endpoint's own schema already
|
|
28
|
+
// rejects a negative amount before it reaches this atom (defense in depth, not the
|
|
29
|
+
// only layer) — this is what closes the same gap for paths that schema doesn't
|
|
30
|
+
// cover: the local/harness evaluator and the platform's own MCP tool policies.
|
|
31
|
+
"amount-unknown": (c) => !(typeof c.amount === "number" && Number.isFinite(c.amount) && c.amount >= 0),
|
|
22
32
|
// Total budget: cumulativeSpend is a SERVER-derived, signed-last context field (never
|
|
23
33
|
// shadowable by the agent's itinerary), so this compares already-spent + this amount.
|
|
24
34
|
"cumulative-over": (c, cfg) => Number(c.cumulativeSpend ?? 0) + Number(c.amount ?? 0) > Number(cfg?.limit ?? 0),
|
|
@@ -83,7 +93,7 @@ var ATOM_SPECS = [
|
|
|
83
93
|
{
|
|
84
94
|
predicate: "amount-unknown",
|
|
85
95
|
label: "Amount not determinable",
|
|
86
|
-
description: "Fires when the action carries no usable amount \u2014 the gate cannot
|
|
96
|
+
description: "Fires when the action carries no usable amount, or a NEGATIVE one \u2014 the gate cannot trust either for capping. A deny-by-default control for value-moving actions: author it with BLOCK ahead of a spend cap, otherwise an amount that is missing, unparseable, or negative passes the cap untested (amount-over only ever fires above the limit, so a negative amount clears every positive cap). A genuine $0 amount does NOT fire this \u2014 only attach it to actions that must always carry a real, non-negative amount.",
|
|
87
97
|
config: [],
|
|
88
98
|
requiredContext: ["amount"]
|
|
89
99
|
},
|
|
@@ -341,11 +351,14 @@ function reasonFor(constraint) {
|
|
|
341
351
|
if (!constraint) return "CONSTRAINT_FAILED";
|
|
342
352
|
return REASON_BY_OPERAND[constraint.leftOperand] ?? `CONSTRAINT_FAILED:${constraint.leftOperand}`;
|
|
343
353
|
}
|
|
344
|
-
function constraintSatisfied(c, req) {
|
|
354
|
+
function constraintSatisfied(c, req, strict) {
|
|
345
355
|
const op = OPERATORS[c.operator];
|
|
346
356
|
if (!op) return false;
|
|
347
357
|
const left = Object.prototype.hasOwnProperty.call(req.values, c.leftOperand) ? req.values[c.leftOperand] : void 0;
|
|
348
|
-
return op(left, c.rightOperand);
|
|
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;
|
|
349
362
|
}
|
|
350
363
|
function targetOf(rule, mandate) {
|
|
351
364
|
return rule.target ?? mandate.target;
|
|
@@ -367,7 +380,7 @@ function evaluateMandate(mandate, req) {
|
|
|
367
380
|
}
|
|
368
381
|
for (const p of mandate.prohibition ?? []) {
|
|
369
382
|
if (targetOf(p, mandate) !== req.target) continue;
|
|
370
|
-
const fires = (p.constraint ?? []).every((c) => constraintSatisfied(c, req));
|
|
383
|
+
const fires = (p.constraint ?? []).every((c) => constraintSatisfied(c, req, false));
|
|
371
384
|
if (fires) {
|
|
372
385
|
return {
|
|
373
386
|
decision: p.enforcement ?? "block",
|
|
@@ -385,10 +398,10 @@ function evaluateMandate(mandate, req) {
|
|
|
385
398
|
};
|
|
386
399
|
}
|
|
387
400
|
for (const p of perms) {
|
|
388
|
-
const failing = (p.constraint ?? []).find((c) => !constraintSatisfied(c, req));
|
|
401
|
+
const failing = (p.constraint ?? []).find((c) => !constraintSatisfied(c, req, true));
|
|
389
402
|
if (!failing) return { decision: "allow", reasonCode: "AUTHORIZED" };
|
|
390
403
|
}
|
|
391
|
-
const firstFail = (perms[0].constraint ?? []).find((c) => !constraintSatisfied(c, req));
|
|
404
|
+
const firstFail = (perms[0].constraint ?? []).find((c) => !constraintSatisfied(c, req, true));
|
|
392
405
|
return {
|
|
393
406
|
decision: firstFail?.onFail ?? "block",
|
|
394
407
|
reasonCode: reasonFor(firstFail),
|
|
@@ -437,8 +450,11 @@ function evaluate(input) {
|
|
|
437
450
|
}
|
|
438
451
|
|
|
439
452
|
// src/policy-core/canonical.ts
|
|
453
|
+
function escapeField(v) {
|
|
454
|
+
return v.replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
|
|
455
|
+
}
|
|
440
456
|
function buildAuthMessage(f) {
|
|
441
|
-
return
|
|
457
|
+
return [f.agentDid, f.action, f.amount, f.currency, f.merchant ?? "", f.nonce, f.issuedAt].map((v) => escapeField(String(v))).join("|");
|
|
442
458
|
}
|
|
443
459
|
|
|
444
460
|
// src/policy-core/context.ts
|