@metamynd/agentsafe-mcp-guard 0.1.0 → 0.2.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 CHANGED
@@ -61,16 +61,51 @@ 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` 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
89
+ execution (`AUTHORIZATION_AGENT_MISMATCH` / `AUTHORIZATION_AMOUNT_MISMATCH` /
90
+ `AUTHORIZATION_CURRENCY_MISMATCH`).
91
+
92
+ The `authorizationId` has to come from a **real** `guard.authorize()` call on the agent side —
93
+ not `buildSignedRequest()`, which never talks to the network. In practice this usually needs no
94
+ extra agent-side plumbing: `agentsafe-guard`'s default `guardTool()` path already calls the real
95
+ remote `authorize()` for any value-bearing action (`sealValueActions`, on by default), so its
96
+ `authorizationId` is already sitting in the `decision` object `guardTool()` hands your handler —
97
+ thread it through to the signed request you present to this guard.
98
+
99
+ Off by default: it costs a network round trip per value-bearing call, so it's a deliberate
100
+ choice, not a strictly-dominant one. A Service happy with per-request policy re-evaluation alone
101
+ (no replay/cumulative-spend guarantee) can skip it.
102
+
69
103
  Run the self-check (handshake + trustless eval, no network):
70
104
 
71
105
  ```powershell
72
106
  cd integrations\agentsafe-mcp-guard
73
- node mcp-guard.smoke.mjs # PASS when every case matches
107
+ node mcp-guard.smoke.mjs # PASS when every case matches
108
+ node claim-authorization.smoke.mjs # requireAuthorization: replay, mismatch, fail-closed
74
109
  ```
75
110
 
76
111
  ## 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,34 @@ 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` — 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.
98
+ */
99
+ async function claimAuthorization({ authorizationId } = {}) {
100
+ if (!authorizationId) return { claimed: false, reasonCode: 'AUTHORIZATION_REQUIRED' };
101
+ if (!base) throw new Error('issuerApi is required to claim an authorization');
102
+ try {
103
+ const res = await fetch(`${base}/policy/mandate/authorize/${encodeURIComponent(authorizationId)}/effect/dispatching`, { method: 'POST' });
104
+ const body = await res.json().catch(() => null);
105
+ 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
+ } catch (err) {
108
+ return { claimed: false, reasonCode: 'AUTHORIZATION_CLAIM_UNREACHABLE', error: String(err?.message ?? err) };
109
+ }
110
+ }
111
+
78
112
  async function loadBundle(agentDid) {
79
113
  if (typeof fetchBundle === 'function') return fetchBundle(agentDid);
80
114
  if (!base) throw new Error('issuerApi (or fetchBundle) is required to load the policy bundle');
@@ -131,8 +165,9 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
131
165
  if (!verifyDidSignature(agentDid, message, signature)) {
132
166
  return { decision: 'block', reasonCode: 'SIGNATURE_INVALID' };
133
167
  }
134
- // 2. Freshness. (Single-use nonce consumption stays the gate's job — a Service
135
- // re-check is verification, not a second authorization.)
168
+ // 2. Freshness. (Single-use nonce consumption stays the gate's job by default — a Service
169
+ // re-check is verification, not a second authorization. requireAuthorization below is
170
+ // the opt-in exception: it DOES give the Service its own single-use claim.)
136
171
  const ts = Date.parse(issuedAt);
137
172
  if (Number.isNaN(ts) || Math.abs(Date.now() - ts) > FRESHNESS_MS) {
138
173
  return { decision: 'block', reasonCode: 'REQUEST_EXPIRED' };
@@ -167,10 +202,24 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
167
202
  const verdict = verdictFromBundle(bundle, { ...signed, itinerary: signed.itinerary ?? {} });
168
203
  // Mode ESCALATE floor lifts an otherwise-PERMIT (allow or observe) to human review
169
204
  // (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 };
205
+ const final = (verdict.decision === 'allow' || verdict.decision === 'observe') && modeGate.decision === 'escalate'
206
+ ? { ...verdict, decision: 'escalate', reasonCode: modeGate.reasonCode }
207
+ : verdict;
208
+ // 4. Stateful claim (opt-in). Re-evaluating policy per request (above) does not by itself
209
+ // stop REPLAY or CUMULATIVE SPEND past the mandate total — both are the stateful issuer
210
+ // gate's job. Only claim on an actual PERMIT: escalate/block/suspend/quarantine execute
211
+ // nothing, so there is nothing to protect and no reason to spend the hold's single use.
212
+ if (requireAuthorization && (final.decision === 'allow' || final.decision === 'observe')) {
213
+ const claim = await claimAuthorization({ authorizationId: signed.authorizationId });
214
+ if (!claim.claimed) return { decision: 'block', reasonCode: claim.reasonCode };
215
+ // The claim alone only proves SOME real, unclaimed authorization exists — it must also
216
+ // be FOR this agent and these exact values, or a cheap legitimate hold's id could be
217
+ // presented to unlock a completely different, more expensive execution.
218
+ if (claim.agentDid !== undefined && claim.agentDid !== agentDid) return { decision: 'block', reasonCode: 'AUTHORIZATION_AGENT_MISMATCH' };
219
+ if (claim.amount !== undefined && Number(claim.amount) !== Number(amount)) return { decision: 'block', reasonCode: 'AUTHORIZATION_AMOUNT_MISMATCH' };
220
+ if (claim.currency !== undefined && claim.currency !== currency) return { decision: 'block', reasonCode: 'AUTHORIZATION_CURRENCY_MISMATCH' };
172
221
  }
173
- return verdict;
222
+ return final;
174
223
  } catch (err) {
175
224
  return { decision: 'block', reasonCode: 'GUARD_ERROR', error: String(err?.message ?? err) };
176
225
  }
@@ -270,7 +319,7 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
270
319
  return { settled: true, txHash: result.txHash, reasonCode: 'SETTLED' };
271
320
  }
272
321
 
273
- return { handshakeChallenge, handshakeVerify, verifyRequest, guardIncomingTool, requirePayment, settle, serviceDid };
322
+ return { handshakeChallenge, handshakeVerify, verifyRequest, guardIncomingTool, requirePayment, settle, claimAuthorization, serviceDid };
274
323
  }
275
324
 
276
325
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@metamynd/agentsafe-mcp-guard",
3
- "version": "0.1.0",
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 so an agent that ignores its own guard still cannot make your service act.",
3
+ "version": "0.2.0",
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",
7
7
  "module": "./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"
@@ -49,8 +49,13 @@
49
49
  "author": "MetaMynd",
50
50
  "license": "MIT",
51
51
  "homepage": "https://metamynd.ai/en/developers/spec",
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "git+https://github.com/Metamynd/agentsafe-guard.git",
55
+ "directory": "packages/agentsafe-mcp-guard"
56
+ },
52
57
  "bugs": {
53
- "url": "https://metamynd.ai/en/support/contact"
58
+ "url": "https://github.com/Metamynd/agentsafe-guard/issues"
54
59
  },
55
60
  "publishConfig": {
56
61
  "access": "public"
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;
@@ -334,6 +335,13 @@ function constraintSatisfied(c, req) {
334
335
  function targetOf(rule, mandate) {
335
336
  return rule.target ?? mandate.target;
336
337
  }
338
+ function isAuthorityFailure(result) {
339
+ return result.matched?.kind === "expiry" || result.matched?.kind === "no-permission";
340
+ }
341
+ function authorityFailure(mandate, target, now) {
342
+ const result = evaluateMandate(mandate, { target, now, values: {} });
343
+ return isAuthorityFailure(result) ? { ...result, decision: "block" } : null;
344
+ }
337
345
  function evaluateMandate(mandate, req) {
338
346
  const now = toTime(req.now);
339
347
  if (mandate.validFrom && now < toTime(mandate.validFrom)) {
@@ -392,7 +400,7 @@ function sumEventField(events, type, field) {
392
400
  }
393
401
 
394
402
  // src/policy-core/evaluate.ts
395
- 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 };
396
404
  function evaluate(input) {
397
405
  let decision = "allow";
398
406
  let reasonCode = "AUTHORIZED";
@@ -402,14 +410,14 @@ function evaluate(input) {
402
410
  reasonCode = code;
403
411
  }
404
412
  };
413
+ const m = input.mandate && input.mandateRequest ? evaluateMandate(input.mandate, input.mandateRequest) : null;
414
+ const authority = m !== null && isAuthorityFailure(m);
415
+ if (m && authority) consider(m.decision, m.reasonCode);
405
416
  const std = evaluateBoundStandards(input.standards ?? [], input.context);
406
417
  if (std.decision !== "allow") consider(std.decision, std.reasonCode ?? "STANDARD_RULE");
407
418
  const sop = evaluateBoundStandards(input.sops ?? [], input.context);
408
419
  if (sop.decision !== "allow") consider(sop.decision, sop.reasonCode ?? "SOP_RULE");
409
- if (input.mandate && input.mandateRequest) {
410
- const m = evaluateMandate(input.mandate, input.mandateRequest);
411
- if (m.decision !== "allow") consider(m.decision, m.reasonCode);
412
- }
420
+ if (m && !authority && m.decision !== "allow") consider(m.decision, m.reasonCode);
413
421
  return { decision, reasonCode, authorizationId: null, remaining: null, proofRef: null };
414
422
  }
415
423
 
@@ -472,12 +480,14 @@ export {
472
480
  applyHold,
473
481
  applySignedLast,
474
482
  asOperatingMode,
483
+ authorityFailure,
475
484
  buildAuthMessage,
476
485
  canAuthorize,
477
486
  evaluate,
478
487
  evaluateBoundStandards,
479
488
  evaluateMandate,
480
489
  evaluateStandardRules,
490
+ isAuthorityFailure,
481
491
  isOperatingMode,
482
492
  moleculeFires,
483
493
  moreRestrictive,