@metamynd/agentsafe-mcp-guard 0.3.6 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,8 +35,8 @@ const guard = createMcpGuard({
35
35
  });
36
36
 
37
37
  // Responder side, over your HTTP transport:
38
- const challenge = guard.handshakeChallenge(hello); // POST /magp/handshake (HELLO → CHALLENGE)
39
- const ready = guard.handshakeVerify(prove); // POST /magp/handshake (PROVE → READY, or throws)
38
+ const challenge = await guard.handshakeChallenge(hello); // POST /magp/handshake (HELLO → CHALLENGE)
39
+ const ready = guard.handshakeVerify(prove); // POST /magp/handshake (PROVE → READY, or throws)
40
40
  ```
41
41
 
42
42
  The agent drives the initiator side with `createGuard(...).handshake()` from `agentsafe-guard`.
@@ -110,6 +110,55 @@ $5,000" protection never engaged, verifier configured or not. New `requireCapabi
110
110
  an omitted capability a hard block (`CAPABILITY_REQUIRED`) instead of a silent pass-through. Both
111
111
  off by default — existing embeds are unchanged.
112
112
 
113
+ **0.5.0 — the `keyProvider` seam
114
+ ([docs/design/agent-key-custody-local-signer-daemon-plan.md](../../docs/design/agent-key-custody-local-signer-daemon-plan.md)).**
115
+ `serviceKey` no longer has to be a raw hex key living in this process. Pass
116
+ `keyProvider: 'daemon'` + `daemonSocketPath` instead, and `handshakeChallenge` gets its signature
117
+ from a separate `@metamynd/agentsafe-signer` daemon (`role: 'service'`) over a local socket — the
118
+ key never enters this process at all. `serviceKey` (unchanged) still works exactly as before and
119
+ remains the default. **Breaking, disclosed plainly**: `handshakeChallenge` and
120
+ `createHandshakeInitiator(...).prove()` are now `async` (a daemon-backed provider needs a socket
121
+ round trip); every caller needs an `await` added. New internal module `key-providers.mjs` — still
122
+ zero external dependencies.
123
+
124
+ **0.5.3 — `requireAuthorization`'s claim now actually enforces revoked authority and mandate
125
+ expiry, and the claim-binding check closes the action axis too.** A four-client readiness review's
126
+ P0 finding required a tested counterparty-verification pattern covering, by name, replay, wrong
127
+ identity, wrong action, expired mandate, and revoked authority. Building the end-to-end trial
128
+ against a real backend (not the mocked cases below) surfaced that two of those five were silently
129
+ unenforced at the claim step itself, not just untested:
130
+
131
+ - **Revoked authority.** Revoking a mandate (`POST /mandate/:ref/revoke`) only ever flipped the
132
+ hold's `mandate_event.status` to `voided` — it never touched the `effect_transition` chain, and
133
+ `markEffect`'s transition check only validated the state-machine shape (`authorized →
134
+ dispatching` is always syntactically legal), never the hold's own status. A resource relying on
135
+ `requireAuthorization` could successfully claim and execute a hold whose mandate had already been
136
+ revoked. Now refused with `AUTHORIZATION_VOIDED`.
137
+ - **Expired mandate.** A hold's expiry (`isWithinHoldWindow`/the TTL) is a *derived* property —
138
+ nothing writes it back to the row when time passes, so the same claim step had no way to see a
139
+ hold was stale. Now refused with `AUTHORIZATION_EXPIRED`.
140
+ - **Wrong action.** `claimAuthorization()`'s mismatch checks compared the claimed hold's
141
+ `agentDid`/`amount`/`currency`/`merchant` against the request being executed, but never `action`
142
+ — because the backend's claim response never carried it. A real, unclaimed authorization minted
143
+ for one action (e.g. `office-supplies-purchase`) could be claimed while executing a *different*
144
+ action at the identical agent/amount/currency/merchant — the same confused-deputy shape the 0.3.6
145
+ merchant check closed, one axis short. The backend now resolves and returns the hold's authorized
146
+ `action` on claim (backward-compatible — an older backend simply omits it, same
147
+ skip-when-absent tolerance as every other field here). Now refused with
148
+ `AUTHORIZATION_ACTION_MISMATCH`.
149
+
150
+ All three are backend-side (`MandateService.markEffect`) except the action check, which also
151
+ needed this guard to compare the new field. See
152
+ [`docs/design/mcp-reverification-quickstart.md`](../../docs/design/mcp-reverification-quickstart.md) (§7)
153
+ for the end-to-end trial these gaps were found and closed against.
154
+
155
+ **0.5.1 — `keyProvider: 'daemon'` retries a transient connect failure.** Same fix as
156
+ `@metamynd/agentsafe-guard` 0.9.1: on Windows the signer daemon's socket is a pool of independent
157
+ named-pipe instances, each consumed by one connection and replaced asynchronously, so two
158
+ signing requests close together could race that replacement window and fail with
159
+ `DAEMON_UNREACHABLE` even though the daemon was healthy. `key-providers.mjs` now retries a
160
+ connection that fails with `ENOENT` for up to 3 seconds before giving up. No API change.
161
+
113
162
  ### Replay, cumulative spend, rate limits, breakers, spend anomalies (`requireAuthorization`)
114
163
 
115
164
  Re-evaluating policy per request (above) proves the request is well-formed and in-policy — it
@@ -16,6 +16,7 @@ import { evaluate, buildAuthMessage, applySignedLast, operatingModeGate } from '
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
+ import { resolveKeyProvider } from './key-providers.mjs';
19
20
 
20
21
  /** Freshness window for signed requests and handshake nonces (spec §7.7). How far `issuedAt`
21
22
  * may be BEHIND server time — network/processing delay. */
@@ -57,19 +58,15 @@ const CLOCK_SKEW_TOLERANCE_MS = 30 * 1000;
57
58
  * integrator's un-capability-aware callers must keep working); set true on any Service where
58
59
  * capability binding is meant to be mandatory, not opt-in.
59
60
  */
60
- export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle, policyPublicKey, settlementStore, verifyCapability, requireAuthorization = false, requireCapability = false } = {}) {
61
+ export function createMcpGuard({ serviceDid, serviceKey, keyProvider: keyProviderOpt, daemonSocketPath, issuerApi, fetchBundle, policyPublicKey, settlementStore, verifyCapability, requireAuthorization = false, requireCapability = false } = {}) {
61
62
  if (!serviceDid) throw new Error('createMcpGuard requires { serviceDid }');
62
63
  const base = issuerApi ? issuerApi.replace(/\/$/, '') : null;
63
- const privateKey = serviceKey
64
- ? crypto.createPrivateKey({ key: Buffer.from(serviceKey, 'hex'), format: 'der', type: 'pkcs8' })
65
- : null;
64
+ // keyProvider seam (docs/design/agent-key-custody-local-signer-daemon-plan.md): null when
65
+ // neither `serviceKey` nor `keyProvider` is configured handshakeChallenge throws its own
66
+ // clear error only if actually called, matching the original lazy-throw behavior exactly.
67
+ const keyProvider = resolveKeyProvider({ keyProvider: keyProviderOpt, serviceKey, daemonSocketPath });
66
68
  const pending = new Map(); // handshakeId -> { fromDid, nonceB, expiresAt }
67
69
 
68
- function sign(message) {
69
- if (!privateKey) throw new Error('serviceKey is required to sign handshake messages');
70
- return crypto.sign(null, Buffer.from(message, 'utf8'), privateKey).toString('hex');
71
- }
72
-
73
70
  // --- Mutual handshake, RESPONDER side (spec §8.2) ---
74
71
  // A → B HELLO { fromDid, nonceA }
75
72
  // B → A CHALLENGE { toDid, nonceB, sigB(nonceA) } ← proves B controls toDid
@@ -77,12 +74,13 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
77
74
  // B → A READY { channelId }
78
75
 
79
76
  /** Step 1 (B): on HELLO, sign nonceA to prove control of serviceDid, issue nonceB. */
80
- function handshakeChallenge({ fromDid, nonceA, protoVersion } = {}) {
77
+ async function handshakeChallenge({ fromDid, nonceA, protoVersion } = {}) {
81
78
  if (!fromDid || !nonceA) throw new Error('HELLO requires { fromDid, nonceA }');
79
+ if (!keyProvider) throw new Error('serviceKey (or keyProvider) is required to sign handshake messages');
82
80
  const handshakeId = crypto.randomUUID();
83
81
  const nonceB = crypto.randomUUID();
84
82
  pending.set(handshakeId, { fromDid, nonceB, expiresAt: Date.now() + FRESHNESS_MS });
85
- return { handshakeId, toDid: serviceDid, nonceB, sigB: sign(nonceA), protoVersion: protoVersion ?? '1.0' };
83
+ return { handshakeId, toDid: serviceDid, nonceB, sigB: await keyProvider.signHandshakeNonce(nonceA), protoVersion: protoVersion ?? '1.0' };
86
84
  }
87
85
 
88
86
  /** Step 2 (B): on PROVE, verify sigA over nonceB against fromDid's key-in-DID. */
@@ -110,12 +108,12 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
110
108
  * against the stateful issuer gate already checked both (agentsafe-guard.mjs's authorizeLocal()
111
109
  * seals any value-bearing action through the real remote authorize() by default).
112
110
  *
113
- * On success, also returns the hold's OWN bound `agentDid`/`amount`/`currency`/`merchant` — the
114
- * caller MUST compare these to the request actually being executed. A claim alone only proves
115
- * "some real, unclaimed authorization exists"; without this check, a legitimately-obtained
116
- * authorization for a small, honest transaction could be presented to unlock a completely
117
- * different one the same confused-deputy shape payload binding closes at the request layer,
118
- * recurring one layer deeper.
111
+ * On success, also returns the hold's OWN bound `agentDid`/`action`/`amount`/`currency`/
112
+ * `merchant` — the caller MUST compare these to the request actually being executed. A claim
113
+ * alone only proves "some real, unclaimed authorization exists"; without this check, a
114
+ * legitimately-obtained authorization for a small, honest transaction (or a DIFFERENT action
115
+ * entirely) could be presented to unlock a completely different one the same confused-deputy
116
+ * shape payload binding closes at the request layer, recurring one layer deeper.
119
117
  */
120
118
  async function claimAuthorization({ authorizationId } = {}) {
121
119
  if (!authorizationId) return { claimed: false, reasonCode: 'AUTHORIZATION_REQUIRED' };
@@ -124,7 +122,14 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
124
122
  const res = await fetch(`${base}/policy/mandate/authorize/${encodeURIComponent(authorizationId)}/effect/dispatching`, { method: 'POST' });
125
123
  const body = await res.json().catch(() => null);
126
124
  if (!res.ok) return { claimed: false, reasonCode: body?.message ?? body?.data?.reasonCode ?? `AUTHORIZATION_CLAIM_HTTP_${res.status}` };
127
- return { claimed: true, agentDid: body?.data?.agentDid, amount: body?.data?.amount, currency: body?.data?.currency, merchant: body?.data?.merchant };
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
+ };
128
133
  } catch (err) {
129
134
  return { claimed: false, reasonCode: 'AUTHORIZATION_CLAIM_UNREACHABLE', error: String(err?.message ?? err) };
130
135
  }
@@ -253,10 +258,12 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
253
258
  // re-open the confused-deputy gap this claim exists to close, and nothing else here
254
259
  // would notice.
255
260
  if (claim.agentDid === undefined) console.warn('[mcp-guard] claim response omitted agentDid — binding degraded to "some valid unclaimed authorization exists"');
261
+ if (claim.action === undefined) console.warn('[mcp-guard] claim response omitted action — action binding degraded');
256
262
  if (Number(amount) > 0 && claim.amount === undefined) console.warn('[mcp-guard] claim response omitted amount for a value-bearing request — amount binding degraded');
257
263
  if (Number(amount) > 0 && claim.currency === undefined) console.warn('[mcp-guard] claim response omitted currency for a value-bearing request — currency binding degraded');
258
264
  if (merchant && claim.merchant === undefined) console.warn('[mcp-guard] claim response omitted merchant for a request that signed one — merchant binding degraded');
259
265
  if (claim.agentDid !== undefined && claim.agentDid !== agentDid) return { decision: 'block', reasonCode: 'AUTHORIZATION_AGENT_MISMATCH' };
266
+ if (claim.action !== undefined && claim.action !== action) return { decision: 'block', reasonCode: 'AUTHORIZATION_ACTION_MISMATCH' };
260
267
  if (claim.amount !== undefined && Number(claim.amount) !== Number(amount)) return { decision: 'block', reasonCode: 'AUTHORIZATION_AMOUNT_MISMATCH' };
261
268
  if (claim.currency !== undefined && claim.currency !== currency) return { decision: 'block', reasonCode: 'AUTHORIZATION_CURRENCY_MISMATCH' };
262
269
  if (claim.merchant !== undefined && claim.merchant !== merchant) return { decision: 'block', reasonCode: 'AUTHORIZATION_MERCHANT_MISMATCH' };
@@ -274,7 +281,17 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
274
281
  */
275
282
  function guardIncomingTool(action, handler) {
276
283
  return async (signed, ...rest) => {
277
- const decision = await verifyRequest({ ...signed, action: signed?.action ?? action });
284
+ // The WRAPPED TOOL's own `action` is authoritative — never `signed?.action` (the caller's
285
+ // own claim). A Service that wraps more than one tool with ONE guard instance (the normal
286
+ // MCP-server shape: many tools, one guard) previously let a genuinely-valid signature for
287
+ // action A verify successfully — correctly, it really was valid for A — and then run
288
+ // action B's handler, because verifyRequest was asked to check whatever the SIGNED payload
289
+ // claimed instead of which wrapped function was actually being invoked. A caller with a
290
+ // real, cheap, in-policy authorization (e.g. a $0 read) could invoke ANY other tool sharing
291
+ // this guard (e.g. a wire transfer) and have it execute under that unrelated verification.
292
+ // Mirrors gateway.mjs's `route.action ?? signed.action` — "the route pins the action ...
293
+ // 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 });
278
295
  // allow/observe both PERMIT the tool call; observe is permit-but-flag (SAFR §11).
279
296
  if (decision.decision !== 'allow' && decision.decision !== 'observe') {
280
297
  const err = new Error(`MCP guard ${decision.decision.toUpperCase()} "${action}": ${decision.reasonCode}`);
@@ -389,7 +406,9 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
389
406
  * then, given the responder's CHALLENGE, verifies the responder proved control of
390
407
  * its DID before producing PROVE.
391
408
  *
392
- * @param {{fromDid:string, sign:(msg:string)=>string}} p sign() uses the initiator's own key
409
+ * @param {{fromDid:string, sign:(msg:string)=>(string|Promise<string>)}} p sign() uses the
410
+ * initiator's own key — may be sync (a raw local key) or async (e.g. a keyProvider backed by
411
+ * agentsafe-signer); `prove()` awaits it either way, see key-providers.mjs.
393
412
  */
394
413
  export function createHandshakeInitiator({ fromDid, sign } = {}) {
395
414
  if (!fromDid || typeof sign !== 'function') throw new Error('createHandshakeInitiator requires { fromDid, sign }');
@@ -400,7 +419,7 @@ export function createHandshakeInitiator({ fromDid, sign } = {}) {
400
419
  return { nonceA, message: { fromDid, nonceA, protoVersion: '1.0' } };
401
420
  },
402
421
  /** Step 3 (A): verify CHALLENGE proves the responder controls toDid, then PROVE. */
403
- prove({ nonceA, challenge } = {}) {
422
+ async prove({ nonceA, challenge } = {}) {
404
423
  const { toDid, nonceB, sigB, handshakeId } = challenge ?? {};
405
424
  if (!toDid || !nonceB || !sigB) throw new Error('malformed CHALLENGE');
406
425
  if (!verifyDidSignature(toDid, nonceA, sigB)) {
@@ -408,7 +427,7 @@ export function createHandshakeInitiator({ fromDid, sign } = {}) {
408
427
  e.name = 'HandshakeFailed';
409
428
  throw e;
410
429
  }
411
- return { handshakeId, sigA: sign(nonceB), remoteDid: toDid };
430
+ return { handshakeId, sigA: await sign(nonceB), remoteDid: toDid };
412
431
  },
413
432
  };
414
433
  }
@@ -0,0 +1,91 @@
1
+ // key-providers.mjs — the keyProvider seam for the SERVICE side (docs/design/
2
+ // agent-key-custody-local-signer-daemon-plan.md). Much smaller than agentsafe-guard's own copy:
3
+ // serviceKey is only ever used for one thing (signing a handshake nonce, MAGP §8.2), so there is
4
+ // only one provider method here, not four.
5
+ import crypto from 'node:crypto';
6
+ import net from 'node:net';
7
+ import path from 'node:path';
8
+
9
+ export function createStaticKeyProvider(serviceKeyHex) {
10
+ const privateKey = crypto.createPrivateKey({ key: Buffer.from(serviceKeyHex, 'hex'), format: 'der', type: 'pkcs8' });
11
+ return {
12
+ async signHandshakeNonce(nonce) {
13
+ return crypto.sign(null, Buffer.from(nonce, 'utf8'), privateKey).toString('hex');
14
+ },
15
+ };
16
+ }
17
+
18
+ /** Must match agentsafe-guard/key-providers.mjs's and agentsafe-signer/daemon.mjs's own copy — see either's own comment for why this isn't a shared import. */
19
+ function toPlatformSocketPath(logicalPath) {
20
+ if (process.platform !== 'win32') return logicalPath;
21
+ const name = crypto.createHash('sha256').update(path.resolve(logicalPath)).digest('hex').slice(0, 32);
22
+ return `\\\\.\\pipe\\agentsafe-signer-${name}`;
23
+ }
24
+
25
+ const PROTOCOL_VERSION = 1;
26
+
27
+ function daemonRequest(socketPath, op, params, { connectTimeoutMs = 3000 } = {}) {
28
+ return new Promise((resolve, reject) => {
29
+ const deadline = Date.now() + connectTimeoutMs;
30
+ function attempt() {
31
+ const sock = net.connect(toPlatformSocketPath(socketPath));
32
+ const requestId = crypto.randomUUID();
33
+ let buf = '';
34
+ const cleanup = () => sock.destroy();
35
+ sock.once('error', (err) => {
36
+ cleanup();
37
+ // See agentsafe-guard/key-providers.mjs's own copy of this function for why: on Windows
38
+ // the signing socket is a pool of independent named-pipe instances, each consumed by one
39
+ // connection and replaced asynchronously, so a request can transiently race that
40
+ // replacement window (ENOENT) even though the daemon is healthy. Only ENOENT retries — a
41
+ // genuinely down daemon still fails fast.
42
+ if (err.code === 'ENOENT' && Date.now() < deadline) {
43
+ setTimeout(attempt, 20);
44
+ return;
45
+ }
46
+ reject(Object.assign(new Error(`agentsafe-signer daemon unreachable at ${socketPath}: ${err.message}`), { code: 'DAEMON_UNREACHABLE' }));
47
+ });
48
+ sock.once('connect', () => {
49
+ sock.write(JSON.stringify({ protocolVersion: PROTOCOL_VERSION, requestId, op, params }) + '\n');
50
+ });
51
+ sock.on('data', (chunk) => {
52
+ buf += chunk.toString('utf8');
53
+ const idx = buf.indexOf('\n');
54
+ if (idx === -1) return;
55
+ let res;
56
+ try {
57
+ res = JSON.parse(buf.slice(0, idx));
58
+ } catch (err) {
59
+ cleanup();
60
+ reject(err);
61
+ return;
62
+ }
63
+ cleanup();
64
+ if (res.ok) resolve(res.result);
65
+ else reject(Object.assign(new Error(res.error?.message || res.error?.code || 'daemon rejected request'), { code: res.error?.code }));
66
+ });
67
+ }
68
+ attempt();
69
+ });
70
+ }
71
+
72
+ /** The key never enters this process — a service-role agentsafe-signer daemon signs instead. */
73
+ export function createDaemonKeyProvider({ socketPath }) {
74
+ if (!socketPath) throw new Error('createDaemonKeyProvider requires { socketPath }');
75
+ return {
76
+ async signHandshakeNonce(nonce) {
77
+ const { signature } = await daemonRequest(socketPath, 'sign-handshake-nonce', { nonce });
78
+ return signature;
79
+ },
80
+ };
81
+ }
82
+
83
+ /** Resolves a keyProvider from createMcpGuard's opts — null (not thrown) when neither
84
+ * keyProvider nor serviceKey is configured, matching today's "handshakeChallenge throws only if
85
+ * actually called without one" behavior rather than failing construction eagerly. */
86
+ export function resolveKeyProvider({ keyProvider, serviceKey, daemonSocketPath } = {}) {
87
+ if (keyProvider && typeof keyProvider === 'object' && typeof keyProvider.signHandshakeNonce === 'function') return keyProvider;
88
+ if (keyProvider === 'daemon') return createDaemonKeyProvider({ socketPath: daemonSocketPath });
89
+ if (!serviceKey) return null;
90
+ return createStaticKeyProvider(serviceKey);
91
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metamynd/agentsafe-mcp-guard",
3
- "version": "0.3.6",
3
+ "version": "0.6.1",
4
4
  "description": "Zero-dependency trustless governance for the SERVICE side. An MCP server or API re-verifies a calling agent's signed request against the agent's own published policy \u00e2\u20ac\u201d so an agent that ignores its own guard still cannot make your service act.",
5
5
  "type": "module",
6
6
  "main": "./agentsafe-mcp-guard.mjs",
@@ -15,6 +15,7 @@
15
15
  },
16
16
  "files": [
17
17
  "agentsafe-mcp-guard.mjs",
18
+ "key-providers.mjs",
18
19
  "magp-policy.mjs",
19
20
  "magp-did.mjs",
20
21
  "policy-core.mjs",
@@ -23,7 +24,7 @@
23
24
  "LICENSE"
24
25
  ],
25
26
  "scripts": {
26
- "test": "node mcp-guard.smoke.mjs && node pay.smoke.mjs && node decision-token.smoke.mjs && node claim-authorization.smoke.mjs"
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"
27
28
  },
28
29
  "engines": {
29
30
  "node": ">=18"
package/policy-core.mjs CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  // src/policy-core/atom-registry.ts
4
4
  var RISK_RANK = { low: 0, medium: 1, high: 2, critical: 3 };
5
+ function currencyOutOfScope(ctx, cfgCurrency) {
6
+ if (cfgCurrency === void 0 || cfgCurrency === null) return false;
7
+ const allowed = Array.isArray(cfgCurrency) ? cfgCurrency : [cfgCurrency];
8
+ if (allowed.length === 0) return false;
9
+ const currency = ctx.currency;
10
+ const matches = typeof currency === "string" && allowed.some((u) => typeof u === "string" && u.toUpperCase() === currency.toUpperCase());
11
+ return !matches;
12
+ }
5
13
  var ATOM_REGISTRY = {
6
14
  "data-source-not-approved": (c, cfg) => !!c.dataSourceId && !(cfg?.approved ?? []).includes(String(c.dataSourceId)),
7
15
  "consent-missing": (c) => c.consent === false,
@@ -10,7 +18,11 @@ var ATOM_REGISTRY = {
10
18
  const need = RISK_RANK[String(cfg?.level ?? "high")];
11
19
  return have !== void 0 && need !== void 0 && have >= need;
12
20
  },
13
- "amount-over": (c, cfg) => typeof c.amount === "number" && c.amount > Number(cfg?.limit ?? 0),
21
+ "amount-over": (c, cfg) => {
22
+ if (typeof c.amount !== "number") return false;
23
+ if (currencyOutOfScope(c, cfg?.currency)) return true;
24
+ return c.amount > Number(cfg?.limit ?? 0);
25
+ },
14
26
  // Deny-by-default primitive for value-moving actions. Fires on ABSENCE (like the
15
27
  // evidence atoms below, and unlike `amount-over`) OR on a NEGATIVE amount: true when
16
28
  // the context carries no usable amount, or one that cannot be trusted for capping —
@@ -31,7 +43,12 @@ var ATOM_REGISTRY = {
31
43
  "amount-unknown": (c) => !(typeof c.amount === "number" && Number.isFinite(c.amount) && c.amount >= 0),
32
44
  // Total budget: cumulativeSpend is a SERVER-derived, signed-last context field (never
33
45
  // shadowable by the agent's itinerary), so this compares already-spent + this amount.
34
- "cumulative-over": (c, cfg) => Number(c.cumulativeSpend ?? 0) + Number(c.amount ?? 0) > Number(cfg?.limit ?? 0),
46
+ // See `currencyOutOfScope` above: a configured currency scope that this request's
47
+ // currency doesn't match fires the cap outright, same fail-closed reasoning as `amount-over`.
48
+ "cumulative-over": (c, cfg) => {
49
+ if (currencyOutOfScope(c, cfg?.currency)) return true;
50
+ return Number(c.cumulativeSpend ?? 0) + Number(c.amount ?? 0) > Number(cfg?.limit ?? 0);
51
+ },
35
52
  // Fires if any configured term appears in the prompt and/or output text.
36
53
  // Used to govern agent responses on content (prohibited claims, sensitive advice).
37
54
  "text-matches": (c, cfg) => {
@@ -87,7 +104,25 @@ var ATOM_SPECS = [
87
104
  predicate: "amount-over",
88
105
  label: "Per-transaction amount over limit",
89
106
  description: "Fires when a single action amount exceeds a configured limit (per-transaction cap).",
90
- config: [{ key: "limit", type: "number", required: true, description: "Maximum allowed amount for one transaction" }],
107
+ config: [
108
+ { key: "limit", type: "number", required: true, description: "Maximum allowed amount for one transaction" },
109
+ {
110
+ key: "currency",
111
+ type: "string[]",
112
+ required: false,
113
+ description: `Optional currency scope for the limit (e.g. ['USD'], or ['USD','GBP'] for several). Leave empty to keep the limit currency-blind \u2014 the historical default: the raw number is compared regardless of currency. Once set, a request in a currency outside this list \u2014 or with none supplied at all \u2014 fires this atom regardless of amount (unverifiable is treated as unsafe, not as "smaller"), so the cap can't be cleared by naming a cheaper-looking currency (e.g. 200 JPY vs 200 USD).`
114
+ }
115
+ ],
116
+ // `currency` is NOT listed here even though the executable atom conditionally reads it:
117
+ // unlike `limit`, the `currency` config is OPTIONAL per atom instance, so whether an agent
118
+ // needs to supply it depends on how a given molecule configures this atom — something
119
+ // `requiredContextFor`'s per-predicate (not per-instance) model can't express. Every
120
+ // authorize request already carries `currency` unconditionally regardless (see
121
+ // AuthorizeInput), so nothing is actually left unfed by omitting it here — this only
122
+ // controls the Scenario Bank simulate form / docs "context contract" surfacing, and
123
+ // forcing it onto every amount-over molecule would spuriously mark scenarios that never
124
+ // configure a currency scope as unexercised (see cumulative-over-atom.test.ts's sibling
125
+ // comment below for the same reasoning applied there).
91
126
  requiredContext: ["amount"]
92
127
  },
93
128
  {
@@ -101,8 +136,32 @@ var ATOM_SPECS = [
101
136
  predicate: "cumulative-over",
102
137
  label: "Total budget over limit",
103
138
  description: "Fires when cumulative spend (already-spent + this transaction) exceeds a configured total budget.",
104
- config: [{ key: "limit", type: "number", required: true, description: "Maximum total budget across all transactions" }],
105
- requiredContext: ["amount"]
139
+ config: [
140
+ { key: "limit", type: "number", required: true, description: "Maximum total budget across all transactions" },
141
+ {
142
+ key: "currency",
143
+ type: "string[]",
144
+ required: false,
145
+ description: "Optional currency scope for the budget (e.g. ['USD'], or ['USD','GBP'] for several). Leave empty to keep it currency-blind \u2014 the historical default. Once set, a request in a currency outside this list \u2014 or with none supplied at all \u2014 fires this atom regardless of amount, same fail-closed design as amount-over's currency scope."
146
+ }
147
+ ],
148
+ // The executable atom (atom-registry.ts) reads BOTH fields: `cumulativeSpend + amount >
149
+ // limit`. Omitting `cumulativeSpend` here silently broke two downstream consumers this
150
+ // catalog is the single source of truth for (see file header): the Scenario Bank's
151
+ // simulate form never rendered an "already spent" field for any set using this atom —
152
+ // including its own seeded preset, which supplied `cumulativeSpend` for a form field
153
+ // that didn't exist — so the control could never actually be exercised from the UI; and
154
+ // the integration docs' generated "context contract" told real SDK integrators this
155
+ // atom only needs `amount`, so an agent that never sends `cumulativeSpend` gets it
156
+ // silently treated as 0 and the total-budget cap never fires in production either.
157
+ //
158
+ // `currency`, by contrast, is deliberately NOT added here even though the executable atom
159
+ // conditionally reads it — see the sibling comment on `amount-over`'s currency config
160
+ // above: it is optional PER ATOM INSTANCE (only read when a molecule configures a
161
+ // currency scope), so unlike `cumulativeSpend` (always read), a static per-predicate
162
+ // requiredContext can't represent it without forcing every set using this atom to demand
163
+ // a currency it may never need.
164
+ requiredContext: ["amount", "cumulativeSpend"]
106
165
  },
107
166
  {
108
167
  predicate: "risk-at-or-above",
@@ -357,7 +416,8 @@ function constraintSatisfied(c, req, strict) {
357
416
  const left = Object.prototype.hasOwnProperty.call(req.values, c.leftOperand) ? req.values[c.leftOperand] : void 0;
358
417
  if (!c.unit) return op(left, c.rightOperand);
359
418
  const currency = req.values["mm:currency"];
360
- const unitMatches = typeof currency === "string" && currency.toUpperCase() === c.unit.toUpperCase();
419
+ const allowedUnits = Array.isArray(c.unit) ? c.unit : [c.unit];
420
+ const unitMatches = typeof currency === "string" && allowedUnits.some((u) => u.toUpperCase() === currency.toUpperCase());
361
421
  return unitMatches ? op(left, c.rightOperand) : !strict;
362
422
  }
363
423
  function targetOf(rule, mandate) {
@@ -454,7 +514,15 @@ function escapeField(v) {
454
514
  return v.replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
455
515
  }
456
516
  function buildAuthMessage(f) {
457
- return [f.agentDid, f.action, f.amount, f.currency, f.merchant ?? "", f.nonce, f.issuedAt].map((v) => escapeField(String(v))).join("|");
517
+ return [f.agentDid, f.action, f.amount, f.currency, f.merchant ?? "", f.resource ?? "", f.nonce, f.issuedAt].map((v) => escapeField(String(v))).join("|");
518
+ }
519
+ function buildLocalDecisionMessage(f) {
520
+ return [f.agentDid, f.action, f.decision, f.reasonCode, f.nonce, f.issuedAt].map((v) => escapeField(String(v))).join("|");
521
+ }
522
+
523
+ // src/policy-core/checkpoint-anchor.ts
524
+ function buildCheckpointAnchorMessage(f) {
525
+ return [f.agentDid, f.checkpointHash, f.previousCheckpointHash, f.entryCount, f.nonce, f.issuedAt].map((v) => escapeField(String(v))).join("|");
458
526
  }
459
527
 
460
528
  // src/policy-core/context.ts
@@ -513,6 +581,8 @@ export {
513
581
  asOperatingMode,
514
582
  authorityFailure,
515
583
  buildAuthMessage,
584
+ buildCheckpointAnchorMessage,
585
+ buildLocalDecisionMessage,
516
586
  canAuthorize,
517
587
  evaluate,
518
588
  evaluateBoundStandards,