@metamynd/agentsafe-mcp-guard 0.1.2 → 0.2.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
@@ -61,16 +61,54 @@ const bookFlight = guard.guardIncomingTool('flight-purchase', rawBookFlight);
61
61
 
62
62
  `verifyRequest`:
63
63
  1. rebuilds the canonical message (§7.3) and verifies the Ed25519 signature via **key-in-DID**;
64
- 2. checks freshness (single-use nonce stays the gate's job);
64
+ 2. checks freshness (single-use nonce stays the gate's job **unless `requireAuthorization` is
65
+ set** — see below, that's the exception);
65
66
  3. fetches the agent's policy bundle from the issuer (`GET /policy/bundle/:did`, over TLS);
66
67
  4. evaluates Standards → SOPs → mandate with `policy-core` — signed fields applied last, so a
67
68
  forged `itinerary` key can't shadow the signed amount/merchant (§6.4.2).
68
69
 
70
+ ### Replay and cumulative spend (`requireAuthorization`)
71
+
72
+ Re-evaluating policy per request (above) proves the request is well-formed and in-policy — it
73
+ does **not** stop a captured, still-fresh request from being replayed, and it can't enforce the
74
+ mandate's TOTAL budget across many separately-legal calls (each is only checked against its own
75
+ per-transaction cap). Both are the stateful issuer gate's job, not something a stateless re-check
76
+ can do on its own.
77
+
78
+ ```js
79
+ const guard = createMcpGuard({ serviceDid, issuerApi, requireAuthorization: true });
80
+ ```
81
+
82
+ When set, a PERMIT verdict (allow/observe) additionally requires `signed.authorizationId` to
83
+ **atomically claim single-use execution** against the issuer (`AUTHORIZED → DISPATCHING`, the
84
+ effect-safety state machine) — a second claim of the same id, whether a genuine replay or a race,
85
+ fails, because that transition is legal exactly once. The claimed hold's own bound
86
+ `agentDid`/`amount`/`currency`/`merchant` are checked against what's actually being executed, too
87
+ — a claim alone only proves *some* real, unclaimed authorization exists; without this check, a
88
+ cheap legitimate hold's id could be presented to unlock a completely different, more expensive
89
+ execution (`AUTHORIZATION_AGENT_MISMATCH` / `AUTHORIZATION_AMOUNT_MISMATCH` /
90
+ `AUTHORIZATION_CURRENCY_MISMATCH` / `AUTHORIZATION_MERCHANT_MISMATCH`). A field the backend
91
+ response omits (e.g. an older, not-yet-migrated deployment with no `merchant` column) is skipped,
92
+ not treated as a mismatch — this degrades gracefully, it doesn't silently under-check going
93
+ forward once the backend does report it.
94
+
95
+ The `authorizationId` has to come from a **real** `guard.authorize()` call on the agent side —
96
+ not `buildSignedRequest()`, which never talks to the network. In practice this usually needs no
97
+ extra agent-side plumbing: `agentsafe-guard`'s default `guardTool()` path already calls the real
98
+ remote `authorize()` for any value-bearing action (`sealValueActions`, on by default), so its
99
+ `authorizationId` is already sitting in the `decision` object `guardTool()` hands your handler —
100
+ thread it through to the signed request you present to this guard.
101
+
102
+ Off by default: it costs a network round trip per value-bearing call, so it's a deliberate
103
+ choice, not a strictly-dominant one. A Service happy with per-request policy re-evaluation alone
104
+ (no replay/cumulative-spend guarantee) can skip it.
105
+
69
106
  Run the self-check (handshake + trustless eval, no network):
70
107
 
71
108
  ```powershell
72
109
  cd integrations\agentsafe-mcp-guard
73
- node mcp-guard.smoke.mjs # PASS when every case matches
110
+ node mcp-guard.smoke.mjs # PASS when every case matches
111
+ node claim-authorization.smoke.mjs # requireAuthorization: replay, mismatch, fail-closed
74
112
  ```
75
113
 
76
114
  ## 3. Payment binding (x402, §7a)
@@ -30,8 +30,14 @@ const FRESHNESS_MS = 5 * 60 * 1000;
30
30
  * GET /magp/policy/pubkey). When set, the guard VERIFIES the bundle signature + freshness (Phase F,
31
31
  * §5.3.2/§5.3.3) and fails closed for value-bearing actions on an unsigned/tampered/stale bundle —
32
32
  * so per-request enforcement needs no live MetaMynd. Omit for the legacy hash-addressed + TLS mode.
33
+ * @param {boolean} [cfg.requireAuthorization] when true, a PERMIT verdict (allow/observe) is only
34
+ * actually granted if `signed.authorizationId` atomically claims single-use execution against the
35
+ * stateful issuer gate (see claimAuthorization below) — this is what closes REPLAY and CUMULATIVE
36
+ * SPEND, neither of which the stateless re-check above can enforce on its own. Off by default:
37
+ * it costs a network round trip per value-bearing call, so it's a deliberate choice, not a
38
+ * strictly-dominant one — a Service happy with per-request policy re-evaluation alone can skip it.
33
39
  */
34
- export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle, policyPublicKey, settlementStore, verifyCapability } = {}) {
40
+ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle, policyPublicKey, settlementStore, verifyCapability, requireAuthorization = false } = {}) {
35
41
  if (!serviceDid) throw new Error('createMcpGuard requires { serviceDid }');
36
42
  const base = issuerApi ? issuerApi.replace(/\/$/, '') : null;
37
43
  const privateKey = serviceKey
@@ -75,6 +81,35 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
75
81
 
76
82
  // --- Trustless re-evaluation (spec §9.3, §9.6) ---
77
83
 
84
+ /**
85
+ * Atomically claim a stateful authorization for single execution (AUTHORIZED -> DISPATCHING,
86
+ * the effect-safety state machine in backend/src/features/policy/mandate/effect-machine.ts). A
87
+ * SECOND claim of the SAME authorizationId — a replay, or a race — fails: that transition is only
88
+ * legal once. This is what actually stops replay and enforces the mandate's cumulative budget at
89
+ * a Service, because the authorizationId only exists because the agent's own authorize() call
90
+ * against the stateful issuer gate already checked both (agentsafe-guard.mjs's authorizeLocal()
91
+ * seals any value-bearing action through the real remote authorize() by default).
92
+ *
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.
99
+ */
100
+ async function claimAuthorization({ authorizationId } = {}) {
101
+ if (!authorizationId) return { claimed: false, reasonCode: 'AUTHORIZATION_REQUIRED' };
102
+ if (!base) throw new Error('issuerApi is required to claim an authorization');
103
+ try {
104
+ const res = await fetch(`${base}/policy/mandate/authorize/${encodeURIComponent(authorizationId)}/effect/dispatching`, { method: 'POST' });
105
+ const body = await res.json().catch(() => null);
106
+ if (!res.ok) return { claimed: false, reasonCode: body?.message ?? body?.data?.reasonCode ?? `AUTHORIZATION_CLAIM_HTTP_${res.status}` };
107
+ return { claimed: true, agentDid: body?.data?.agentDid, amount: body?.data?.amount, currency: body?.data?.currency, merchant: body?.data?.merchant };
108
+ } catch (err) {
109
+ return { claimed: false, reasonCode: 'AUTHORIZATION_CLAIM_UNREACHABLE', error: String(err?.message ?? err) };
110
+ }
111
+ }
112
+
78
113
  async function loadBundle(agentDid) {
79
114
  if (typeof fetchBundle === 'function') return fetchBundle(agentDid);
80
115
  if (!base) throw new Error('issuerApi (or fetchBundle) is required to load the policy bundle');
@@ -131,8 +166,9 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
131
166
  if (!verifyDidSignature(agentDid, message, signature)) {
132
167
  return { decision: 'block', reasonCode: 'SIGNATURE_INVALID' };
133
168
  }
134
- // 2. Freshness. (Single-use nonce consumption stays the gate's job — a Service
135
- // re-check is verification, not a second authorization.)
169
+ // 2. Freshness. (Single-use nonce consumption stays the gate's job by default — a Service
170
+ // re-check is verification, not a second authorization. requireAuthorization below is
171
+ // the opt-in exception: it DOES give the Service its own single-use claim.)
136
172
  const ts = Date.parse(issuedAt);
137
173
  if (Number.isNaN(ts) || Math.abs(Date.now() - ts) > FRESHNESS_MS) {
138
174
  return { decision: 'block', reasonCode: 'REQUEST_EXPIRED' };
@@ -167,10 +203,25 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
167
203
  const verdict = verdictFromBundle(bundle, { ...signed, itinerary: signed.itinerary ?? {} });
168
204
  // Mode ESCALATE floor lifts an otherwise-PERMIT (allow or observe) to human review
169
205
  // (escalate outranks observe, so a flag never masks it) — mirrors the backend gate.
170
- if ((verdict.decision === 'allow' || verdict.decision === 'observe') && modeGate.decision === 'escalate') {
171
- return { ...verdict, decision: 'escalate', reasonCode: modeGate.reasonCode };
206
+ const final = (verdict.decision === 'allow' || verdict.decision === 'observe') && modeGate.decision === 'escalate'
207
+ ? { ...verdict, decision: 'escalate', reasonCode: modeGate.reasonCode }
208
+ : verdict;
209
+ // 4. Stateful claim (opt-in). Re-evaluating policy per request (above) does not by itself
210
+ // stop REPLAY or CUMULATIVE SPEND past the mandate total — both are the stateful issuer
211
+ // gate's job. Only claim on an actual PERMIT: escalate/block/suspend/quarantine execute
212
+ // nothing, so there is nothing to protect and no reason to spend the hold's single use.
213
+ if (requireAuthorization && (final.decision === 'allow' || final.decision === 'observe')) {
214
+ const claim = await claimAuthorization({ authorizationId: signed.authorizationId });
215
+ if (!claim.claimed) return { decision: 'block', reasonCode: claim.reasonCode };
216
+ // The claim alone only proves SOME real, unclaimed authorization exists — it must also
217
+ // 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.
219
+ if (claim.agentDid !== undefined && claim.agentDid !== agentDid) return { decision: 'block', reasonCode: 'AUTHORIZATION_AGENT_MISMATCH' };
220
+ if (claim.amount !== undefined && Number(claim.amount) !== Number(amount)) return { decision: 'block', reasonCode: 'AUTHORIZATION_AMOUNT_MISMATCH' };
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' };
172
223
  }
173
- return verdict;
224
+ return final;
174
225
  } catch (err) {
175
226
  return { decision: 'block', reasonCode: 'GUARD_ERROR', error: String(err?.message ?? err) };
176
227
  }
@@ -270,7 +321,7 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
270
321
  return { settled: true, txHash: result.txHash, reasonCode: 'SETTLED' };
271
322
  }
272
323
 
273
- return { handshakeChallenge, handshakeVerify, verifyRequest, guardIncomingTool, requirePayment, settle, serviceDid };
324
+ return { handshakeChallenge, handshakeVerify, verifyRequest, guardIncomingTool, requirePayment, settle, claimAuthorization, serviceDid };
274
325
  }
275
326
 
276
327
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metamynd/agentsafe-mcp-guard",
3
- "version": "0.1.2",
3
+ "version": "0.2.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",
@@ -23,7 +23,7 @@
23
23
  "LICENSE"
24
24
  ],
25
25
  "scripts": {
26
- "test": "node mcp-guard.smoke.mjs && node pay.smoke.mjs && node decision-token.smoke.mjs"
26
+ "test": "node mcp-guard.smoke.mjs && node pay.smoke.mjs && node decision-token.smoke.mjs && node claim-authorization.smoke.mjs"
27
27
  },
28
28
  "engines": {
29
29
  "node": ">=18"
package/policy-core.mjs CHANGED
@@ -58,8 +58,9 @@ ${c.output ?? ""}`.toLowerCase();
58
58
  };
59
59
  function notInAllowList(value, allowList) {
60
60
  const v = value != null ? String(value).toLowerCase().trim() : "";
61
+ if (v === "") return false;
61
62
  const allowed = (Array.isArray(allowList) ? allowList : []).map((x) => String(x).toLowerCase().trim());
62
- return v !== "" && allowed.length > 0 && !allowed.includes(v);
63
+ return !allowed.includes(v);
63
64
  }
64
65
 
65
66
  // src/policy-core/atom-catalog.ts
@@ -191,7 +192,7 @@ function requiredContextFor(predicates) {
191
192
  }
192
193
 
193
194
  // src/policy-core/standards-rules.ts
194
- var PRECEDENCE = { allow: 0, observe: 1, escalate: 2, block: 3, suspend: 4, quarantine: 5 };
195
+ var PRECEDENCE = { allow: 0, observe: 1, escalate: 2, block: 3, suspend: 4, quarantine: 5, decommission: 6 };
195
196
  function atomFires(atom, ctx) {
196
197
  const pred = ATOM_REGISTRY[atom.predicate];
197
198
  if (!pred) return false;
@@ -399,7 +400,7 @@ function sumEventField(events, type, field) {
399
400
  }
400
401
 
401
402
  // src/policy-core/evaluate.ts
402
- var PRECEDENCE2 = { allow: 0, observe: 1, escalate: 2, block: 3, suspend: 4, quarantine: 5 };
403
+ var PRECEDENCE2 = { allow: 0, observe: 1, escalate: 2, block: 3, suspend: 4, quarantine: 5, decommission: 6 };
403
404
  function evaluate(input) {
404
405
  let decision = "allow";
405
406
  let reasonCode = "AUTHORIZED";