@metamynd/agentsafe-mcp-guard 0.2.0 → 0.3.1

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
@@ -67,6 +67,11 @@ const bookFlight = guard.guardIncomingTool('flight-purchase', rawBookFlight);
67
67
  4. evaluates Standards → SOPs → mandate with `policy-core` — signed fields applied last, so a
68
68
  forged `itinerary` key can't shadow the signed amount/merchant (§6.4.2).
69
69
 
70
+ `policy-core`'s `amount-unknown` atom (0.3.0) is a deny-by-default check for any value-moving
71
+ tool call whose amount the guard can't determine — a signed-transaction or nested x402 payload
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.
74
+
70
75
  ### Replay and cumulative spend (`requireAuthorization`)
71
76
 
72
77
  Re-evaluating policy per request (above) proves the request is well-formed and in-policy — it
@@ -83,11 +88,14 @@ When set, a PERMIT verdict (allow/observe) additionally requires `signed.authori
83
88
  **atomically claim single-use execution** against the issuer (`AUTHORIZED → DISPATCHING`, the
84
89
  effect-safety state machine) — a second claim of the same id, whether a genuine replay or a race,
85
90
  fails, because that transition is legal exactly once. The claimed hold's own bound
86
- `agentDid`/`amount`/`currency` are checked against what's actually being executed, too — a claim
87
- alone only proves *some* real, unclaimed authorization exists; without this check, a cheap
88
- legitimate hold's id could be presented to unlock a completely different, more expensive
91
+ `agentDid`/`amount`/`currency`/`merchant` are checked against what's actually being executed, too
92
+ — a claim alone only proves *some* real, unclaimed authorization exists; without this check, a
93
+ cheap legitimate hold's id could be presented to unlock a completely different, more expensive
89
94
  execution (`AUTHORIZATION_AGENT_MISMATCH` / `AUTHORIZATION_AMOUNT_MISMATCH` /
90
- `AUTHORIZATION_CURRENCY_MISMATCH`).
95
+ `AUTHORIZATION_CURRENCY_MISMATCH` / `AUTHORIZATION_MERCHANT_MISMATCH`). A field the backend
96
+ response omits (e.g. an older, not-yet-migrated deployment with no `merchant` column) is skipped,
97
+ not treated as a mismatch — this degrades gracefully, it doesn't silently under-check going
98
+ forward once the backend does report it.
91
99
 
92
100
  The `authorizationId` has to come from a **real** `guard.authorize()` call on the agent side —
93
101
  not `buildSignedRequest()`, which never talks to the network. In practice this usually needs no
@@ -90,11 +90,12 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
90
90
  * against the stateful issuer gate already checked both (agentsafe-guard.mjs's authorizeLocal()
91
91
  * seals any value-bearing action through the real remote authorize() by default).
92
92
  *
93
- * On success, also returns the hold's OWN bound `agentDid`/`amount`/`currency` — the caller MUST
94
- * compare these to the request actually being executed. A claim alone only proves "some real,
95
- * unclaimed authorization exists"; without this check, a legitimately-obtained authorization for
96
- * a small, honest transaction could be presented to unlock a completely different one — the same
97
- * confused-deputy shape payload binding closes at the request layer, recurring one layer deeper.
93
+ * On success, also returns the hold's OWN bound `agentDid`/`amount`/`currency`/`merchant` — the
94
+ * caller MUST compare these to the request actually being executed. A claim alone only proves
95
+ * "some real, unclaimed authorization exists"; without this check, a legitimately-obtained
96
+ * authorization for a small, honest transaction could be presented to unlock a completely
97
+ * different one — the same confused-deputy shape payload binding closes at the request layer,
98
+ * recurring one layer deeper.
98
99
  */
99
100
  async function claimAuthorization({ authorizationId } = {}) {
100
101
  if (!authorizationId) return { claimed: false, reasonCode: 'AUTHORIZATION_REQUIRED' };
@@ -103,7 +104,7 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
103
104
  const res = await fetch(`${base}/policy/mandate/authorize/${encodeURIComponent(authorizationId)}/effect/dispatching`, { method: 'POST' });
104
105
  const body = await res.json().catch(() => null);
105
106
  if (!res.ok) return { claimed: false, reasonCode: body?.message ?? body?.data?.reasonCode ?? `AUTHORIZATION_CLAIM_HTTP_${res.status}` };
106
- return { claimed: true, agentDid: body?.data?.agentDid, amount: body?.data?.amount, currency: body?.data?.currency };
107
+ return { claimed: true, agentDid: body?.data?.agentDid, amount: body?.data?.amount, currency: body?.data?.currency, merchant: body?.data?.merchant };
107
108
  } catch (err) {
108
109
  return { claimed: false, reasonCode: 'AUTHORIZATION_CLAIM_UNREACHABLE', error: String(err?.message ?? err) };
109
110
  }
@@ -218,6 +219,7 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
218
219
  if (claim.agentDid !== undefined && claim.agentDid !== agentDid) return { decision: 'block', reasonCode: 'AUTHORIZATION_AGENT_MISMATCH' };
219
220
  if (claim.amount !== undefined && Number(claim.amount) !== Number(amount)) return { decision: 'block', reasonCode: 'AUTHORIZATION_AMOUNT_MISMATCH' };
220
221
  if (claim.currency !== undefined && claim.currency !== currency) return { decision: 'block', reasonCode: 'AUTHORIZATION_CURRENCY_MISMATCH' };
222
+ if (claim.merchant !== undefined && claim.merchant !== merchant) return { decision: 'block', reasonCode: 'AUTHORIZATION_MERCHANT_MISMATCH' };
221
223
  }
222
224
  return final;
223
225
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metamynd/agentsafe-mcp-guard",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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
@@ -11,6 +11,14 @@ var ATOM_REGISTRY = {
11
11
  return have !== void 0 && need !== void 0 && have >= need;
12
12
  },
13
13
  "amount-over": (c, cfg) => typeof c.amount === "number" && c.amount > Number(cfg?.limit ?? 0),
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)),
14
22
  // Total budget: cumulativeSpend is a SERVER-derived, signed-last context field (never
15
23
  // shadowable by the agent's itinerary), so this compares already-spent + this amount.
16
24
  "cumulative-over": (c, cfg) => Number(c.cumulativeSpend ?? 0) + Number(c.amount ?? 0) > Number(cfg?.limit ?? 0),
@@ -72,6 +80,13 @@ var ATOM_SPECS = [
72
80
  config: [{ key: "limit", type: "number", required: true, description: "Maximum allowed amount for one transaction" }],
73
81
  requiredContext: ["amount"]
74
82
  },
83
+ {
84
+ predicate: "amount-unknown",
85
+ 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.",
87
+ config: [],
88
+ requiredContext: ["amount"]
89
+ },
75
90
  {
76
91
  predicate: "cumulative-over",
77
92
  label: "Total budget over limit",