@metamynd/agentsafe-mcp-guard 0.3.1 → 0.3.3

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
@@ -70,7 +70,15 @@ 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).
74
82
 
75
83
  ### Replay and cumulative spend (`requireAuthorization`)
76
84
 
@@ -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
- if (Number.isNaN(ts) || Math.abs(Date.now() - ts) > FRESHNESS_MS) {
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).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metamynd/agentsafe-mcp-guard",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
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`): true when the context carries no
16
- // usable amount the gate cannot tell how much value the call would move, so a
17
- // spend cap authored next to it would silently never fire. Author it with BLOCK as
18
- // the FIRST rule of a spend policy; the cap that follows then only ever judges a
19
- // known number. Opt-in: only a rule that keys it runs it, so actions that carry no
20
- // amount by nature are unaffected.
21
- "amount-unknown": (c) => !(typeof c.amount === "number" && Number.isFinite(c.amount)),
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 tell how much value it would move. A deny-by-default control for value-moving actions: author it with BLOCK ahead of a spend cap, otherwise an action whose amount is missing or unparseable passes the cap untested. Fires on ABSENCE, so only attach it to actions that must always carry an amount.",
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
  },
@@ -345,7 +355,9 @@ function constraintSatisfied(c, req) {
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 (!op(left, c.rightOperand)) return false;
359
+ if (c.unit && req.values["mm:currency"] !== c.unit) return false;
360
+ return true;
349
361
  }
350
362
  function targetOf(rule, mandate) {
351
363
  return rule.target ?? mandate.target;
@@ -437,8 +449,11 @@ function evaluate(input) {
437
449
  }
438
450
 
439
451
  // src/policy-core/canonical.ts
452
+ function escapeField(v) {
453
+ return v.replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
454
+ }
440
455
  function buildAuthMessage(f) {
441
- return `${f.agentDid}|${f.action}|${f.amount}|${f.currency}|${f.merchant ?? ""}|${f.nonce}|${f.issuedAt}`;
456
+ return [f.agentDid, f.action, f.amount, f.currency, f.merchant ?? "", f.nonce, f.issuedAt].map((v) => escapeField(String(v))).join("|");
442
457
  }
443
458
 
444
459
  // src/policy-core/context.ts