@metamynd/agentsafe-mcp-guard 0.6.1 → 0.11.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
@@ -159,6 +159,107 @@ signing requests close together could race that replacement window and fail with
159
159
  `DAEMON_UNREACHABLE` even though the daemon was healthy. `key-providers.mjs` now retries a
160
160
  connection that fails with `ENOENT` for up to 3 seconds before giving up. No API change.
161
161
 
162
+ **0.11.0 — payload binding: the claim states what this Service will actually execute (MAGP §8.3.9, §8.7.11).**
163
+ `guardIncomingTool(action, handler, { bindPayload: true })` digests the tool's arguments (or `bindPayload: (signed, ...args) => value`
164
+ to choose), refuses locally (`PAYLOAD_NOT_BOUND`) when they differ from the digest the agent signed, and sends the digest with the
165
+ claim — in the `x-magp-payload-digest` header and as a signed field of the claim message — so the issuer compares it with the
166
+ digest it stored at authorize time and refuses a mismatch, leaving the hold unclaimed. `requirePayloadBinding: true` also refuses
167
+ an authorization the agent did not bind (`PAYLOAD_BINDING_REQUIRED`). A payload JSON cannot carry is refused
168
+ (`PAYLOAD_NOT_CANONICALIZABLE`), not skipped, and a grant that does not echo the digest (an issuer that predates binding) is
169
+ refused too. Both options are off by default; `verifyRequest(signed, { payloadDigest, requirePayloadBinding })` is the lower-level form.
170
+
171
+ **0.10.0 — the agent can no longer skip a risk rule by hiding or understating its risk (D-03).** The rules
172
+ now judge `riskLevel` with provenance (MAGP §6.3), identically to the issuer's gate:
173
+
174
+ - A missing or unrecognised `riskLevel` **escalates** (`CONTEXT_UNVERIFIABLE`) for any rule that uses the
175
+ risk atom (it used to read as "not risky" and be allowed); `"HIGH"` is read as `high`. An agent that sends no
176
+ `riskLevel` will now be escalated.
177
+ - **`trustedContext`** — what *this Service* derived from the real call, never from the agent. Pass it to
178
+ `verifyRequest(signed, { trustedContext: { riskLevel: 'high' } })`, or per tool:
179
+ `guardIncomingTool('wire-transfer', handler, { trustedContext: { riskLevel: 'high' } })` (an object, or
180
+ `(signed, ...rest) => object`). It is applied over the agent's claim and labelled `gateway_derived`; the agent
181
+ can raise its risk above it, never lower it below it. A deriver that throws, or that is configured but yields
182
+ nothing usable (returns `undefined`, a non-object, or a `riskLevel` that is not a level), fails the call closed —
183
+ it never falls back to the agent's word.
184
+ - The mandate's owner-set **`riskTier`** (in the signed bundle) is a further floor, so the agent's "low" about an
185
+ action the owner classed `high` is judged `high`; and the SUPERVISED-mode high-risk escalation judges that
186
+ effective risk too.
187
+ - A rule can demand a trusted source with `requireProvenance: { riskLevel: 'gateway_derived' }`.
188
+
189
+ Without `trustedContext`, an owner tier or `requireProvenance`, the agent's claim is still all the rules see —
190
+ this closes hiding and garbling; those three close understating.
191
+
192
+ **0.9.0 — a lost claim response no longer strands the hold, and a refused claim can be looked up.**
193
+ - Every claim now carries a fresh, unguessable `Idempotency-Key`, and a claim whose response never arrives
194
+ (dropped connection, a 5xx) is **retried once with the same key**. If the first attempt had actually landed,
195
+ the issuer recognises the retry as yours and returns your original grant (`claimAuthorization()` reports
196
+ `replayed: true`) instead of refusing, so you can go on to execute. Before this, a lost response left the
197
+ hold claimed with nobody executing it, committed to the cap until reconciled. The key is per call and never
198
+ persisted: a restarted process, or a second request for the same authorization, gets no replay — a claim is
199
+ still single-use. A definite answer is never retried; the issuer's `EFFECT_TRANSITION_CONTENDED` (an
200
+ overlapping attempt of yours is mid-claim) is retried, since it is not a refusal.
201
+ - A second claim of the same authorization is now refused with the stable code
202
+ **`AUTHORIZATION_ALREADY_CLAIMED`** whatever became of the first (it used to be
203
+ `INVALID_EFFECT_TRANSITION` or `EFFECT_TRANSITION_CONTENDED` depending on timing).
204
+ - New `lookupOutcome({ authorizationId })` — what became of it? Returns `outcome`
205
+ (`not_started | expired | in_flight | settled | not_executed | unknown | reversing | reversed |
206
+ reversal_failed`) and two safety bits: `nothingExecuted` (nothing has run *so far*) and **`retrySafe`**
207
+ (nothing can run *later* either — true only for `expired` and `not_executed`). Re-authorize and retry only on
208
+ `retrySafe`. A `not_started` hold is `nothingExecuted` but **not** `retrySafe`: it can still be claimed until
209
+ its window closes, so a request queued behind a slow gateway could run it while your retry runs too — void
210
+ it first, then read `not_executed`. Use it after an `AUTHORIZATION_ALREADY_CLAIMED`, and never retry an
211
+ `unknown` or `in_flight` outcome blindly.
212
+ - The idempotency key must stay secret from the agent (128 bits of randomness; the issuer refuses one under 32
213
+ characters or containing the authorization id). Never use the authorization id as a *claim* key — it is the
214
+ one thing the agent knows; it is only the right key to give an *upstream* to de-duplicate on.
215
+ - The authorization id is the effect's natural idempotency key: pass `decision.authorizationId` to an
216
+ upstream that de-duplicates, and the effect is exactly-once even if you ever run the same call twice.
217
+ (`@metamynd/agentsafe-http-gateway` 0.9.0 does this for you.) Needs an issuer that understands
218
+ `Idempotency-Key` (MAGP §8.7.7); an older issuer ignores it and behaves as before.
219
+
220
+ **0.8.0 — a Service can prove who it is.** A claim token is a bearer secret: it proves "I made the claim",
221
+ not who you are. Give the guard a signing identity and it signs the claim and every settlement call
222
+ (capture / release / mark-unknown) instead:
223
+
224
+ ```js
225
+ const guard = createMcpGuard({
226
+ serviceDid: 'did:hedera:testnet:…', // did:key or did:hedera; the key must be the one the DID commits to
227
+ serviceKey: privateKeyHex, // or keyProvider: a signing-capable provider
228
+ issuerApi, requireAuthorization: true,
229
+ });
230
+ ```
231
+
232
+ The issuer records `svc:<did>` as the claimer and only that identity can lower or void the hold; no
233
+ claim token is issued, so nothing can leak. The signature covers the action, the authorization id and
234
+ the amount/reason fields, plus a one-time nonce and timestamp, so a captured call can't be replayed or
235
+ edited. A `serviceDid` with no key (or a non-DID label) keeps the 0.7.0 token behaviour. Limits, stated
236
+ plainly: the issuer verifies the key controls the DID, not that the DID is one you trust — see spec
237
+ §8.7.6. The `daemon` key provider does not sign service messages yet.
238
+
239
+ **0.7.0 — the claim token is relayed, and a Service can close the hold it claimed.** The issuer now
240
+ treats a *claimed* hold as a commitment: it stays against the mandate's cap until it is settled (it no
241
+ longer lapses with the 15-minute hold TTL), and once claimed it can be settled *below* its amount, or
242
+ voided, only with the **claim token** returned by that hold's successful claim. Without that, an agent
243
+ could wait for a Service to execute and then capture `$0` (or void) its own hold to get the budget back
244
+ — 26 × $250 executed against a $5,000 cap that way. This package used to discard the token, so a Service
245
+ could neither settle below the hold nor release one after an upstream failure. Now:
246
+
247
+ - `verifyRequest()` / `guardIncomingTool()` return the token as `decision.claimToken` (and
248
+ `decision.authorizationId`) on a claimed permit. They are **non-enumerable**, so an echoed,
249
+ logged, spread or `JSON.stringify`-ed verdict does not carry the token to the calling agent —
250
+ the one party that must not have it. Keep it server-side.
251
+ - New guard methods, all best-effort and non-throwing (they return `{ ok, reasonCode }`):
252
+ `captureAuthorization({ authorizationId, claimToken, amountCharged, bookingRef?, settlementTxHash? })`,
253
+ `releaseAuthorization({ authorizationId, claimToken, reason? })`, and
254
+ `markAuthorizationUnknown({ authorizationId, reason? })`.
255
+ - `guardIncomingTool(action, handler, { settle: true })` settles a handler that returns (at the
256
+ authorized amount) and parks one that throws as **UNKNOWN** — it never *releases* on a throw, because a
257
+ throw does not prove nothing was executed. Off by default: an existing embed is unchanged.
258
+ - **Release only what provably did not happen.** `releaseAuthorization` returns the budget. Use it when
259
+ the upstream cleanly refused; use `markAuthorizationUnknown` for a timeout, a 5xx or a dropped
260
+ connection, which keeps the spend committed and hands it to reconciliation. Failing to settle can only
261
+ over-count spend, never under-count it.
262
+
162
263
  ### Replay, cumulative spend, rate limits, breakers, spend anomalies (`requireAuthorization`)
163
264
 
164
265
  Re-evaluating policy per request (above) proves the request is well-formed and in-policy — it
@@ -12,11 +12,28 @@
12
12
  // Dependencies are the two generated, zero-external-dependency bundles:
13
13
  // policy-core.mjs (deterministic evaluator) and magp-did.mjs (key-in-DID verify).
14
14
  import crypto from 'node:crypto';
15
- import { evaluate, buildAuthMessage, applySignedLast, operatingModeGate } from './policy-core.mjs';
15
+ import { evaluate, buildAuthMessage, applySignedLast, operatingModeGate, buildRuleContext, riskFloorFor, maxRisk, normalizeRiskLevel } from './policy-core.mjs';
16
16
  import { verifyDidSignature } from './magp-did.mjs';
17
17
  import { buildPaymentRequirements, checkSettlementBinding } from './x402.mjs';
18
18
  import { verifyBundle } from './magp-policy.mjs';
19
19
  import { resolveKeyProvider } from './key-providers.mjs';
20
+ import { PAYLOAD_DIGEST_HEADER, buildPayloadBindingMessage, claimDigestField, isPayloadDigest, payloadDigestOf, toWireJson } from './payload-binding.mjs';
21
+
22
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
23
+
24
+ /**
25
+ * A `trustedContext` this Service configured must be a real object, and any `riskLevel` in it must be a real
26
+ * level. One that is not (a lookup that missed and returned undefined, a typo, a junk value) means the deriver is
27
+ * BROKEN — and the safe reading of a broken deriver is a refused call, never "carry on with the agent's word",
28
+ * which is exactly what the deriver was there to avoid. `undefined` itself means "not configured".
29
+ */
30
+ function assertTrustedContext(tc, where) {
31
+ if (tc === undefined) return;
32
+ if (tc === null || typeof tc !== 'object' || Array.isArray(tc)) throw new Error(`trustedContext${where ? ` for ${where}` : ''} must be an object (got ${tc === null ? 'null' : Array.isArray(tc) ? 'an array' : typeof tc})`);
33
+ if (Object.prototype.hasOwnProperty.call(tc, 'riskLevel') && normalizeRiskLevel(tc.riskLevel) === null) {
34
+ throw new Error(`trustedContext${where ? ` for ${where}` : ''}.riskLevel is not one of low|medium|high|critical`);
35
+ }
36
+ }
20
37
 
21
38
  /** Freshness window for signed requests and handshake nonces (spec §7.7). How far `issuedAt`
22
39
  * may be BEHIND server time — network/processing delay. */
@@ -115,26 +132,193 @@ export function createMcpGuard({ serviceDid, serviceKey, keyProvider: keyProvide
115
132
  * entirely) could be presented to unlock a completely different one — the same confused-deputy
116
133
  * shape payload binding closes at the request layer, recurring one layer deeper.
117
134
  */
118
- async function claimAuthorization({ authorizationId } = {}) {
135
+ /**
136
+ * Headers that sign one settlement-surface call as THIS Service (`x-magp-service-*`), or `{}` when it
137
+ * cannot: no `serviceKey`/`keyProvider` that can sign arbitrary messages, or a `serviceDid` that is not
138
+ * self-certifying (only did:key and did:hedera embed the verification key, so the issuer needs no
139
+ * registry to check them). The signed message is domain-separated from every other MAGP signature and
140
+ * binds the action, the authorization id and the call's own values:
141
+ *
142
+ * MAGP-SERVICE-v1 | action | authorizationId | ...fields | nonce | issuedAt (each field "\" and "|" escaped)
143
+ *
144
+ * An authenticated claim records this Service's DID on the effect chain; from then on only this identity
145
+ * may settle the hold below its amount, void it, or mark it unknown, and no bearer token is issued.
146
+ */
147
+ async function serviceAuthHeaders(action, authorizationId, fields = []) {
148
+ if (!serviceDid || !/^did:(key|hedera):/.test(serviceDid)) return {};
149
+ if (!keyProvider || typeof keyProvider.signServiceMessage !== 'function') return {};
150
+ const escape = (v) => String(v).replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
151
+ const nonce = crypto.randomUUID();
152
+ const issuedAt = new Date().toISOString();
153
+ const message = ['MAGP-SERVICE-v1', action, authorizationId, ...fields, nonce, issuedAt].map(escape).join('|');
154
+ const signature = await keyProvider.signServiceMessage(message);
155
+ return { 'x-magp-service-did': serviceDid, 'x-magp-service-nonce': nonce, 'x-magp-service-issued-at': issuedAt, 'x-magp-service-signature': signature };
156
+ }
157
+
158
+ /**
159
+ * Claim the authorization, retrying ONCE when the answer is lost.
160
+ *
161
+ * A claim whose response never arrives (dropped connection, a 5xx from a proxy in front of the issuer) is
162
+ * ambiguous: it may have landed. Without more, this Service could not tell "my claim landed" from "someone
163
+ * else claimed", would refuse, and the hold would sit claimed — committed to the cap — with nobody executing
164
+ * it. So every claim CALL carries a fresh unguessable `Idempotency-Key`, reused only for the retry of that
165
+ * same call: the issuer recognises the retry as this claimant's own and returns the original grant
166
+ * (`replayed: true`) instead of refusing. The key is per call and never persisted, so a restarted process, or a
167
+ * later request for the same authorization, gets no replay — a claim is still single-use.
168
+ *
169
+ * Only an AMBIGUOUS failure is retried (no response, a 5xx, or the issuer's retryable
170
+ * EFFECT_TRANSITION_CONTENDED). A definite answer, including AUTHORIZATION_ALREADY_CLAIMED, is final and is
171
+ * returned as it is.
172
+ */
173
+ async function claimAuthorization({ authorizationId, payloadDigest } = {}) {
119
174
  if (!authorizationId) return { claimed: false, reasonCode: 'AUTHORIZATION_REQUIRED' };
120
175
  if (!base) throw new Error('issuerApi is required to claim an authorization');
176
+ if (payloadDigest !== undefined && !isPayloadDigest(payloadDigest)) return { claimed: false, reasonCode: 'PAYLOAD_DIGEST_INVALID' };
177
+ const idempotencyKey = crypto.randomUUID().replace(/-/g, '');
178
+ const attempts = 2;
179
+ let lastError;
180
+ for (let attempt = 1; attempt <= attempts; attempt++) {
181
+ try {
182
+ // Re-signed per attempt (a fresh nonce; the signature also covers the key, so it cannot be swapped or stripped).
183
+ // `payloadDigest` — the digest of exactly what THIS Service is about to execute — is one more signed field and rides
184
+ // as a header: the issuer compares it with the digest the AGENT signed for this authorization and refuses the claim
185
+ // (leaving the hold unclaimed) on any difference. Payload binding, spec 8.3.9 / 8.7.11.
186
+ const auth = await serviceAuthHeaders('claim', authorizationId, [idempotencyKey, ...(payloadDigest ? [claimDigestField(payloadDigest)] : [])]);
187
+ const res = await fetch(`${base}/policy/mandate/authorize/${encodeURIComponent(authorizationId)}/effect/dispatching`, {
188
+ method: 'POST',
189
+ headers: { ...auth, 'idempotency-key': idempotencyKey, ...(payloadDigest ? { [PAYLOAD_DIGEST_HEADER]: payloadDigest } : {}) },
190
+ });
191
+ const body = await res.json().catch(() => null);
192
+ // Ambiguous, so worth one retry with the same key: a 5xx, or EFFECT_TRANSITION_CONTENDED — the issuer's
193
+ // "an overlapping attempt of yours is mid-claim, ask again", which is not a refusal.
194
+ const ambiguous = res.status >= 500 || (res.status === 409 && body?.message === 'EFFECT_TRANSITION_CONTENDED');
195
+ if (ambiguous && attempt < attempts) { lastError = `HTTP ${res.status}`; await sleep(150); continue; }
196
+ if (!res.ok) return { claimed: false, reasonCode: body?.message ?? body?.data?.reasonCode ?? `AUTHORIZATION_CLAIM_HTTP_${res.status}` };
197
+ return {
198
+ claimed: true,
199
+ agentDid: body?.data?.agentDid,
200
+ action: body?.data?.action,
201
+ amount: body?.data?.amount,
202
+ currency: body?.data?.currency,
203
+ merchant: body?.data?.merchant,
204
+ // The digest the agent signed for this authorization (null = unbound); absent from an issuer that predates it.
205
+ payloadDigest: body?.data?.payloadDigest,
206
+ // The settlement token the issuer hands ONLY the caller whose claim succeeded. Once a hold is
207
+ // claimed it can be settled below its amount, or voided, only with this token — so the Service
208
+ // that executes must keep it and present it (captureAuthorization / releaseAuthorization).
209
+ claimToken: body?.data?.claimToken,
210
+ // This claim was signed as this Service's own identity, so the issuer recorded it as the claimant.
211
+ counterpartyAuthenticated: 'x-magp-service-did' in auth,
212
+ // True when the issuer answered a RETRY with the grant of this call's own earlier attempt.
213
+ replayed: body?.data?.replayed === true,
214
+ };
215
+ } catch (err) {
216
+ lastError = String(err?.message ?? err);
217
+ if (attempt < attempts) { await sleep(150); continue; }
218
+ }
219
+ }
220
+ return { claimed: false, reasonCode: 'AUTHORIZATION_CLAIM_UNREACHABLE', error: lastError };
221
+ }
222
+
223
+ /**
224
+ * What became of an authorization? Public, keyed by the authorization id (no signature needed). Use it when a
225
+ * claim was refused with AUTHORIZATION_ALREADY_CLAIMED, or before deciding whether to retry anything:
226
+ *
227
+ * `outcome` not_started | expired | in_flight | settled | not_executed | unknown | reversing | reversed | reversal_failed
228
+ * `nothingExecuted` nothing has executed SO FAR (not_started, expired, not_executed)
229
+ * `retrySafe` nothing can execute LATER either, so a fresh authorization cannot duplicate this one:
230
+ * true ONLY for expired and not_executed
231
+ *
232
+ * Retry only on `retrySafe`. `not_started` is nothingExecuted but NOT retrySafe: the hold can still be claimed
233
+ * until its window closes, so a request queued behind a slow gateway could run it too (void it first, then it
234
+ * reads not_executed). `unknown` and `in_flight` are neither — an ambiguous outcome must be reconciled, never
235
+ * retried blindly. Best-effort and non-throwing: `{ ok: false, reasonCode }` when the issuer cannot be asked.
236
+ */
237
+ async function lookupOutcome({ authorizationId } = {}) {
238
+ if (!authorizationId) return { ok: false, reasonCode: 'AUTHORIZATION_REQUIRED' };
239
+ if (!base) return { ok: false, reasonCode: 'ISSUER_API_REQUIRED' };
121
240
  try {
122
- const res = await fetch(`${base}/policy/mandate/authorize/${encodeURIComponent(authorizationId)}/effect/dispatching`, { method: 'POST' });
241
+ const res = await fetch(`${base}/policy/mandate/authorize/${encodeURIComponent(authorizationId)}/effect`);
123
242
  const body = await res.json().catch(() => null);
124
- if (!res.ok) return { claimed: false, reasonCode: body?.message ?? body?.data?.reasonCode ?? `AUTHORIZATION_CLAIM_HTTP_${res.status}` };
125
- return {
126
- claimed: true,
127
- agentDid: body?.data?.agentDid,
128
- action: body?.data?.action,
129
- amount: body?.data?.amount,
130
- currency: body?.data?.currency,
131
- merchant: body?.data?.merchant,
132
- };
243
+ if (!res.ok || !body?.data) return { ok: false, status: res.status, reasonCode: body?.message ?? `OUTCOME_HTTP_${res.status}` };
244
+ return { ok: true, ...body.data };
133
245
  } catch (err) {
134
- return { claimed: false, reasonCode: 'AUTHORIZATION_CLAIM_UNREACHABLE', error: String(err?.message ?? err) };
246
+ return { ok: false, reasonCode: 'ISSUER_UNREACHABLE', error: String(err?.message ?? err) };
135
247
  }
136
248
  }
137
249
 
250
+ /**
251
+ * Attach the claim to a PERMIT verdict as NON-ENUMERABLE properties. The verdict is routinely
252
+ * echoed onward (logged, put in a response, spread into another object), and the calling agent
253
+ * is exactly the party the claim token must be kept from: with it, the agent could void an
254
+ * executed hold or settle it for less. A non-enumerable property survives normal use
255
+ * (`decision.claimToken`) but not JSON.stringify or `{ ...decision }`.
256
+ */
257
+ function withClaim(verdict, authorizationId, claimToken, authenticated = false) {
258
+ if (!claimToken && !authenticated) return verdict;
259
+ const out = { ...verdict };
260
+ if (claimToken) Object.defineProperty(out, 'claimToken', { value: claimToken, enumerable: false });
261
+ // `true` when the claim was signed as this Service's own identity: there is then NO token, and the
262
+ // settlement helpers below authenticate each call by signing it instead.
263
+ if (authenticated) Object.defineProperty(out, 'counterpartyAuthenticated', { value: true, enumerable: false });
264
+ Object.defineProperty(out, 'authorizationId', { value: authorizationId, enumerable: false });
265
+ return out;
266
+ }
267
+
268
+ /** One best-effort call to the issuer's settlement surface. Never throws. */
269
+ async function issuerPost(path, body, extraHeaders = {}) {
270
+ if (!base) return { ok: false, reasonCode: 'ISSUER_API_REQUIRED' };
271
+ try {
272
+ const res = await fetch(`${base}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...extraHeaders }, body: JSON.stringify(body ?? {}) });
273
+ const payload = await res.json().catch(() => null);
274
+ if (!res.ok) return { ok: false, status: res.status, reasonCode: payload?.message ?? payload?.data?.reasonCode ?? `ISSUER_HTTP_${res.status}` };
275
+ // void answers 200 with success:false when the hold was not voidable (e.g. already settled)
276
+ if (payload?.success === false) return { ok: false, status: res.status, reasonCode: payload?.data?.reasonCode ?? payload?.message ?? 'NOT_APPLIED', data: payload?.data ?? null };
277
+ return { ok: true, status: res.status, data: payload?.data ?? null };
278
+ } catch (err) {
279
+ return { ok: false, reasonCode: 'ISSUER_UNREACHABLE', error: String(err?.message ?? err) };
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Settle a claimed hold once the Service has actually executed. `claimToken` is the one the
285
+ * successful claim returned (`verdict.claimToken`); it is required to settle BELOW the authorized
286
+ * amount, and unnecessary at the full amount. Best-effort and non-throwing: a failure here never
287
+ * turns an executed call into an error, and a claimed hold stays committed to the mandate's cap
288
+ * either way, so failing to settle can only over-count spend, never under-count it.
289
+ */
290
+ async function captureAuthorization({ authorizationId, claimToken, amountCharged, bookingRef, settlementTxHash } = {}) {
291
+ if (!authorizationId) return { ok: false, reasonCode: 'AUTHORIZATION_REQUIRED' };
292
+ if (!Number.isFinite(Number(amountCharged))) return { ok: false, reasonCode: 'AMOUNT_CHARGED_REQUIRED' };
293
+ const amount = Number(amountCharged);
294
+ const auth = await serviceAuthHeaders('capture', authorizationId, [String(amount), bookingRef ?? '', settlementTxHash ?? '']);
295
+ return issuerPost(`/policy/mandate/authorize/${encodeURIComponent(authorizationId)}/capture`, { amountCharged: amount, bookingRef, settlementTxHash, claimToken }, auth);
296
+ }
297
+
298
+ /**
299
+ * Release a claimed hold whose effect provably did NOT happen (the upstream cleanly rejected it),
300
+ * returning its budget to the mandate. Requires the claim token: the issuer refuses to void a
301
+ * claimed hold for anyone else, because the agent could otherwise wait for this Service to
302
+ * execute and then void its own hold. Do NOT call this for an ambiguous outcome (a timeout, a 5xx,
303
+ * a dropped connection) — use markAuthorizationUnknown so the spend stays committed.
304
+ */
305
+ async function releaseAuthorization({ authorizationId, claimToken, reason } = {}) {
306
+ if (!authorizationId) return { ok: false, reasonCode: 'AUTHORIZATION_REQUIRED' };
307
+ const auth = await serviceAuthHeaders('void', authorizationId, [reason ?? '']);
308
+ return issuerPost(`/policy/mandate/authorize/${encodeURIComponent(authorizationId)}/void`, { reason, claimToken }, auth);
309
+ }
310
+
311
+ /**
312
+ * Report that the outcome of an executed-or-not call is unknown (response lost, 5xx, timeout).
313
+ * The effect moves to UNKNOWN, which keeps the spend committed and hands it to reconciliation
314
+ * instead of guessing in either direction.
315
+ */
316
+ async function markAuthorizationUnknown({ authorizationId, reason } = {}) {
317
+ if (!authorizationId) return { ok: false, reasonCode: 'AUTHORIZATION_REQUIRED' };
318
+ const auth = await serviceAuthHeaders('unknown', authorizationId, [reason ?? '']);
319
+ return issuerPost(`/policy/mandate/authorize/${encodeURIComponent(authorizationId)}/effect/unknown`, { reason }, auth);
320
+ }
321
+
138
322
  async function loadBundle(agentDid) {
139
323
  if (typeof fetchBundle === 'function') return fetchBundle(agentDid);
140
324
  if (!base) throw new Error('issuerApi (or fetchBundle) is required to load the policy bundle');
@@ -150,15 +334,42 @@ export function createMcpGuard({ serviceDid, serviceKey, keyProvider: keyProvide
150
334
  return body.data;
151
335
  }
152
336
 
153
- /** Evaluate the agent's bundle against the request via policy-core (signed fields last). */
154
- function verdictFromBundle(bundle, req) {
155
- const { agentDid, action, amount = 0, currency = 'USD', merchant = '', itinerary = {}, cumulativeSpend = amount, now } = req;
156
- const mandate = (bundle.mandates ?? []).find((m) => m.action === action)?.document;
337
+ /**
338
+ * Evaluate the agent's bundle against the request via policy-core (signed fields last).
339
+ *
340
+ * `trustedContext` is context THIS SERVICE derived from the real request (never from the agent): it is applied
341
+ * over the agent's claim and labelled `gateway_derived`, so a rule can require it (`requireProvenance`) and a
342
+ * lie in the itinerary cannot outvote it. The mandate's own `riskTier` (in the signed bundle) is a risk floor
343
+ * under whatever the agent claims, exactly as at the issuer's gate (spec §6.4.3).
344
+ */
345
+ function verdictFromBundle(bundle, req, trustedContext) {
346
+ const { agentDid, action, amount = 0, currency = 'USD', merchant = '', resource = null, itinerary = {}, cumulativeSpend = amount, now } = req;
347
+ const mandates = bundle.mandates ?? [];
348
+ const mandate = mandates.find((m) => m.action === action)?.document;
349
+ // No mandate covers this action at all — refuse outright, matching mandate.service.ts's
350
+ // own first check (before signature/standards/anything else). Without this, `evaluate()`
351
+ // treats an omitted `mandate` as "skip the mandate layer" (its own documented, intentional
352
+ // behavior for a caller that never resolves one at all) — found live: an ungranted action
353
+ // with no Standard/SOP molecule happening to also catch it was silently ALLOWED here,
354
+ // while the hosted gate and the agent SDK both correctly refused the identical request.
355
+ if (!mandate) {
356
+ return { decision: 'block', reasonCode: mandates.length > 0 ? 'NO_PERMISSION_FOR_ACTION' : 'NO_MANDATE', authorizationId: null, remaining: null, proofRef: null };
357
+ }
157
358
  return evaluate({
158
359
  standards: (bundle.standards ?? []).map((s) => ({ standardKey: s.key, document: s.document })),
159
360
  sops: (bundle.sops ?? []).map((s) => ({ standardKey: `sop:${s.id}`, document: s.document })),
160
361
  mandate,
161
- context: applySignedLast(itinerary, { action, agentDid, amount }),
362
+ // currency/merchant/resource are signed fields, same as action/agentDid/amount above —
363
+ // omitting them here (found live: they were) means a currency-scoped amount-over/
364
+ // cumulative-over Standards/SOP atom always sees currency as absent and fires closed
365
+ // (SOP_SPEND_CAP on a genuinely in-cap request), and a resource-scope atom never runs
366
+ // at all. Mirrors mandate.service.ts's ruleCtx (PR #588), the parity target for this.
367
+ context: buildRuleContext({
368
+ unsigned: itinerary,
369
+ signed: { action, agentDid, amount, currency, merchant, resource },
370
+ gatewayDerived: trustedContext,
371
+ riskFloor: riskFloorFor(mandate, action),
372
+ }),
162
373
  mandateRequest: mandate
163
374
  ? {
164
375
  target: action,
@@ -172,6 +383,9 @@ export function createMcpGuard({ serviceDid, serviceKey, keyProvider: keyProvide
172
383
  // — omitting this would make EVERY unit-bearing cap fail regardless of amount.
173
384
  // Defaults to 'USD', matching verifyRequest()'s own default for this field.
174
385
  'mm:currency': currency,
386
+ // Unprefixed `resource` (not `mm:resource`) to match the constraint's own
387
+ // leftOperand (ResourceService.scopeConstraint()) — mirrors mandate.service.ts.
388
+ resource,
175
389
  }),
176
390
  }
177
391
  : undefined,
@@ -182,20 +396,49 @@ export function createMcpGuard({ serviceDid, serviceKey, keyProvider: keyProvide
182
396
  * Verify an agent's presented signed authorize request, then re-evaluate policy
183
397
  * locally against the agent's issuer-hosted bundle. Fails CLOSED: any bad
184
398
  * signature, staleness, fetch error, or evaluation error returns a block.
185
- * @param {{agentDid,action,amount?,currency?,merchant?,itinerary?,nonce,issuedAt,signature}} signed
399
+ * @param {{agentDid,action,amount?,currency?,merchant?,resource?,itinerary?,nonce,issuedAt,signature}} signed
400
+ * @param {{ trustedContext?: Record<string, unknown> }} [options] `trustedContext`: context this Service derived
401
+ * from the real request (e.g. `{ riskLevel: 'high' }` for a wire-transfer route) — NEVER anything the agent sent.
402
+ * It outranks the agent's claim and is labelled `gateway_derived`; a risk it states can be raised by the agent
403
+ * but not lowered. Without it the rules see the agent's own claim, as before.
186
404
  * @returns {Promise<{decision:'allow'|'observe'|'block'|'escalate'|'suspend'|'quarantine',reasonCode:string|null}>}
187
405
  */
188
- async function verifyRequest(signed = {}) {
406
+ async function verifyRequest(signed = {}, { trustedContext, payloadDigest, requirePayloadBinding } = {}) {
189
407
  try {
190
- const { agentDid, action, amount = 0, currency = 'USD', merchant = '', nonce, issuedAt, signature } = signed;
408
+ assertTrustedContext(trustedContext); // a broken deriver is a refused request (GUARD_ERROR), never a quiet downgrade
409
+ const { agentDid, action, amount = 0, currency = 'USD', merchant = '', resource = null, nonce, issuedAt, signature } = signed;
191
410
  if (!agentDid || !action || !nonce || !issuedAt || !signature) {
192
411
  return { decision: 'block', reasonCode: 'MALFORMED_REQUEST' };
193
412
  }
194
413
  // 1. Signature over the canonical message (§7.3), verified via key-in-DID (§4.1.2).
195
- const message = buildAuthMessage({ agentDid, action, amount, currency, merchant, nonce, issuedAt });
414
+ // `resource` MUST be included it's the 8th signed field (canonical.ts); omitting it
415
+ // here (found live: it was) rejects every genuinely-valid resource-bearing signature.
416
+ const message = buildAuthMessage({ agentDid, action, amount, currency, merchant, resource, nonce, issuedAt });
196
417
  if (!verifyDidSignature(agentDid, message, signature)) {
197
418
  return { decision: 'block', reasonCode: 'SIGNATURE_INVALID' };
198
419
  }
420
+ // 1b. Payload binding (spec 8.3.9). A digest with no valid signature over it binds nothing — refuse it. When THIS Service
421
+ // states the digest of what it is about to execute (`payloadDigest`), it must be the one the agent signed: a mismatch is
422
+ // refused here, before any claim, so it costs no round trip. (The ISSUER re-checks the same thing against the digest it
423
+ // stored at authorize time when the claim is made — this early check is only cheaper, never the authority.)
424
+ const signedDigest = signed.payloadDigest;
425
+ if (signedDigest !== undefined || signed.payloadSignature !== undefined) {
426
+ const bound = isPayloadDigest(signedDigest) && typeof signed.payloadSignature === 'string'
427
+ && verifyDidSignature(agentDid, buildPayloadBindingMessage({ agentDid, action, nonce, issuedAt, payloadDigest: signedDigest }), signed.payloadSignature);
428
+ if (!bound) return { decision: 'block', reasonCode: 'PAYLOAD_BINDING_INVALID' };
429
+ }
430
+ if (payloadDigest !== undefined) {
431
+ if (!isPayloadDigest(payloadDigest)) return { decision: 'block', reasonCode: 'PAYLOAD_DIGEST_INVALID' };
432
+ if (signedDigest === undefined) {
433
+ // The executor has a payload; the agent bound none. Refuse only when binding is REQUIRED — otherwise this is an
434
+ // unbound request, exactly as before payload binding existed (and it is not claimed with a digest: see below).
435
+ if (requirePayloadBinding) return { decision: 'block', reasonCode: 'PAYLOAD_BINDING_REQUIRED' };
436
+ } else if (signedDigest !== payloadDigest) {
437
+ return { decision: 'block', reasonCode: 'PAYLOAD_NOT_BOUND' };
438
+ }
439
+ } else if (requirePayloadBinding && signedDigest === undefined) {
440
+ return { decision: 'block', reasonCode: 'PAYLOAD_BINDING_REQUIRED' };
441
+ }
199
442
  // 2. Freshness. (Single-use nonce consumption stays the gate's job by default — a Service
200
443
  // re-check is verification, not a second authorization. requireAuthorization below is
201
444
  // the opt-in exception: it DOES give the Service its own single-use claim.)
@@ -223,7 +466,11 @@ export function createMcpGuard({ serviceDid, serviceKey, keyProvider: keyProvide
223
466
  // as a non-enumerable sibling. READ_ONLY refuses a value-bearing action up-front;
224
467
  // SUPERVISED/RESTRICTED only ESCALATE, applied to the verdict below so a rule block
225
468
  // still outranks the floor (most-restrictive-wins, mirroring the gate).
226
- const modeGate = operatingModeGate(bundle?.__operatingMode?.mode, { amount, riskLevel: signed?.itinerary?.riskLevel });
469
+ // The risk it judges is the EFFECTIVE one — the owner's tier (in the signed bundle) and this Service's own
470
+ // derivation are floors under the agent's claim, so "low" cannot dodge the SUPERVISED high-risk escalation.
471
+ const mandateForRisk = (bundle?.mandates ?? []).find((m) => m.action === action)?.document;
472
+ const effectiveRisk = maxRisk(riskFloorFor(mandateForRisk, action), normalizeRiskLevel(trustedContext?.riskLevel), normalizeRiskLevel(signed?.itinerary?.riskLevel)) ?? undefined;
473
+ const modeGate = operatingModeGate(bundle?.__operatingMode?.mode, { amount, riskLevel: effectiveRisk });
227
474
  if (modeGate.decision === 'block') return { decision: 'block', reasonCode: modeGate.reasonCode };
228
475
  // 3b. Signed-bundle verification + risk-tiered fail-closed (Phase F, §5.3.2/§5.3.3). When a
229
476
  // policy key is configured, a value-bearing action (amount > 0) MUST fail closed on an
@@ -233,7 +480,7 @@ export function createMcpGuard({ serviceDid, serviceKey, keyProvider: keyProvide
233
480
  const v = verifyBundle(bundle, { publicKey: policyPublicKey, valueBearing: Number(amount) > 0 });
234
481
  if (!v.ok) return { decision: 'block', reasonCode: v.reasonCode };
235
482
  }
236
- const verdict = verdictFromBundle(bundle, { ...signed, itinerary: signed.itinerary ?? {} });
483
+ const verdict = verdictFromBundle(bundle, { ...signed, itinerary: signed.itinerary ?? {} }, trustedContext);
237
484
  // Mode ESCALATE floor lifts an otherwise-PERMIT (allow or observe) to human review
238
485
  // (escalate outranks observe, so a flag never masks it) — mirrors the backend gate.
239
486
  const final = (verdict.decision === 'allow' || verdict.decision === 'observe') && modeGate.decision === 'escalate'
@@ -243,9 +490,19 @@ export function createMcpGuard({ serviceDid, serviceKey, keyProvider: keyProvide
243
490
  // stop REPLAY or CUMULATIVE SPEND past the mandate total — both are the stateful issuer
244
491
  // gate's job. Only claim on an actual PERMIT: escalate/block/suspend/quarantine execute
245
492
  // nothing, so there is nothing to protect and no reason to spend the hold's single use.
493
+ let claimToken; let claimAuthenticated = false;
246
494
  if (requireAuthorization && (final.decision === 'allow' || final.decision === 'observe')) {
247
- const claim = await claimAuthorization({ authorizationId: signed.authorizationId });
495
+ // The claim states the digest of what THIS Service is about to execute — only when the agent bound one (a digest for
496
+ // an unbound authorization is refused by the issuer: this Service would be asserting a binding that does not exist).
497
+ const claimDigest = payloadDigest !== undefined && signedDigest !== undefined ? payloadDigest : undefined;
498
+ const claim = await claimAuthorization({ authorizationId: signed.authorizationId, payloadDigest: claimDigest });
499
+ claimToken = claim.claimToken; claimAuthenticated = claim.counterpartyAuthenticated === true;
248
500
  if (!claim.claimed) return { decision: 'block', reasonCode: claim.reasonCode };
501
+ // The grant states the digest the hold is bound to (null = unbound), so it must be the one this claim stated. The issuer
502
+ // already refused a claim whose digest differed; this catches an issuer that did NOT compare — one that predates payload
503
+ // binding ignores the header and its grant carries no digest at all, which is "not enforced", never "fine". Only
504
+ // reachable when the agent bound a payload, a flow an issuer that predates binding cannot honour anyway.
505
+ if ((claim.payloadDigest ?? null) !== (claimDigest ?? null)) return { decision: 'block', reasonCode: 'PAYLOAD_DIGEST_MISMATCH' };
249
506
  // The claim alone only proves SOME real, unclaimed authorization exists — it must also
250
507
  // be FOR this agent and these exact values, or a cheap legitimate hold's id could be
251
508
  // presented to unlock a completely different, more expensive execution. Each check is
@@ -268,7 +525,9 @@ export function createMcpGuard({ serviceDid, serviceKey, keyProvider: keyProvide
268
525
  if (claim.currency !== undefined && claim.currency !== currency) return { decision: 'block', reasonCode: 'AUTHORIZATION_CURRENCY_MISMATCH' };
269
526
  if (claim.merchant !== undefined && claim.merchant !== merchant) return { decision: 'block', reasonCode: 'AUTHORIZATION_MERCHANT_MISMATCH' };
270
527
  }
271
- return final;
528
+ // On a claimed permit the Service that executes needs the claim token to settle or release the
529
+ // hold afterwards (non-enumerable — see withClaim).
530
+ return withClaim(final, signed.authorizationId, claimToken, claimAuthenticated);
272
531
  } catch (err) {
273
532
  return { decision: 'block', reasonCode: 'GUARD_ERROR', error: String(err?.message ?? err) };
274
533
  }
@@ -279,8 +538,38 @@ export function createMcpGuard({ serviceDid, serviceKey, keyProvider: keyProvide
279
538
  * agent's signed request must be passed as the first argument. Throws
280
539
  * GovernanceBlocked on any non-allow decision.
281
540
  */
282
- function guardIncomingTool(action, handler) {
541
+ function guardIncomingTool(action, handler, { settle: settleClaim = false, trustedContext, bindPayload = false, requirePayloadBinding = false } = {}) {
542
+ // `requirePayloadBinding` only checks that the AGENT bound something; the comparison with what this tool executes needs a
543
+ // digest of it. Without `bindPayload` there is nothing to compare, so the option would give assurance it does not provide.
544
+ if (requirePayloadBinding && !bindPayload) {
545
+ throw new Error(`guardIncomingTool("${action}"): requirePayloadBinding needs bindPayload — without a digest of what the tool executes there is nothing to compare the binding with`);
546
+ }
283
547
  return async (signed, ...rest) => {
548
+ // Payload binding (spec 8.3.9): the digest of what THIS call is about to execute. `bindPayload: true` digests the tool's
549
+ // single argument after the signed request and hands the handler that same JSON snapshot, so what is digested is what
550
+ // runs (a `toJSON()` or a later mutation cannot make them differ); a tool with any other shape passes a function that
551
+ // returns what to digest, and then owns making the handler execute exactly that. Computed BEFORE the request is verified
552
+ // so a payload JSON cannot carry refuses the call rather than skipping the check.
553
+ let executorDigest;
554
+ if (bindPayload) {
555
+ try {
556
+ let payload;
557
+ if (typeof bindPayload === 'function') {
558
+ payload = await bindPayload(signed, ...rest);
559
+ } else {
560
+ if (rest.length !== 1) throw new Error(`bindPayload: true digests exactly one argument after the signed request, got ${rest.length}; pass a function to say what to digest`);
561
+ payload = rest[0];
562
+ }
563
+ const wire = toWireJson(payload);
564
+ executorDigest = payloadDigestOf(wire);
565
+ if (typeof bindPayload !== 'function') rest = [wire];
566
+ } catch (err) {
567
+ const e = new Error(`MCP guard BLOCK "${action}": PAYLOAD_NOT_CANONICALIZABLE`);
568
+ e.name = 'GovernanceBlocked';
569
+ e.governance = { decision: 'block', reasonCode: 'PAYLOAD_NOT_CANONICALIZABLE', error: String(err?.message ?? err) };
570
+ throw e;
571
+ }
572
+ }
284
573
  // The WRAPPED TOOL's own `action` is authoritative — never `signed?.action` (the caller's
285
574
  // own claim). A Service that wraps more than one tool with ONE guard instance (the normal
286
575
  // MCP-server shape: many tools, one guard) previously let a genuinely-valid signature for
@@ -291,7 +580,14 @@ export function createMcpGuard({ serviceDid, serviceKey, keyProvider: keyProvide
291
580
  // this guard (e.g. a wire transfer) and have it execute under that unrelated verification.
292
581
  // Mirrors gateway.mjs's `route.action ?? signed.action` — "the route pins the action ...
293
582
  // the client can't pick it" — for exactly the same reason, one layer down at the tool call.
294
- const decision = await verifyRequest({ ...signed, action });
583
+ // `trustedContext` (an object, or `(signed, ...rest) => object`) is what THIS tool's author derived from
584
+ // the real call — the risk of wiring money vs reading a report — never the agent's claim. A throwing
585
+ // deriver fails the call closed (a governance error, not a silent downgrade to the agent's word).
586
+ const derived = typeof trustedContext === 'function' ? await trustedContext(signed, ...rest) : trustedContext;
587
+ // Configured but yielding nothing usable (a function that returned undefined) is a broken deriver, not "no
588
+ // trusted context": refuse rather than fall back to the agent's word. `undefined` option = not configured.
589
+ if (trustedContext !== undefined) assertTrustedContext(derived === undefined ? null : derived, `"${action}"`);
590
+ const decision = await verifyRequest({ ...signed, action }, { trustedContext: derived, payloadDigest: executorDigest, requirePayloadBinding });
295
591
  // allow/observe both PERMIT the tool call; observe is permit-but-flag (SAFR §11).
296
592
  if (decision.decision !== 'allow' && decision.decision !== 'observe') {
297
593
  const err = new Error(`MCP guard ${decision.decision.toUpperCase()} "${action}": ${decision.reasonCode}`);
@@ -338,6 +634,21 @@ export function createMcpGuard({ serviceDid, serviceKey, keyProvider: keyProvide
338
634
  if (!requireAuthorization && Number(signed?.amount) > 0) {
339
635
  console.warn(`[mcp-guard] "${action}" (amount=${signed.amount}) permitted in trustless mode — rate-limit, circuit-breaker, replay, cumulative-spend, and spend-anomaly floors are stateful and were NOT re-verified against live issuer state. Set requireAuthorization:true for custodial/value-bearing surfaces.`);
340
636
  }
637
+ // Opt-in settlement of a claimed hold (`requireAuthorization` + `{ settle: true }`): a handler
638
+ // that returns is settled at the authorized amount; one that throws is reported UNKNOWN, not
639
+ // released — a thrown error does not prove nothing was executed, and UNKNOWN keeps the spend
640
+ // committed until reconciliation decides. Off by default so existing embeds are unchanged.
641
+ if (settleClaim && (decision.claimToken || decision.counterpartyAuthenticated)) {
642
+ let result;
643
+ try {
644
+ result = await handler(signed, ...rest);
645
+ } catch (err) {
646
+ await markAuthorizationUnknown({ authorizationId: decision.authorizationId, reason: 'HANDLER_THREW' });
647
+ throw err;
648
+ }
649
+ await captureAuthorization({ authorizationId: decision.authorizationId, claimToken: decision.claimToken, amountCharged: Number(signed?.amount) });
650
+ return result;
651
+ }
341
652
  return handler(signed, ...rest);
342
653
  };
343
654
  }
@@ -398,7 +709,7 @@ export function createMcpGuard({ serviceDid, serviceKey, keyProvider: keyProvide
398
709
  return { settled: true, txHash: result.txHash, reasonCode: 'SETTLED' };
399
710
  }
400
711
 
401
- return { handshakeChallenge, handshakeVerify, verifyRequest, guardIncomingTool, requirePayment, settle, claimAuthorization, serviceDid };
712
+ return { handshakeChallenge, handshakeVerify, verifyRequest, guardIncomingTool, requirePayment, settle, claimAuthorization, lookupOutcome, captureAuthorization, releaseAuthorization, markAuthorizationUnknown, serviceDid };
402
713
  }
403
714
 
404
715
  /**
package/key-providers.mjs CHANGED
@@ -12,6 +12,14 @@ export function createStaticKeyProvider(serviceKeyHex) {
12
12
  async signHandshakeNonce(nonce) {
13
13
  return crypto.sign(null, Buffer.from(nonce, 'utf8'), privateKey).toString('hex');
14
14
  },
15
+ /**
16
+ * Sign one settlement-surface call (claim / capture / void / unknown) as this Service's own identity —
17
+ * the issuer verifies it against the key embedded in `serviceDid`. The daemon provider does not offer
18
+ * this yet, so a daemon-backed Service keeps making anonymous (token-bearing) calls.
19
+ */
20
+ async signServiceMessage(message) {
21
+ return crypto.sign(null, Buffer.from(message, 'utf8'), privateKey).toString('hex');
22
+ },
15
23
  };
16
24
  }
17
25
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metamynd/agentsafe-mcp-guard",
3
- "version": "0.6.1",
3
+ "version": "0.11.0",
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",
@@ -18,13 +18,14 @@
18
18
  "key-providers.mjs",
19
19
  "magp-policy.mjs",
20
20
  "magp-did.mjs",
21
+ "payload-binding.mjs",
21
22
  "policy-core.mjs",
22
23
  "x402.mjs",
23
24
  "README.md",
24
25
  "LICENSE"
25
26
  ],
26
27
  "scripts": {
27
- "test": "node mcp-guard.smoke.mjs && node pay.smoke.mjs && node decision-token.smoke.mjs && node claim-authorization.smoke.mjs && node guard-incoming-tool.smoke.mjs"
28
+ "test": "node mcp-guard.smoke.mjs && node pay.smoke.mjs && node decision-token.smoke.mjs && node claim-authorization.smoke.mjs && node claim-token.smoke.mjs && node payload-binding.smoke.mjs && node guard-incoming-tool.smoke.mjs"
28
29
  },
29
30
  "engines": {
30
31
  "node": ">=18"
@@ -0,0 +1,110 @@
1
+ // GENERATED from backend/src/features/magp/payload-binding.ts - do not edit. Regenerate: npm run build:mcp-guard-core
2
+
3
+ // src/features/magp/payload-binding.ts
4
+ import { createHash } from "node:crypto";
5
+
6
+ // src/policy-core/canonical.ts
7
+ function escapeField(v) {
8
+ return v.replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
9
+ }
10
+
11
+ // src/features/magp/payload-binding.ts
12
+ var PAYLOAD_BINDING_PREFIX = "MAGP-PAYLOAD-v1";
13
+ var PAYLOAD_DIGEST_PREFIX = "sha256:";
14
+ var PAYLOAD_DIGEST_HEADER = "x-magp-payload-digest";
15
+ var MAX_CANONICAL_PAYLOAD_BYTES = 256 * 1024;
16
+ var MAX_DEPTH = 32;
17
+ var PayloadNotCanonicalizable = class extends Error {
18
+ constructor(message) {
19
+ super(message);
20
+ this.name = "PayloadNotCanonicalizable";
21
+ }
22
+ };
23
+ function hasLoneSurrogate(s) {
24
+ for (let i = 0; i < s.length; i++) {
25
+ const c = s.charCodeAt(i);
26
+ if (c >= 55296 && c <= 56319) {
27
+ const next = s.charCodeAt(i + 1);
28
+ if (!(next >= 56320 && next <= 57343)) return true;
29
+ i++;
30
+ } else if (c >= 56320 && c <= 57343) {
31
+ return true;
32
+ }
33
+ }
34
+ return false;
35
+ }
36
+ function serialize(value, depth, path) {
37
+ if (depth > MAX_DEPTH) throw new PayloadNotCanonicalizable(`payload is nested deeper than ${MAX_DEPTH} levels at ${path}`);
38
+ if (value === null) return "null";
39
+ switch (typeof value) {
40
+ case "boolean":
41
+ return value ? "true" : "false";
42
+ case "number":
43
+ if (!Number.isFinite(value)) throw new PayloadNotCanonicalizable(`${path} is not a finite number`);
44
+ return JSON.stringify(value);
45
+ // ECMAScript Number::toString — what RFC 8785 specifies; -0 serialises as "0"
46
+ case "string":
47
+ if (hasLoneSurrogate(value)) throw new PayloadNotCanonicalizable(`${path} contains an unpaired surrogate`);
48
+ return JSON.stringify(value);
49
+ case "object": {
50
+ if (Array.isArray(value)) return `[${value.map((v, i) => serialize(v, depth + 1, `${path}[${i}]`)).join(",")}]`;
51
+ const proto = Object.getPrototypeOf(value);
52
+ if (proto !== Object.prototype && proto !== null) throw new PayloadNotCanonicalizable(`${path} is not a plain JSON object`);
53
+ const obj = value;
54
+ const keys = Object.keys(obj).sort();
55
+ const parts = keys.map((k) => {
56
+ if (hasLoneSurrogate(k)) throw new PayloadNotCanonicalizable(`${path} has a key with an unpaired surrogate`);
57
+ return `${JSON.stringify(k)}:${serialize(obj[k], depth + 1, `${path}.${k}`)}`;
58
+ });
59
+ return `{${parts.join(",")}}`;
60
+ }
61
+ default:
62
+ throw new PayloadNotCanonicalizable(`${path} is a ${typeof value}, which JSON cannot represent`);
63
+ }
64
+ }
65
+ function canonicalPayload(value) {
66
+ const text = serialize(value, 0, "$");
67
+ if (Buffer.byteLength(text, "utf8") > MAX_CANONICAL_PAYLOAD_BYTES) {
68
+ throw new PayloadNotCanonicalizable(`canonical payload exceeds ${MAX_CANONICAL_PAYLOAD_BYTES} bytes`);
69
+ }
70
+ return text;
71
+ }
72
+ function payloadDigestOf(value) {
73
+ return PAYLOAD_DIGEST_PREFIX + createHash("sha256").update(canonicalPayload(value), "utf8").digest("hex");
74
+ }
75
+ var DIGEST_RE = /^sha256:[0-9a-f]{64}$/;
76
+ function isPayloadDigest(value) {
77
+ return typeof value === "string" && DIGEST_RE.test(value);
78
+ }
79
+ function toWireJson(value) {
80
+ const text = JSON.stringify(value);
81
+ if (text === void 0) throw new PayloadNotCanonicalizable("payload is not JSON-serialisable");
82
+ return JSON.parse(text);
83
+ }
84
+ function buildPayloadBindingMessage(input) {
85
+ return [PAYLOAD_BINDING_PREFIX, input.agentDid, input.action, input.nonce, input.issuedAt, input.payloadDigest].map((f) => escapeField(String(f))).join("|");
86
+ }
87
+ function decideClaimPayload(stored, presented) {
88
+ if (stored) {
89
+ if (!presented) return { ok: false, reasonCode: "PAYLOAD_DIGEST_REQUIRED" };
90
+ return presented === stored ? { ok: true } : { ok: false, reasonCode: "PAYLOAD_DIGEST_MISMATCH" };
91
+ }
92
+ return presented ? { ok: false, reasonCode: "PAYLOAD_NOT_BOUND_AT_AUTHORIZE" } : { ok: true };
93
+ }
94
+ function claimDigestField(payloadDigest) {
95
+ return `payload=${payloadDigest}`;
96
+ }
97
+ export {
98
+ MAX_CANONICAL_PAYLOAD_BYTES,
99
+ PAYLOAD_BINDING_PREFIX,
100
+ PAYLOAD_DIGEST_HEADER,
101
+ PAYLOAD_DIGEST_PREFIX,
102
+ PayloadNotCanonicalizable,
103
+ buildPayloadBindingMessage,
104
+ canonicalPayload,
105
+ claimDigestField,
106
+ decideClaimPayload,
107
+ isPayloadDigest,
108
+ payloadDigestOf,
109
+ toWireJson
110
+ };
package/policy-core.mjs CHANGED
@@ -1,5 +1,129 @@
1
1
  // GENERATED from backend/src/policy-core — do not edit. Regenerate: npm run build:mcp-guard-core
2
2
 
3
+ // src/policy-core/provenance.ts
4
+ var PROVENANCE_LEVELS = ["agent_asserted", "agent_signed", "gateway_derived", "authoritative", "attested"];
5
+ var PROVENANCE_RANK = {
6
+ agent_asserted: 0,
7
+ agent_signed: 1,
8
+ gateway_derived: 2,
9
+ authoritative: 3,
10
+ attested: 4
11
+ };
12
+ function isProvenance(v) {
13
+ return typeof v === "string" && Object.prototype.hasOwnProperty.call(PROVENANCE_RANK, v);
14
+ }
15
+ var PROVENANCE_KEY = Symbol.for("magp.context.provenance");
16
+ function provenanceOf(ctx, field) {
17
+ const map = ctx?.[PROVENANCE_KEY];
18
+ const p = map && typeof map === "object" ? map[field] : void 0;
19
+ return isProvenance(p) ? p : "agent_asserted";
20
+ }
21
+ function meetsProvenance(actual, minimum) {
22
+ return PROVENANCE_RANK[actual] >= PROVENANCE_RANK[minimum];
23
+ }
24
+ var RISK_LEVELS = ["low", "medium", "high", "critical"];
25
+ var RISK_ORDER = { low: 0, medium: 1, high: 2, critical: 3 };
26
+ function normalizeRiskLevel(v) {
27
+ if (typeof v !== "string") return null;
28
+ const s = v.trim().toLowerCase();
29
+ return Object.prototype.hasOwnProperty.call(RISK_ORDER, s) ? s : null;
30
+ }
31
+ function maxRisk(...levels) {
32
+ let best = null;
33
+ for (const l of levels) if (l && (best === null || RISK_ORDER[l] > RISK_ORDER[best])) best = l;
34
+ return best;
35
+ }
36
+ function riskFloorFor(mandate, target) {
37
+ if (!mandate) return null;
38
+ let floor = null;
39
+ for (const p of mandate.permission ?? []) {
40
+ if (!p || typeof p !== "object") continue;
41
+ if ((p.target ?? mandate.target) !== target) continue;
42
+ floor = maxRisk(floor, normalizeRiskLevel(p.riskTier));
43
+ }
44
+ return floor;
45
+ }
46
+ var FIELD_KINDS = {
47
+ riskLevel: "risk",
48
+ consent: "boolean",
49
+ piiPresent: "boolean",
50
+ amount: "number",
51
+ cumulativeSpend: "number",
52
+ callCount: "number",
53
+ evidenceConfidence: "number",
54
+ holTrustScore: "number",
55
+ dataSourceId: "string",
56
+ jurisdiction: "string",
57
+ dataResidency: "string",
58
+ model: "string",
59
+ tool: "string",
60
+ currency: "string",
61
+ action: "string",
62
+ prompt: "string",
63
+ output: "string",
64
+ evidenceTypes: "string[]"
65
+ };
66
+ function contextFieldProblem(ctx, field) {
67
+ const v = ctx?.[field];
68
+ if (v === void 0 || v === null) return "missing";
69
+ if (typeof v === "string" && v.trim() === "") return "missing";
70
+ switch (FIELD_KINDS[field]) {
71
+ case "risk":
72
+ return normalizeRiskLevel(v) === null ? "malformed" : null;
73
+ case "boolean":
74
+ return typeof v === "boolean" ? null : "malformed";
75
+ case "number":
76
+ return typeof v === "number" && Number.isFinite(v) ? null : "malformed";
77
+ case "string":
78
+ return typeof v === "string" ? null : "malformed";
79
+ case "string[]":
80
+ return Array.isArray(v) && v.every((x) => typeof x === "string") ? null : "malformed";
81
+ default:
82
+ return null;
83
+ }
84
+ }
85
+ var ATOM_DEFAULT_REQUIRED_CONTEXT = {
86
+ "risk-at-or-above": ["riskLevel"]
87
+ };
88
+ function buildRuleContext(src) {
89
+ const ctx = {};
90
+ const prov = /* @__PURE__ */ Object.create(null);
91
+ const put = (k, v, level) => {
92
+ Object.defineProperty(ctx, k, { value: v, enumerable: true, writable: true, configurable: true });
93
+ prov[k] = level;
94
+ };
95
+ const layers = [
96
+ [src.unsigned, "agent_asserted"],
97
+ [src.signed, "agent_signed"],
98
+ [src.gatewayDerived, "gateway_derived"],
99
+ [src.serverDerived, "authoritative"]
100
+ ];
101
+ for (const [layer, level] of layers) {
102
+ for (const [k, v] of Object.entries(layer ?? {})) {
103
+ if (k === "riskLevel" && (level === "gateway_derived" || level === "authoritative") && normalizeRiskLevel(v) === null) continue;
104
+ put(k, v, level);
105
+ }
106
+ }
107
+ const floors = [];
108
+ const addFloor = (v, source) => {
109
+ const n = normalizeRiskLevel(v);
110
+ if (n) floors.push({ level: n, source });
111
+ };
112
+ addFloor(src.riskFloor, "authoritative");
113
+ addFloor(src.gatewayDerived?.riskLevel, "gateway_derived");
114
+ addFloor(src.serverDerived?.riskLevel, "authoritative");
115
+ const assertedUnsigned = normalizeRiskLevel(src.unsigned?.riskLevel);
116
+ const assertedSigned = normalizeRiskLevel(src.signed?.riskLevel);
117
+ const asserted = maxRisk(assertedUnsigned, assertedSigned);
118
+ if (floors.length > 0) {
119
+ put("riskLevel", maxRisk(asserted, ...floors.map((f) => f.level)), floors.reduce((best, f) => PROVENANCE_RANK[f.source] > PROVENANCE_RANK[best] ? f.source : best, "agent_asserted"));
120
+ } else if (asserted) {
121
+ put("riskLevel", asserted, assertedSigned ? "agent_signed" : "agent_asserted");
122
+ }
123
+ Object.defineProperty(ctx, PROVENANCE_KEY, { value: prov, enumerable: true, writable: false });
124
+ return ctx;
125
+ }
126
+
3
127
  // src/policy-core/atom-registry.ts
4
128
  var RISK_RANK = { low: 0, medium: 1, high: 2, critical: 3 };
5
129
  function currencyOutOfScope(ctx, cfgCurrency) {
@@ -14,7 +138,8 @@ var ATOM_REGISTRY = {
14
138
  "data-source-not-approved": (c, cfg) => !!c.dataSourceId && !(cfg?.approved ?? []).includes(String(c.dataSourceId)),
15
139
  "consent-missing": (c) => c.consent === false,
16
140
  "risk-at-or-above": (c, cfg) => {
17
- const have = RISK_RANK[String(c.riskLevel)];
141
+ const haveLevel = normalizeRiskLevel(c.riskLevel);
142
+ const have = haveLevel === null ? void 0 : RISK_RANK[haveLevel];
18
143
  const need = RISK_RANK[String(cfg?.level ?? "high")];
19
144
  return have !== void 0 && need !== void 0 && have >= need;
20
145
  },
@@ -276,6 +401,7 @@ function requiredContextFor(predicates) {
276
401
  }
277
402
 
278
403
  // src/policy-core/standards-rules.ts
404
+ var CONTEXT_UNVERIFIABLE = "CONTEXT_UNVERIFIABLE";
279
405
  var PRECEDENCE = { allow: 0, observe: 1, escalate: 2, block: 3, suspend: 4, quarantine: 5, decommission: 6 };
280
406
  function atomFires(atom, ctx) {
281
407
  const pred = ATOM_REGISTRY[atom.predicate];
@@ -304,17 +430,49 @@ function moleculeFires(m, ctx) {
304
430
  return false;
305
431
  }
306
432
  }
433
+ function requiredContextOf(m) {
434
+ const required = /* @__PURE__ */ new Map();
435
+ const need = (field, level) => {
436
+ const have = required.get(field);
437
+ if (!have || PROVENANCE_RANK[level] > PROVENANCE_RANK[have]) required.set(field, level);
438
+ };
439
+ for (const a of m.atoms ?? []) {
440
+ if (!Object.prototype.hasOwnProperty.call(ATOM_DEFAULT_REQUIRED_CONTEXT, a.predicate)) continue;
441
+ for (const f of ATOM_DEFAULT_REQUIRED_CONTEXT[a.predicate]) need(f, "agent_asserted");
442
+ }
443
+ for (const [f, level] of Object.entries(m.requireProvenance ?? {})) {
444
+ need(f, isProvenance(level) ? level : "attested");
445
+ }
446
+ return required;
447
+ }
448
+ function moleculeUnverifiable(m, ctx) {
449
+ const bad = [];
450
+ for (const [field, minimum] of requiredContextOf(m)) {
451
+ if (contextFieldProblem(ctx, field) !== null || !meetsProvenance(provenanceOf(ctx, field), minimum)) bad.push(field);
452
+ }
453
+ return bad.sort();
454
+ }
307
455
  function evaluateStandardRules(molecules, ctx, standardKey = null) {
308
456
  let best = null;
309
457
  for (const m of molecules ?? []) {
310
- if (moleculeFires(m, ctx)) {
311
- if (!best || PRECEDENCE[m.decision] > PRECEDENCE[best.decision]) {
312
- best = { decision: m.decision, reasonCode: m.reasonCode, id: m.id };
313
- }
458
+ const fired = moleculeFires(m, ctx);
459
+ const unverifiable = moleculeUnverifiable(m, ctx);
460
+ if (!fired && unverifiable.length === 0) continue;
461
+ let decision = fired ? m.decision : "escalate";
462
+ if (unverifiable.length > 0 && PRECEDENCE[decision] < PRECEDENCE.escalate) decision = "escalate";
463
+ const reasonCode = fired ? m.reasonCode : CONTEXT_UNVERIFIABLE;
464
+ if (!best || PRECEDENCE[decision] > PRECEDENCE[best.decision]) {
465
+ best = { decision, reasonCode, id: m.id, unverifiable: unverifiable.length > 0 ? unverifiable : void 0 };
314
466
  }
315
467
  }
316
468
  if (!best) return { decision: "allow", reasonCode: null, firedMoleculeId: null, standardKey };
317
- return { decision: best.decision, reasonCode: best.reasonCode, firedMoleculeId: best.id, standardKey };
469
+ return {
470
+ decision: best.decision,
471
+ reasonCode: best.reasonCode,
472
+ firedMoleculeId: best.id,
473
+ standardKey,
474
+ ...best.unverifiable ? { unverifiableContext: best.unverifiable } : {}
475
+ };
318
476
  }
319
477
  function evaluateBoundStandards(standards, ctx) {
320
478
  let best = { decision: "allow", reasonCode: null, firedMoleculeId: null, standardKey: null };
@@ -369,6 +527,19 @@ function validateMolecules(molecules) {
369
527
  if (!m.atoms || m.atoms.length === 0) {
370
528
  issues.push({ moleculeId: m.id, message: "molecule has no atoms" });
371
529
  }
530
+ if (m.requireProvenance !== void 0) {
531
+ const rp = m.requireProvenance;
532
+ if (rp === null || typeof rp !== "object" || Array.isArray(rp)) {
533
+ issues.push({ moleculeId: m.id, message: "requireProvenance must be an object of { field: level }" });
534
+ } else {
535
+ for (const [field, level] of Object.entries(rp)) {
536
+ if (field.trim() === "") issues.push({ moleculeId: m.id, message: "requireProvenance has an empty field name" });
537
+ if (!isProvenance(level)) {
538
+ issues.push({ moleculeId: m.id, message: `requireProvenance '${field}' must be one of agent_asserted|agent_signed|gateway_derived|authoritative|attested` });
539
+ }
540
+ }
541
+ }
542
+ }
372
543
  for (const a of m.atoms ?? []) {
373
544
  if (!ATOM_REGISTRY[a.predicate]) {
374
545
  issues.push({ moleculeId: m.id, message: `unknown atom predicate '${a.predicate}'` });
@@ -406,9 +577,14 @@ var REASON_BY_OPERAND = {
406
577
  "mm:route": "ROUTE_NOT_ALLOWED",
407
578
  "mm:counterparty": "COUNTERPARTY_NOT_ALLOWED"
408
579
  };
409
- function reasonFor(constraint) {
580
+ var AMOUNT_OPERANDS = /* @__PURE__ */ new Set(["mm:payAmount", "mm:cumulativeSpend"]);
581
+ function reasonFor(constraint, req) {
410
582
  if (!constraint) return "CONSTRAINT_FAILED";
411
- return REASON_BY_OPERAND[constraint.leftOperand] ?? `CONSTRAINT_FAILED:${constraint.leftOperand}`;
583
+ const { leftOperand } = constraint;
584
+ if (AMOUNT_OPERANDS.has(leftOperand) && !Object.prototype.hasOwnProperty.call(req.values, leftOperand)) {
585
+ return "AMOUNT_NOT_DETERMINABLE";
586
+ }
587
+ return REASON_BY_OPERAND[leftOperand] ?? `CONSTRAINT_FAILED:${leftOperand}`;
412
588
  }
413
589
  function constraintSatisfied(c, req, strict) {
414
590
  const op = OPERATORS[c.operator];
@@ -464,7 +640,7 @@ function evaluateMandate(mandate, req) {
464
640
  const firstFail = (perms[0].constraint ?? []).find((c) => !constraintSatisfied(c, req, true));
465
641
  return {
466
642
  decision: firstFail?.onFail ?? "block",
467
- reasonCode: reasonFor(firstFail),
643
+ reasonCode: reasonFor(firstFail, req),
468
644
  matched: { kind: "permission", target: perms[0].target, constraint: firstFail }
469
645
  };
470
646
  }
@@ -516,6 +692,9 @@ function escapeField(v) {
516
692
  function buildAuthMessage(f) {
517
693
  return [f.agentDid, f.action, f.amount, f.currency, f.merchant ?? "", f.resource ?? "", f.nonce, f.issuedAt].map((v) => escapeField(String(v))).join("|");
518
694
  }
695
+ function buildLegacyAuthMessageV1(f) {
696
+ return [f.agentDid, f.action, f.amount, f.currency, f.merchant ?? "", f.nonce, f.issuedAt].map((v) => escapeField(String(v))).join("|");
697
+ }
519
698
  function buildLocalDecisionMessage(f) {
520
699
  return [f.agentDid, f.action, f.decision, f.reasonCode, f.nonce, f.issuedAt].map((v) => escapeField(String(v))).join("|");
521
700
  }
@@ -569,11 +748,17 @@ function operatingModeGate(mode, ctx) {
569
748
  }
570
749
  }
571
750
  export {
751
+ ATOM_DEFAULT_REQUIRED_CONTEXT,
572
752
  ATOM_REGISTRY,
573
753
  ATOM_SPECS,
574
754
  CATALOGUED_ATOMS,
755
+ CONTEXT_UNVERIFIABLE,
575
756
  MODES_BY_RANK,
576
757
  MODE_RANK,
758
+ PROVENANCE_KEY,
759
+ PROVENANCE_LEVELS,
760
+ PROVENANCE_RANK,
761
+ RISK_LEVELS,
577
762
  SUPERVISED_AMOUNT_CAP,
578
763
  applyCapture,
579
764
  applyHold,
@@ -582,20 +767,31 @@ export {
582
767
  authorityFailure,
583
768
  buildAuthMessage,
584
769
  buildCheckpointAnchorMessage,
770
+ buildLegacyAuthMessageV1,
585
771
  buildLocalDecisionMessage,
772
+ buildRuleContext,
586
773
  canAuthorize,
774
+ contextFieldProblem,
587
775
  evaluate,
588
776
  evaluateBoundStandards,
589
777
  evaluateMandate,
590
778
  evaluateStandardRules,
591
779
  isAuthorityFailure,
592
780
  isOperatingMode,
781
+ isProvenance,
782
+ maxRisk,
783
+ meetsProvenance,
593
784
  moleculeFires,
785
+ moleculeUnverifiable,
594
786
  moreRestrictive,
787
+ normalizeRiskLevel,
595
788
  operatingModeGate,
789
+ provenanceOf,
596
790
  releaseHold,
597
791
  remainingBudget,
598
792
  requiredContextFor,
793
+ requiredContextOf,
794
+ riskFloorFor,
599
795
  sumEventField,
600
796
  validateMolecules
601
797
  };