@metamynd/agentsafe-mcp-guard 0.6.2 → 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 +101 -0
- package/agentsafe-mcp-guard.mjs +315 -24
- package/key-providers.mjs +8 -0
- package/package.json +3 -2
- package/payload-binding.mjs +110 -0
- package/policy-core.mjs +205 -9
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
|
package/agentsafe-mcp-guard.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 {
|
|
125
|
-
return {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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 };
|
|
245
|
+
} catch (err) {
|
|
246
|
+
return { ok: false, reasonCode: 'ISSUER_UNREACHABLE', error: String(err?.message ?? err) };
|
|
247
|
+
}
|
|
248
|
+
}
|
|
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 };
|
|
133
278
|
} catch (err) {
|
|
134
|
-
return {
|
|
279
|
+
return { ok: false, reasonCode: 'ISSUER_UNREACHABLE', error: String(err?.message ?? err) };
|
|
135
280
|
}
|
|
136
281
|
}
|
|
137
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,8 +334,15 @@ export function createMcpGuard({ serviceDid, serviceKey, keyProvider: keyProvide
|
|
|
150
334
|
return body.data;
|
|
151
335
|
}
|
|
152
336
|
|
|
153
|
-
/**
|
|
154
|
-
|
|
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) {
|
|
155
346
|
const { agentDid, action, amount = 0, currency = 'USD', merchant = '', resource = null, itinerary = {}, cumulativeSpend = amount, now } = req;
|
|
156
347
|
const mandates = bundle.mandates ?? [];
|
|
157
348
|
const mandate = mandates.find((m) => m.action === action)?.document;
|
|
@@ -173,7 +364,12 @@ export function createMcpGuard({ serviceDid, serviceKey, keyProvider: keyProvide
|
|
|
173
364
|
// cumulative-over Standards/SOP atom always sees currency as absent and fires closed
|
|
174
365
|
// (SOP_SPEND_CAP on a genuinely in-cap request), and a resource-scope atom never runs
|
|
175
366
|
// at all. Mirrors mandate.service.ts's ruleCtx (PR #588), the parity target for this.
|
|
176
|
-
context:
|
|
367
|
+
context: buildRuleContext({
|
|
368
|
+
unsigned: itinerary,
|
|
369
|
+
signed: { action, agentDid, amount, currency, merchant, resource },
|
|
370
|
+
gatewayDerived: trustedContext,
|
|
371
|
+
riskFloor: riskFloorFor(mandate, action),
|
|
372
|
+
}),
|
|
177
373
|
mandateRequest: mandate
|
|
178
374
|
? {
|
|
179
375
|
target: action,
|
|
@@ -201,10 +397,15 @@ export function createMcpGuard({ serviceDid, serviceKey, keyProvider: keyProvide
|
|
|
201
397
|
* locally against the agent's issuer-hosted bundle. Fails CLOSED: any bad
|
|
202
398
|
* signature, staleness, fetch error, or evaluation error returns a block.
|
|
203
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.
|
|
204
404
|
* @returns {Promise<{decision:'allow'|'observe'|'block'|'escalate'|'suspend'|'quarantine',reasonCode:string|null}>}
|
|
205
405
|
*/
|
|
206
|
-
async function verifyRequest(signed = {}) {
|
|
406
|
+
async function verifyRequest(signed = {}, { trustedContext, payloadDigest, requirePayloadBinding } = {}) {
|
|
207
407
|
try {
|
|
408
|
+
assertTrustedContext(trustedContext); // a broken deriver is a refused request (GUARD_ERROR), never a quiet downgrade
|
|
208
409
|
const { agentDid, action, amount = 0, currency = 'USD', merchant = '', resource = null, nonce, issuedAt, signature } = signed;
|
|
209
410
|
if (!agentDid || !action || !nonce || !issuedAt || !signature) {
|
|
210
411
|
return { decision: 'block', reasonCode: 'MALFORMED_REQUEST' };
|
|
@@ -216,6 +417,28 @@ export function createMcpGuard({ serviceDid, serviceKey, keyProvider: keyProvide
|
|
|
216
417
|
if (!verifyDidSignature(agentDid, message, signature)) {
|
|
217
418
|
return { decision: 'block', reasonCode: 'SIGNATURE_INVALID' };
|
|
218
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
|
+
}
|
|
219
442
|
// 2. Freshness. (Single-use nonce consumption stays the gate's job by default — a Service
|
|
220
443
|
// re-check is verification, not a second authorization. requireAuthorization below is
|
|
221
444
|
// the opt-in exception: it DOES give the Service its own single-use claim.)
|
|
@@ -243,7 +466,11 @@ export function createMcpGuard({ serviceDid, serviceKey, keyProvider: keyProvide
|
|
|
243
466
|
// as a non-enumerable sibling. READ_ONLY refuses a value-bearing action up-front;
|
|
244
467
|
// SUPERVISED/RESTRICTED only ESCALATE, applied to the verdict below so a rule block
|
|
245
468
|
// still outranks the floor (most-restrictive-wins, mirroring the gate).
|
|
246
|
-
|
|
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 });
|
|
247
474
|
if (modeGate.decision === 'block') return { decision: 'block', reasonCode: modeGate.reasonCode };
|
|
248
475
|
// 3b. Signed-bundle verification + risk-tiered fail-closed (Phase F, §5.3.2/§5.3.3). When a
|
|
249
476
|
// policy key is configured, a value-bearing action (amount > 0) MUST fail closed on an
|
|
@@ -253,7 +480,7 @@ export function createMcpGuard({ serviceDid, serviceKey, keyProvider: keyProvide
|
|
|
253
480
|
const v = verifyBundle(bundle, { publicKey: policyPublicKey, valueBearing: Number(amount) > 0 });
|
|
254
481
|
if (!v.ok) return { decision: 'block', reasonCode: v.reasonCode };
|
|
255
482
|
}
|
|
256
|
-
const verdict = verdictFromBundle(bundle, { ...signed, itinerary: signed.itinerary ?? {} });
|
|
483
|
+
const verdict = verdictFromBundle(bundle, { ...signed, itinerary: signed.itinerary ?? {} }, trustedContext);
|
|
257
484
|
// Mode ESCALATE floor lifts an otherwise-PERMIT (allow or observe) to human review
|
|
258
485
|
// (escalate outranks observe, so a flag never masks it) — mirrors the backend gate.
|
|
259
486
|
const final = (verdict.decision === 'allow' || verdict.decision === 'observe') && modeGate.decision === 'escalate'
|
|
@@ -263,9 +490,19 @@ export function createMcpGuard({ serviceDid, serviceKey, keyProvider: keyProvide
|
|
|
263
490
|
// stop REPLAY or CUMULATIVE SPEND past the mandate total — both are the stateful issuer
|
|
264
491
|
// gate's job. Only claim on an actual PERMIT: escalate/block/suspend/quarantine execute
|
|
265
492
|
// nothing, so there is nothing to protect and no reason to spend the hold's single use.
|
|
493
|
+
let claimToken; let claimAuthenticated = false;
|
|
266
494
|
if (requireAuthorization && (final.decision === 'allow' || final.decision === 'observe')) {
|
|
267
|
-
|
|
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;
|
|
268
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' };
|
|
269
506
|
// The claim alone only proves SOME real, unclaimed authorization exists — it must also
|
|
270
507
|
// be FOR this agent and these exact values, or a cheap legitimate hold's id could be
|
|
271
508
|
// presented to unlock a completely different, more expensive execution. Each check is
|
|
@@ -288,7 +525,9 @@ export function createMcpGuard({ serviceDid, serviceKey, keyProvider: keyProvide
|
|
|
288
525
|
if (claim.currency !== undefined && claim.currency !== currency) return { decision: 'block', reasonCode: 'AUTHORIZATION_CURRENCY_MISMATCH' };
|
|
289
526
|
if (claim.merchant !== undefined && claim.merchant !== merchant) return { decision: 'block', reasonCode: 'AUTHORIZATION_MERCHANT_MISMATCH' };
|
|
290
527
|
}
|
|
291
|
-
|
|
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);
|
|
292
531
|
} catch (err) {
|
|
293
532
|
return { decision: 'block', reasonCode: 'GUARD_ERROR', error: String(err?.message ?? err) };
|
|
294
533
|
}
|
|
@@ -299,8 +538,38 @@ export function createMcpGuard({ serviceDid, serviceKey, keyProvider: keyProvide
|
|
|
299
538
|
* agent's signed request must be passed as the first argument. Throws
|
|
300
539
|
* GovernanceBlocked on any non-allow decision.
|
|
301
540
|
*/
|
|
302
|
-
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
|
+
}
|
|
303
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
|
+
}
|
|
304
573
|
// The WRAPPED TOOL's own `action` is authoritative — never `signed?.action` (the caller's
|
|
305
574
|
// own claim). A Service that wraps more than one tool with ONE guard instance (the normal
|
|
306
575
|
// MCP-server shape: many tools, one guard) previously let a genuinely-valid signature for
|
|
@@ -311,7 +580,14 @@ export function createMcpGuard({ serviceDid, serviceKey, keyProvider: keyProvide
|
|
|
311
580
|
// this guard (e.g. a wire transfer) and have it execute under that unrelated verification.
|
|
312
581
|
// Mirrors gateway.mjs's `route.action ?? signed.action` — "the route pins the action ...
|
|
313
582
|
// the client can't pick it" — for exactly the same reason, one layer down at the tool call.
|
|
314
|
-
|
|
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 });
|
|
315
591
|
// allow/observe both PERMIT the tool call; observe is permit-but-flag (SAFR §11).
|
|
316
592
|
if (decision.decision !== 'allow' && decision.decision !== 'observe') {
|
|
317
593
|
const err = new Error(`MCP guard ${decision.decision.toUpperCase()} "${action}": ${decision.reasonCode}`);
|
|
@@ -358,6 +634,21 @@ export function createMcpGuard({ serviceDid, serviceKey, keyProvider: keyProvide
|
|
|
358
634
|
if (!requireAuthorization && Number(signed?.amount) > 0) {
|
|
359
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.`);
|
|
360
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
|
+
}
|
|
361
652
|
return handler(signed, ...rest);
|
|
362
653
|
};
|
|
363
654
|
}
|
|
@@ -418,7 +709,7 @@ export function createMcpGuard({ serviceDid, serviceKey, keyProvider: keyProvide
|
|
|
418
709
|
return { settled: true, txHash: result.txHash, reasonCode: 'SETTLED' };
|
|
419
710
|
}
|
|
420
711
|
|
|
421
|
-
return { handshakeChallenge, handshakeVerify, verifyRequest, guardIncomingTool, requirePayment, settle, claimAuthorization, serviceDid };
|
|
712
|
+
return { handshakeChallenge, handshakeVerify, verifyRequest, guardIncomingTool, requirePayment, settle, claimAuthorization, lookupOutcome, captureAuthorization, releaseAuthorization, markAuthorizationUnknown, serviceDid };
|
|
422
713
|
}
|
|
423
714
|
|
|
424
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.
|
|
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
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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 {
|
|
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
|
-
|
|
580
|
+
var AMOUNT_OPERANDS = /* @__PURE__ */ new Set(["mm:payAmount", "mm:cumulativeSpend"]);
|
|
581
|
+
function reasonFor(constraint, req) {
|
|
410
582
|
if (!constraint) return "CONSTRAINT_FAILED";
|
|
411
|
-
|
|
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
|
};
|