@metamynd/agentsafe-guard 0.7.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -112,6 +112,47 @@ OWN recorded spend history — a baseline the platform's trust-graph engine lear
112
112
  threshold a human pre-sets. Opt-in (`SPEND_ANOMALY_MODE=on`), off by default. Nothing in this
113
113
  package's own evaluation changes; documented here because the count moved again.
114
114
 
115
+ **0.9.0 — the `keyProvider` seam
116
+ ([docs/design/agent-key-custody-local-signer-daemon-plan.md](../../docs/design/agent-key-custody-local-signer-daemon-plan.md)).
117
+ `agentKey` no longer has to be a raw hex key living in this process. Pass
118
+ `keyProvider: 'daemon'` + `daemonSocketPath` instead, and every signature is produced by a
119
+ separate `@metamynd/agentsafe-signer` daemon over a local socket — the key never enters this
120
+ process at all. `agentKey` (unchanged) still works exactly as before and remains the default;
121
+ this is additive, not a replacement.**
122
+
123
+ **Breaking, disclosed plainly rather than silently shipped**: `buildSignedRequest`,
124
+ `signChallenge`, and `guard.handshake().prove()` are now `async` — they return a `Promise` of
125
+ what they used to return directly, because a `daemon`-backed provider genuinely needs a socket
126
+ round trip to produce a signature, and there is no honest way to make that synchronous. Every
127
+ caller of these three needs an `await` added. `authorize()` and `verifyKey()` were already
128
+ `async` and need no changes. New internal module: `key-providers.mjs` (exports
129
+ `createStaticKeyProvider`, `createDaemonKeyProvider`, and the resolution logic `createGuard`
130
+ itself uses) — still zero external dependencies, `node:crypto` + `node:net` only.
131
+
132
+ **0.9.1 — `keyProvider: 'daemon'` retries a transient connect failure.** On Windows the signer
133
+ daemon's socket is a pool of independent named-pipe instances (see
134
+ `@metamynd/agentsafe-signer`'s `windows-secure-pipe.mjs`); each instance is consumed by one
135
+ connection and replaced asynchronously, so two signing requests close together could race that
136
+ replacement window and fail with `DAEMON_UNREACHABLE` even though the daemon was healthy.
137
+ `key-providers.mjs` now retries a connection that fails with `ENOENT` for up to 3 seconds before
138
+ giving up — a genuinely unreachable daemon still fails fast on any other error. No API change.
139
+
140
+ **0.11.0 — `keyProvider: 'daemon'` now supports `signLocalDecision`.** A daemon-custody agent
141
+ gets the same local-first block/escalate/non-value-allow audit reporting the static-key provider
142
+ has had since 0.9.4 — `createDaemonKeyProvider` gained the fifth, optional `signLocalDecision`
143
+ method, and `@metamynd/agentsafe-signer`'s daemon gained the matching `sign-local-decision`
144
+ signing-socket operation. No change for a `createGuard({ agentKey })` caller.
145
+
146
+ **0.12.0 — passphrase-encrypted managed key delivery**
147
+ ([docs/design/passphrase-encrypted-key-delivery-plan.md](../../docs/design/passphrase-encrypted-key-delivery-plan.md)).
148
+ A managed key can now be delivered as ciphertext instead of plaintext: set a passphrase at
149
+ issuance, and `agent.metamynd.json` carries `agentKeyEncrypted` (`agentKey: null`) instead of a
150
+ raw key. `createGuardFromConfig(source, { passphrase })` decrypts it in memory before building
151
+ the guard — see "Passphrase-encrypted managed key" above. New export from `key-providers.mjs`:
152
+ `decryptAgentKeyWithPassword` (a byte-for-byte, `node:crypto`-only reimplementation of the
153
+ backend's `encryptWithPassword`/`decryptWithPassword`, cross-verified against it). Fully
154
+ additive: a config without `agentKeyEncrypted` is loaded exactly as before, no passphrase needed.
155
+
115
156
  ```yaml
116
157
  # .github/workflows/governance.yml
117
158
  name: Governance
@@ -201,6 +242,11 @@ nonce/replay + atomic cap, and anchored evidence **must** be server-side. If the
201
242
  can't be fetched, the guard defers to the authoritative remote gate rather than
202
243
  blind-allowing; a value action it can neither evaluate nor seal **fails closed**.
203
244
 
245
+ A block/escalate/non-value-allow decided locally is still reported to the gate as a
246
+ best-effort, signed **local decision receipt** — visible in the Activity Log and fleet
247
+ decision-mix, but not anchored/evidence-grade (see "Trust model" below). This never
248
+ blocks or delays the call above it.
249
+
204
250
  ```js
205
251
  // default — local-first
206
252
  const guard = await createGuardFromConfig('./agent.metamynd.json');
@@ -257,8 +303,29 @@ const guard = await createGuardFromConfig('./agent.metamynd.json', { agentKey: m
257
303
  await guard.verifyKey({ ref: config.identityId, challenge: config.challenge, token: ownerToken }); // one-time
258
304
  ```
259
305
 
260
- `guard.signChallenge(challenge)` returns just the hex signature if you'd rather submit verify-key
261
- yourself. (Fastest path: `npm create metamynd-agent@latest -- --byok` does all of this for you.)
306
+ `await guard.signChallenge(challenge)` returns just the hex signature if you'd rather submit
307
+ verify-key yourself. (Fastest path: `npm create metamynd-agent@latest -- --byok` does all of this
308
+ for you.)
309
+
310
+ ### Passphrase-encrypted managed key
311
+
312
+ If you didn't set a passphrase at issuance, a managed key is delivered **plaintext** — the
313
+ `agentKey` field in `agent.metamynd.json` is usable as-is. Setting a passphrase (Launchpad's
314
+ Identity stage, or `passphrase` on `POST /onboarding/agent`) instead delivers an
315
+ `agentKeyEncrypted` object and leaves `agentKey: null`; decrypt it in memory when you load the
316
+ config:
317
+
318
+ ```js
319
+ const guard = await createGuardFromConfig('./agent.metamynd.json', { passphrase: myPassphrase });
320
+ ```
321
+
322
+ This is strictly better than a plaintext file sitting on disk, in an email attachment, or in a
323
+ `git add .` — but strictly weaker than `keyProvider: 'daemon'` (no key material in this process
324
+ at all): the decrypted key still lives in this process's memory for as long as it runs.
325
+ **Passphrase loss is unrecoverable**, the same as a lost BYOK private key — MetaMynd never sees
326
+ or stores the passphrase itself, so there is nothing to reset. Omitting `{ passphrase }` when the
327
+ config actually needs one throws a clear, specific error rather than the generic "requires
328
+ agentKey or keyProvider" message.
262
329
 
263
330
  ## 1. Seed a bound agent (once)
264
331
 
@@ -331,7 +398,11 @@ const gatedBookFlight = guard.guardTool(
331
398
  ```
332
399
 
333
400
  - If your OpenClaw build has a **pre-tool hook / middleware** instead of raw handlers, call
334
- `await guard.authorize({ action, amount, merchant, context })` there and refuse on any non-`allow`.
401
+ `await guard.authorize({ action, amount, merchant, resource, context })` there and refuse on any
402
+ non-`allow`. `resource` is a genuine, non-spoofable "what this action touches" declaration —
403
+ checked against a mandate's `{leftOperand:'resource'}` constraint the same way `merchant` is
404
+ checked against its own allow-list, and just as much a SIGNED field (tampering with it after
405
+ signing fails the request with `SIGNATURE_INVALID`, not a silent bypass).
335
406
  - **`context`** is what the Standard/SOP atoms read (jurisdiction, model, tool, PII, risk, …). Each
336
407
  atom declares what it needs — fetch the catalog at `GET /api/v1/standards/atoms` to see the exact
337
408
  fields (`requiredContext`) for the rules your agent is bound to.
@@ -388,8 +459,8 @@ const sandboxed = (ctx) => ctx.action === 'flight-purchase' ? sandboxBook(ctx.ar
388
459
  Contract: `async ({ action, args, decision, proceed }) => result`. Call `proceed()` to execute for
389
460
  real; return without it to substitute the side-effect. Self-check: `node execution-adapter.smoke.mjs`.
390
461
 
391
- Signed request fields (`amount`, `merchant`) are always applied over the unsigned `context`, so a
392
- forged context key can never shadow them (MAGP §6.4.2). Run the self-check:
462
+ Signed request fields (`amount`, `merchant`, `resource`) are always applied over the unsigned
463
+ `context`, so a forged context key can never shadow them (MAGP §6.4.2). Run the self-check:
393
464
 
394
465
  ```powershell
395
466
  cd integrations\agentsafe-guard
@@ -410,7 +481,7 @@ drives the initiator side:
410
481
  const hs = guard.handshake();
411
482
  const { nonceA, message } = hs.hello(); // → send HELLO to the Service
412
483
  // Service replies with CHALLENGE { toDid, nonceB, sigB(nonceA) }
413
- const { sigA, handshakeId } = hs.prove({ nonceA, challenge }); // verifies the Service, → send PROVE
484
+ const { sigA, handshakeId } = await hs.prove({ nonceA, challenge }); // verifies the Service, → send PROVE
414
485
  // Service replies READY { channelId }
415
486
  ```
416
487
 
@@ -446,5 +517,10 @@ The gate is a **checkpoint** — enforcement is real when it's actually called.
446
517
  only proceeds on `allow`. A rogue agent that skips the call can't get the counterparty to act.
447
518
  - **Cooperative:** the agent's own tool layer (this guard) calls the gate and refuses on block/escalate.
448
519
 
449
- Either way, every decision is Ed25519-authenticated, deterministic, and anchored as evidence
450
- (visible in the dashboard's Regulator log and on HashScan).
520
+ Either way, every decision is Ed25519-authenticated and deterministic. A decision that round-trips
521
+ the gate a sealed value action, or `mode:'remote'` is also **anchored as evidence** (visible in
522
+ the dashboard's Regulator log and on HashScan). A purely local-first decision (the default mode's
523
+ block/escalate/non-value-allow — see "Enforcement mode" above) is NOT anchored, but is still
524
+ recorded for Activity Log / decision-mix visibility via a best-effort signed receipt (no hold, no
525
+ spend check, not evidence-grade) — `guardToolLocal()`'s pure-offline path has no server-side trail
526
+ at all, by design.
@@ -14,6 +14,7 @@ import { evaluate, buildAuthMessage, applySignedLast, operatingModeGate } from '
14
14
  import { envelopeHashFor } from './governance-envelope.mjs';
15
15
  import { verifyDidSignature } from './magp-did.mjs';
16
16
  import { checkSettlementBinding } from './x402.mjs';
17
+ import { resolveKeyProvider, decryptAgentKeyWithPassword } from './key-providers.mjs';
17
18
 
18
19
  /**
19
20
  * Replay a Merkle sibling chain and report whether it reconstructs `root`.
@@ -87,6 +88,14 @@ export function executionAdapterFromEnv(env = (typeof process !== 'undefined' ?
87
88
  * Async loader — build a guard from the portable config the one-call `POST /onboarding/agent`
88
89
  * endpoint returns: a URL, a file path, or the config object itself. Overrides win over the config.
89
90
  * const guard = await createGuardFromConfig('./agent.metamynd.json');
91
+ *
92
+ * Passphrase-encrypted key delivery (docs/design/passphrase-encrypted-key-delivery-plan.md): when
93
+ * the loaded config carries `agentKeyEncrypted` (no plaintext `agentKey` — the operator set a
94
+ * passphrase at issuance) and no explicit `agentKey`/`keyProvider` override was given, pass
95
+ * `{ passphrase }` here to decrypt it IN-MEMORY before the guard is built:
96
+ * const guard = await createGuardFromConfig('./agent.metamynd.json', { passphrase: '...' });
97
+ * The passphrase itself is never sent anywhere by this function — only used locally to derive
98
+ * the decryption key, matching the one hard invariant the design doc names.
90
99
  */
91
100
  export async function createGuardFromConfig(source, overrides = {}) {
92
101
  let cfg = source;
@@ -94,7 +103,15 @@ export async function createGuardFromConfig(source, overrides = {}) {
94
103
  cfg = /^https?:\/\//.test(source) ? await (await fetch(source)).json() : JSON.parse(readFileSync(source, 'utf8'));
95
104
  }
96
105
  if (cfg && cfg.data && !cfg.agentDid) cfg = cfg.data; // unwrap a { success, data } API response
97
- return createGuard({ config: cfg, ...overrides });
106
+ const { passphrase, ...rest } = overrides;
107
+ if (cfg?.agentKeyEncrypted && !cfg.agentKey && !rest.agentKey && !rest.keyProvider) {
108
+ if (!passphrase) {
109
+ throw new Error("createGuardFromConfig: this config's key is passphrase-encrypted — pass { passphrase }");
110
+ }
111
+ const agentKey = decryptAgentKeyWithPassword(cfg.agentKeyEncrypted.ciphertext, passphrase, cfg.agentKeyEncrypted.salt);
112
+ return createGuard({ config: cfg, agentKey, ...rest });
113
+ }
114
+ return createGuard({ config: cfg, ...rest });
98
115
  }
99
116
 
100
117
  export function createGuard(opts = {}) {
@@ -108,18 +125,17 @@ export function createGuard(opts = {}) {
108
125
  if (cfg && cfg.data && !cfg.agentDid) cfg = cfg.data; // unwrap a { success, data } API response
109
126
  const api = opts.api ?? cfg?.apiBase ?? cfg?.api;
110
127
  const agentDid = opts.agentDid ?? cfg?.agentDid;
111
- const agentKey = opts.agentKey ?? cfg?.agentKey;
112
- if (!api || !agentDid || !agentKey) throw new Error('createGuard requires { api, agentDid, agentKey } — directly, or via { config } / { configPath } / createGuardFromConfig()');
128
+ if (!api || !agentDid) throw new Error('createGuard requires { api, agentDid } — directly, or via { config } / { configPath } / createGuardFromConfig() — plus either { agentKey } or { keyProvider }');
113
129
  const base = api.replace(/\/$/, '');
114
130
  // ExecutionAdapter seam (SAFR §19): an explicit opt wins, else the AGENTSAFE_EXECUTION_MODE env,
115
131
  // else live. Applies to every guarded tool unless a tool passes its own adapter.
116
132
  const defaultExecutionAdapter = opts.executionAdapter ?? executionAdapterFromEnv() ?? liveExecutionAdapter;
117
- const privateKey = crypto.createPrivateKey({ key: Buffer.from(agentKey, 'hex'), format: 'der', type: 'pkcs8' });
118
-
119
- // Ed25519 over the exact canonical message the backend verifies.
120
- function sign(message) {
121
- return crypto.sign(null, Buffer.from(message, 'utf8'), privateKey).toString('hex');
122
- }
133
+ // keyProvider seam (docs/design/agent-key-custody-local-signer-daemon-plan.md): defaults to
134
+ // 'staticKey' (the raw key in THIS process, today's only behavior before this seam existed) —
135
+ // pass keyProvider:'daemon' + daemonSocketPath to keep the key out of this process entirely.
136
+ // Every place this file used to call a local sign(message) now calls one of the provider's
137
+ // four methods instead — see key-providers.mjs for why there are four, not one.
138
+ const keyProvider = resolveKeyProvider(opts, cfg);
123
139
 
124
140
  // Tier 1 context-claim binding (opt-in, docs/design/context-claim-binding.md): when
125
141
  // on, sign the GovernanceEnvelope hash too, so a counterparty/gate can prove the
@@ -127,12 +143,11 @@ export function createGuard(opts = {}) {
127
143
  // subset. Off by default: a bare request stays a valid degenerate envelope, exactly
128
144
  // like today, and the wire body carries no envelopeSignature field at all.
129
145
  const signContext = opts.signContext ?? cfg?.signContext ?? false;
130
- function envelopeSignatureFor({ action, amount, currency, merchant, context, trace, materiality, nonce, issuedAt }) {
146
+ async function envelopeSignatureFor({ action, amount, currency, merchant, context, trace, materiality, nonce, issuedAt }) {
131
147
  if (!signContext) return undefined;
132
148
  // The hash is independent of `signature` (excluded from what it commits to — see
133
149
  // governance-envelope.ts), so an empty placeholder here is exact, not approximate.
134
- const hash = envelopeHashFor({ agentDid, action, amount, currency, merchant, itinerary: context, trace, materiality, nonce, issuedAt, signature: '' });
135
- return sign(hash);
150
+ return keyProvider.signEnvelope({ agentDid, action, amount, currency, merchant, itinerary: context, trace, materiality, nonce, issuedAt });
136
151
  }
137
152
 
138
153
  // --- Enforcement mode (spec §9.2 + local-first plan) --------------------------------------
@@ -167,17 +182,40 @@ export function createGuard(opts = {}) {
167
182
  * agent's authorization trustlessly against the agent's policy bundle (§9.3). Same shape
168
183
  * `authorize()` posts to the gate; a fresh nonce each call.
169
184
  */
170
- function buildSignedRequest({ action, amount = 0, currency = 'USD', merchant = '', context = {}, trace, materiality }) {
185
+ async function buildSignedRequest({ action, amount, currency, merchant, resource, context = {}, trace, materiality }) {
171
186
  const nonce = crypto.randomUUID();
172
187
  const issuedAt = new Date().toISOString();
173
- const message = buildAuthMessage({ agentDid, action, amount, currency, merchant, nonce, issuedAt });
188
+ // This object is presented to a COUNTERPARTY (spec §9.3) but its own docstring also
189
+ // promises "same shape authorize() posts to the gate", so it must ALSO independently
190
+ // re-verify if posted straight to /policy/mandate/authorize, not just via a naive
191
+ // counterparty reconstruction. Found live: signing with amount/currency genuinely
192
+ // undefined (bothOmitted) produced a message the backend's authMessage() — which
193
+ // ALWAYS defaults a missing amount/currency to 0/'USD' before reconstructing,
194
+ // regardless of the wire body — could never verify. Fix: SIGN with the same 0/'USD'
195
+ // default authorize() and the gate both use, unconditionally; the WIRE body still
196
+ // keeps a real omission as a real omission. A naive third-party reconstruction (e.g.
197
+ // today's agentsafe-mcp-guard, which doesn't yet apply this same default) would need
198
+ // the same fix to correctly re-verify a bothOmitted request — flagged as a follow-up,
199
+ // not something to leave this function broken against the gate over.
200
+ const bothOmitted = amount === undefined && currency === undefined;
201
+ const wireAmount = bothOmitted ? undefined : (amount ?? 0);
202
+ const wireCurrency = bothOmitted ? undefined : (currency ?? 'USD');
203
+ const signedAmount = amount ?? 0;
204
+ const signedCurrency = currency ?? 'USD';
205
+ // `resource` is independent of amount/currency's bothOmitted pairing — it always signs and
206
+ // travels exactly as given (undefined stays undefined on the wire, buildAuthMessage's own
207
+ // internal `?? ''` fallback handles the signed-message side, same as `merchant`).
174
208
  // trace/materiality are GovernanceEnvelope fields (SAFR §5) — unsigned metadata; the
175
- // signed message stays the action subset, so verification is unchanged.
176
- return {
177
- agentDid, action, amount, currency, merchant, itinerary: context, trace, materiality, nonce, issuedAt,
178
- signature: sign(message),
179
- envelopeSignature: envelopeSignatureFor({ action, amount, currency, merchant, context, trace, materiality, nonce, issuedAt }),
180
- };
209
+ // signed message stays the action subset, so verification is unchanged. `resource` is
210
+ // deliberately NOT passed to envelopeSignatureFor: the backend's governance-envelope.ts
211
+ // action fields don't include it yet either (only amount/currency/merchant) — adding it
212
+ // to just one side would break Tier-1 envelope-hash verification for any resource-
213
+ // declaring request. A coordinated backend+guard follow-up, not something to do half here.
214
+ const [signature, envelopeSignature] = await Promise.all([
215
+ keyProvider.signAuthorize({ agentDid, action, amount: signedAmount, currency: signedCurrency, merchant, resource, nonce, issuedAt }),
216
+ envelopeSignatureFor({ action, amount: wireAmount, currency: wireCurrency, merchant, context, trace, materiality, nonce, issuedAt }),
217
+ ]);
218
+ return { agentDid, action, amount: wireAmount, currency: wireCurrency, merchant, resource, itinerary: context, trace, materiality, nonce, issuedAt, signature, envelopeSignature };
181
219
  }
182
220
 
183
221
  /**
@@ -185,13 +223,31 @@ export function createGuard(opts = {}) {
185
223
  * returns { decision:'allow'|'block'|'escalate', reasonCode, authorizationId, remaining }.
186
224
  * A network/gate failure returns a fail-CLOSED block so the agent can't proceed blind.
187
225
  */
188
- async function authorize({ action, amount = 0, currency = 'USD', merchant = '', context = {}, trace, materiality }) {
226
+ async function authorize({ action, amount, currency, merchant, resource, context = {}, trace, materiality }) {
189
227
  const nonce = crypto.randomUUID();
190
228
  const issuedAt = new Date().toISOString();
191
- // Build the canonical signed message with policy-core so the guard and the
192
- // backend gate produce byte-identical input to Ed25519 (spec §7.3).
193
- const message = buildAuthMessage({ agentDid, action, amount, currency, merchant, nonce, issuedAt });
194
229
  try {
230
+ // This is verified SERVER-SIDE by the gate, which independently reconstructs the signed
231
+ // message via its own authMessage() (mandate.service.ts) — that function ALSO defaults a
232
+ // missing amount/currency to 0/'USD' before rebuilding the message, regardless of what the
233
+ // wire body actually contains. So the client signs with that same 0/'USD' default whenever
234
+ // a field is genuinely omitted — matching the gate's reconstruction — while the WIRE body
235
+ // below keeps a real omission as a real omission (not the gate's problem: it defaults on
236
+ // its own) rather than fabricating amount:0/currency:'USD' for a non-financial action.
237
+ // `resource` needs no such dance: the gate's authMessage() spreads `...input` directly
238
+ // (no explicit default), and buildAuthMessage's own internal `f.resource ?? ''` fallback
239
+ // already matches whatever this client signs when it too is genuinely omitted.
240
+ const signedAmount = amount ?? 0;
241
+ const signedCurrency = currency ?? 'USD';
242
+ // The canonical message itself is built by the key provider (policy-core's
243
+ // buildAuthMessage, same as the backend gate verifies against — spec §7.3), not here —
244
+ // see key-providers.mjs for why callers pass structured fields, not a pre-built string.
245
+ // `resource` deliberately NOT passed to envelopeSignatureFor — see buildSignedRequest's
246
+ // own comment on why (backend governance-envelope.ts doesn't include it yet either).
247
+ const [signature, envelopeSignature] = await Promise.all([
248
+ keyProvider.signAuthorize({ agentDid, action, amount: signedAmount, currency: signedCurrency, merchant, resource, nonce, issuedAt }),
249
+ envelopeSignatureFor({ action, amount, currency, merchant, context, trace, materiality, nonce, issuedAt }),
250
+ ]);
195
251
  const res = await fetch(`${base}/policy/mandate/authorize`, {
196
252
  method: 'POST',
197
253
  headers: { 'Content-Type': 'application/json' },
@@ -199,15 +255,19 @@ export function createGuard(opts = {}) {
199
255
  // as unsigned-message metadata; JSON.stringify drops them when undefined, so an
200
256
  // agent that omits them (or leaves signContext off) sends the legacy body.
201
257
  body: JSON.stringify({
202
- agentDid, action, amount, currency, merchant, itinerary: context, trace, materiality, nonce, issuedAt,
203
- signature: sign(message),
204
- envelopeSignature: envelopeSignatureFor({ action, amount, currency, merchant, context, trace, materiality, nonce, issuedAt }),
258
+ agentDid, action, amount, currency, merchant, resource, itinerary: context, trace, materiality, nonce, issuedAt,
259
+ signature,
260
+ envelopeSignature,
205
261
  }),
206
262
  });
207
263
  const body = await res.json().catch(() => null);
208
264
  return body?.data ?? { decision: 'block', reasonCode: `GATE_HTTP_${res.status}` };
209
265
  } catch (err) {
210
- return { decision: 'block', reasonCode: 'GATE_UNREACHABLE', error: String(err?.message ?? err) };
266
+ // A daemon-backed keyProvider can fail before the gate is ever reached (the signer, not
267
+ // the gate, was unreachable) — a distinct reasonCode so this doesn't read as a gate outage
268
+ // it wasn't. Still fail-CLOSED either way, which is the property that actually matters.
269
+ const reasonCode = err?.code?.startsWith?.('DAEMON_') ? 'SIGNER_UNREACHABLE' : 'GATE_UNREACHABLE';
270
+ return { decision: 'block', reasonCode, error: String(err?.message ?? err) };
211
271
  }
212
272
  }
213
273
 
@@ -355,6 +415,37 @@ export function createGuard(opts = {}) {
355
415
  return _anchor;
356
416
  }
357
417
 
418
+ // Verdicts the backend's /policy/decisions/local will actually accept (local-decision.service.ts's
419
+ // LOCAL_DECISIONS) — 'quarantine'/'suspend' are containment, a SERVER-state decision whose audit
420
+ // trail already lives on the server (the `contained` flag this very evaluation read came FROM
421
+ // the server's own bundle response), so there is nothing new to report for those.
422
+ const REPORTABLE_LOCAL_DECISIONS = new Set(['allow', 'observe', 'block', 'escalate']);
423
+
424
+ /**
425
+ * Best-effort, NEVER awaited by the caller: reports a purely-local verdict to the gate
426
+ * for audit VISIBILITY only (Activity Log / fleet decision-mix / regulator log otherwise
427
+ * show nothing for the large majority of decisions under the default local-first mode —
428
+ * see docs/design/... and this package's own README "Enforcement mode" section for why
429
+ * that's a deliberate security tradeoff, not a bug, but one that used to leave zero trail
430
+ * anywhere). Silently does nothing if the keyProvider doesn't support it (e.g. the daemon
431
+ * keyProvider, which doesn't implement signLocalDecision in this version) or the verdict
432
+ * isn't one the endpoint accepts. Never throws, never delays the caller.
433
+ */
434
+ function reportLocalDecision(action, decision, reasonCode) {
435
+ if (typeof keyProvider.signLocalDecision !== 'function') return;
436
+ if (!REPORTABLE_LOCAL_DECISIONS.has(decision)) return;
437
+ const nonce = crypto.randomUUID();
438
+ const issuedAt = new Date().toISOString();
439
+ void (async () => {
440
+ const signature = await keyProvider.signLocalDecision({ agentDid, action, decision, reasonCode, nonce, issuedAt });
441
+ await fetch(`${base}/policy/decisions/local`, {
442
+ method: 'POST',
443
+ headers: { 'Content-Type': 'application/json' },
444
+ body: JSON.stringify({ agentDid, action, decision, reasonCode, nonce, issuedAt, signature }),
445
+ });
446
+ })().catch(() => {});
447
+ }
448
+
358
449
  /**
359
450
  * LOCAL-FIRST decision (the default). Evaluates the rule layer against the cached
360
451
  * bundle with the same policy-core the gate runs — so a block/escalate is decided
@@ -382,8 +473,12 @@ export function createGuard(opts = {}) {
382
473
  const local = evaluateLocally({ ..._bundleFor(b, action), request: input });
383
474
  // allow/observe both PERMIT; block/escalate/contain are decided locally with no network.
384
475
  const permits = local.decision === 'allow' || local.decision === 'observe';
385
- if (!permits) return local; // denied/escalated locally, no network
476
+ if (!permits) {
477
+ reportLocalDecision(action, local.decision, local.reasonCode); // fire-and-forget — see above
478
+ return local; // denied/escalated locally, no network
479
+ }
386
480
  if (amount > 0 && sealValueActions) return authorize(input); // seal value action remotely (allow or observe)
481
+ reportLocalDecision(action, local.decision, local.reasonCode); // non-value permit — fire-and-forget
387
482
  return local; // non-value permit — local is sufficient
388
483
  }
389
484
 
@@ -445,6 +540,12 @@ export function createGuard(opts = {}) {
445
540
  * the gate — cooperative-mode, low-latency governance (spec §9.2). Fails CLOSED:
446
541
  * any error during local evaluation throws GovernanceBlocked, never allows.
447
542
  *
543
+ * Unlike `guardTool()`'s default local-first path (`authorizeLocal()`), this one makes
544
+ * NO network call of any kind, ever — that is its entire purpose (pure-offline,
545
+ * cooperative-mode use). It does NOT report to /policy/decisions/local, so a verdict
546
+ * decided this way has NO central audit trail at all, by design — a deliberate,
547
+ * pre-existing tradeoff this package leaves unchanged.
548
+ *
448
549
  * @param {string} action
449
550
  * @param {(args:any, decision:any)=>any} handler
450
551
  * @param {(args:any)=>{amount?:number,currency?:string,merchant?:string,context?:object}} mapArgs
@@ -514,7 +615,7 @@ export function createGuard(opts = {}) {
514
615
  * the DIDs, §4.1.2). Returns { hello, prove } to drive the exchange:
515
616
  * const hs = guard.handshake();
516
617
  * const { nonceA, message } = hs.hello(); // → send HELLO to the Service
517
- * const { sigA, handshakeId } = hs.prove({ nonceA, challenge }); // verifies the Service, → send PROVE
618
+ * const { sigA, handshakeId } = await hs.prove({ nonceA, challenge }); // verifies the Service, → send PROVE
518
619
  * `prove` throws HandshakeFailed if the Service's CHALLENGE does not verify.
519
620
  */
520
621
  function handshake() {
@@ -523,7 +624,7 @@ export function createGuard(opts = {}) {
523
624
  const nonceA = crypto.randomUUID();
524
625
  return { nonceA, message: { fromDid: agentDid, nonceA, protoVersion: '1.0' } };
525
626
  },
526
- prove({ nonceA, challenge } = {}) {
627
+ async prove({ nonceA, challenge } = {}) {
527
628
  const { toDid, nonceB, sigB, handshakeId } = challenge ?? {};
528
629
  if (!toDid || !nonceB || !sigB) throw new Error('malformed CHALLENGE');
529
630
  if (!verifyDidSignature(toDid, nonceA, sigB)) {
@@ -531,7 +632,7 @@ export function createGuard(opts = {}) {
531
632
  e.name = 'HandshakeFailed';
532
633
  throw e;
533
634
  }
534
- return { handshakeId, sigA: sign(nonceB), remoteDid: toDid };
635
+ return { handshakeId, sigA: await keyProvider.signHandshakeNonce(nonceB), remoteDid: toDid };
535
636
  },
536
637
  };
537
638
  }
@@ -700,7 +801,7 @@ export function createGuard(opts = {}) {
700
801
  const res = await fetch(`${base}/agent-identity/${encodeURIComponent(ref)}/verify-key`, {
701
802
  method: 'POST',
702
803
  headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
703
- body: JSON.stringify({ signature: sign(challenge) }),
804
+ body: JSON.stringify({ signature: await keyProvider.signKeyControlChallenge(challenge) }),
704
805
  });
705
806
  const body = await res.json().catch(() => null);
706
807
  if (!res.ok) {
@@ -711,10 +812,10 @@ export function createGuard(opts = {}) {
711
812
  return body?.data ?? { verified: true };
712
813
  }
713
814
 
714
- /** Sign a BYOK challenge with the agent's key (hex) — for integrators who submit verify-key themselves. */
715
- function signChallenge(challenge) {
815
+ /** Sign a BYOK challenge with the agent's key — for integrators who submit verify-key themselves. */
816
+ async function signChallenge(challenge) {
716
817
  if (!challenge) throw new Error('signChallenge requires the challenge nonce');
717
- return sign(challenge);
818
+ return keyProvider.signKeyControlChallenge(challenge);
718
819
  }
719
820
 
720
821
  return { authorize, authorizeLocal, check, loadBundle, policyAnchor: _currentAnchor, watchPolicy, mode, verifyOnChain, buildSignedRequest, capture, guardTool, evaluateLocally, guardToolLocal, handshake, preparePayment, escalationStatus, proof, effectDispatching, effectDispatched, effectUnknown, effectStatus, verifyKey, signChallenge, agentDid, executionAdapter: defaultExecutionAdapter };
@@ -0,0 +1,174 @@
1
+ // key-providers.mjs — the keyProvider seam (docs/design/agent-key-custody-local-signer-daemon-plan.md).
2
+ // A KeyProvider has four REQUIRED async methods, matching the four places this package always
3
+ // signs something (see agentsafe-guard.mjs: buildSignedRequest/authorize, the opt-in envelope
4
+ // signature, the mutual-handshake PROVE step, and BYOK key-control-proof) — every built-in
5
+ // provider implements all four, and a caller's own plain-object provider must too. `signLocalDecision`
6
+ // is a FIFTH, OPTIONAL method (both built-in providers implement it — see agentsafe-guard.mjs's
7
+ // authorizeLocal(), which checks for it before attempting to report a local decision) — a
8
+ // caller's own plain-object provider that omits it simply doesn't get that audit-visibility
9
+ // enhancement, with no change to its existing behavior otherwise.
10
+ import crypto from 'node:crypto';
11
+ import net from 'node:net';
12
+ import path from 'node:path';
13
+ import { buildAuthMessage, buildLocalDecisionMessage } from './policy-core.mjs';
14
+ import { envelopeHashFor } from './governance-envelope.mjs';
15
+
16
+ /**
17
+ * Today's default: the raw key lives in THIS process (see the design doc's "What this does not
18
+ * do" for the confidentiality tradeoff that implies). Builds each canonical message locally with
19
+ * the same policy-core/governance-envelope functions the backend verifies against, then signs.
20
+ */
21
+ export function createStaticKeyProvider(agentKeyHex) {
22
+ const privateKey = crypto.createPrivateKey({ key: Buffer.from(agentKeyHex, 'hex'), format: 'der', type: 'pkcs8' });
23
+ const rawSign = (message) => crypto.sign(null, Buffer.from(message, 'utf8'), privateKey).toString('hex');
24
+ return {
25
+ async signAuthorize(fields) {
26
+ return rawSign(buildAuthMessage(fields));
27
+ },
28
+ async signEnvelope(fields) {
29
+ return rawSign(envelopeHashFor({ ...fields, signature: '' }));
30
+ },
31
+ async signHandshakeNonce(nonce) {
32
+ return rawSign(nonce);
33
+ },
34
+ async signKeyControlChallenge(challengeHex) {
35
+ return rawSign(challengeHex);
36
+ },
37
+ async signLocalDecision(fields) {
38
+ return rawSign(buildLocalDecisionMessage(fields));
39
+ },
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Same platform/path translation the signer daemon itself uses (integrations/agentsafe-signer/
45
+ * daemon.mjs's toPlatformSocketPath) — must stay identical or client and server compute different
46
+ * pipe names on Windows and never connect. Duplicated rather than imported: this package has no
47
+ * dependency (workspace or npm) on agentsafe-signer, by design (see that package's own README).
48
+ */
49
+ function toPlatformSocketPath(logicalPath) {
50
+ if (process.platform !== 'win32') return logicalPath;
51
+ const name = crypto.createHash('sha256').update(path.resolve(logicalPath)).digest('hex').slice(0, 32);
52
+ return `\\\\.\\pipe\\agentsafe-signer-${name}`;
53
+ }
54
+
55
+ const PROTOCOL_VERSION = 1;
56
+
57
+ function daemonRequest(socketPath, op, params, { connectTimeoutMs = 3000 } = {}) {
58
+ return new Promise((resolve, reject) => {
59
+ const deadline = Date.now() + connectTimeoutMs;
60
+ function attempt() {
61
+ const sock = net.connect(toPlatformSocketPath(socketPath));
62
+ const requestId = crypto.randomUUID();
63
+ let buf = '';
64
+ const cleanup = () => sock.destroy();
65
+ sock.once('error', (err) => {
66
+ cleanup();
67
+ // On Windows the signing socket is a pool of independent named-pipe instances (see
68
+ // agentsafe-signer/windows-secure-pipe.mjs): each instance is consumed by exactly one
69
+ // connection, then replaced asynchronously. Two requests arriving close enough together
70
+ // can race that replacement window and transiently find zero live instances (ENOENT) even
71
+ // though the daemon itself is up and healthy. Retrying briefly is the same tolerance any
72
+ // client of a local, independently-started daemon needs — not a workaround for a broken
73
+ // invariant — and matches only ENOENT so a daemon that is genuinely down still fails fast.
74
+ if (err.code === 'ENOENT' && Date.now() < deadline) {
75
+ setTimeout(attempt, 20);
76
+ return;
77
+ }
78
+ reject(Object.assign(new Error(`agentsafe-signer daemon unreachable at ${socketPath}: ${err.message}`), { code: 'DAEMON_UNREACHABLE' }));
79
+ });
80
+ sock.once('connect', () => {
81
+ sock.write(JSON.stringify({ protocolVersion: PROTOCOL_VERSION, requestId, op, params }) + '\n');
82
+ });
83
+ sock.on('data', (chunk) => {
84
+ buf += chunk.toString('utf8');
85
+ const idx = buf.indexOf('\n');
86
+ if (idx === -1) return;
87
+ let res;
88
+ try {
89
+ res = JSON.parse(buf.slice(0, idx));
90
+ } catch (err) {
91
+ cleanup();
92
+ reject(err);
93
+ return;
94
+ }
95
+ cleanup();
96
+ if (res.ok) resolve(res.result);
97
+ else reject(Object.assign(new Error(res.error?.message || res.error?.code || 'daemon rejected request'), { code: res.error?.code }));
98
+ });
99
+ }
100
+ attempt();
101
+ });
102
+ }
103
+
104
+ /**
105
+ * The key never enters this process at all — every signature is produced by a separate
106
+ * agentsafe-signer daemon over a local socket, which builds the canonical message itself from
107
+ * these structured fields (never a pre-built string) and returns only the signature.
108
+ */
109
+ export function createDaemonKeyProvider({ socketPath }) {
110
+ if (!socketPath) throw new Error('createDaemonKeyProvider requires { socketPath }');
111
+ return {
112
+ async signAuthorize(fields) {
113
+ const { signature } = await daemonRequest(socketPath, 'sign-authorize', fields);
114
+ return signature;
115
+ },
116
+ async signEnvelope(fields) {
117
+ const { envelopeSignature } = await daemonRequest(socketPath, 'sign-envelope', fields);
118
+ return envelopeSignature;
119
+ },
120
+ async signHandshakeNonce(nonce) {
121
+ const { signature } = await daemonRequest(socketPath, 'sign-handshake-nonce', { nonce });
122
+ return signature;
123
+ },
124
+ async signKeyControlChallenge(challengeHex) {
125
+ const { signature } = await daemonRequest(socketPath, 'sign-key-control-challenge', { challenge: challengeHex });
126
+ return signature;
127
+ },
128
+ async signLocalDecision(fields) {
129
+ const { signature } = await daemonRequest(socketPath, 'sign-local-decision', fields);
130
+ return signature;
131
+ },
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Passphrase-encrypted key delivery (docs/design/passphrase-encrypted-key-delivery-plan.md).
137
+ * A byte-for-byte reimplementation of the backend's `decryptWithPassword`
138
+ * (backend/src/util/encryption.ts) using only node:crypto — PBKDF2-SHA512/100k-iterations/
139
+ * 32-byte key derived from (password, salt), then AES-256-GCM decrypt of the `iv:tag:encrypted`
140
+ * hex-joined triple `encryptWithPassword` produces. MUST stay in lockstep with that function:
141
+ * two independently-written implementations of "the same algorithm" silently diverging is
142
+ * exactly the class of bug buildSignedRequest's bothOmitted case already caught once in this
143
+ * package — see daemon-keyprovider.smoke.mjs's own cross-package pattern for why this is tested
144
+ * against real backend-produced ciphertext, not just self-consistently.
145
+ * @param {string} ciphertext encryptWithPassword()'s combined "iv:tag:encrypted" hex string
146
+ * @param {string} password the operator's passphrase — never persisted, used only here
147
+ * @param {string} salt the salt returned alongside `ciphertext` at issuance time
148
+ * @returns {string} the decrypted plaintext (the agent's private key hex)
149
+ */
150
+ export function decryptAgentKeyWithPassword(ciphertext, password, salt) {
151
+ const parts = String(ciphertext).split(':');
152
+ if (parts.length !== 3) throw new Error('decryptAgentKeyWithPassword: invalid encrypted data format');
153
+ const [ivHex, tagHex, encryptedHex] = parts;
154
+ // Matches deriveKeyFromPassword exactly: salt is passed as-is (its own hex STRING, UTF-8
155
+ // encoded by pbkdf2Sync's default), not decoded from hex to raw bytes first.
156
+ const key = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha512');
157
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(ivHex, 'hex'));
158
+ decipher.setAAD(Buffer.from('hedera-data', 'utf8'));
159
+ decipher.setAuthTag(Buffer.from(tagHex, 'hex'));
160
+ return decipher.update(encryptedHex, 'hex', 'utf8') + decipher.final('utf8');
161
+ }
162
+
163
+ /** Resolves `opts.keyProvider`/`opts.agentKey` into a concrete KeyProvider for createGuard(). */
164
+ export function resolveKeyProvider(opts, cfg) {
165
+ const kp = opts.keyProvider ?? cfg?.keyProvider;
166
+ if (kp && typeof kp === 'object' && typeof kp.signAuthorize === 'function') return kp;
167
+ if (kp === 'daemon' || (kp && typeof kp === 'object' && kp.type === 'daemon')) {
168
+ const socketPath = opts.daemonSocketPath ?? cfg?.daemonSocketPath ?? (kp && kp.socketPath);
169
+ return createDaemonKeyProvider({ socketPath });
170
+ }
171
+ const agentKey = opts.agentKey ?? cfg?.agentKey;
172
+ if (!agentKey) throw new Error("createGuard requires a keyProvider, or { agentKey } for the default 'staticKey' provider");
173
+ return createStaticKeyProvider(agentKey);
174
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metamynd/agentsafe-guard",
3
- "version": "0.7.0",
3
+ "version": "0.12.0",
4
4
  "description": "Zero-dependency runtime governance for any Node AI agent \u2014 gate tool calls through MetaMynd/AgentSafe (allow / block / escalate) against the agent's mandate, enforced Standards, and SOPs. Ed25519-signed, deterministic, fail-closed.",
5
5
  "type": "module",
6
6
  "main": "./agentsafe-guard.mjs",
@@ -16,6 +16,7 @@
16
16
  "cli.mjs",
17
17
  "verify.mjs",
18
18
  "agentsafe-guard.mjs",
19
+ "key-providers.mjs",
19
20
  "policy-core.mjs",
20
21
  "governance-envelope.mjs",
21
22
  "magp-did.mjs",
@@ -25,7 +26,7 @@
25
26
  "LICENSE"
26
27
  ],
27
28
  "scripts": {
28
- "test": "node demo.mjs && node local-eval.smoke.mjs && node execution-adapter.smoke.mjs && node verify.smoke.mjs && node context-signature.smoke.mjs",
29
+ "test": "node demo.mjs && node local-eval.smoke.mjs && node execution-adapter.smoke.mjs && node verify.smoke.mjs && node context-signature.smoke.mjs && node local-decision-report.smoke.mjs && node resource-constraint.smoke.mjs && node passphrase-key.smoke.mjs",
29
30
  "demo": "node demo.mjs"
30
31
  },
31
32
  "engines": {
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,