@metamynd/agentsafe-guard 0.8.0 → 0.12.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
@@ -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
 
@@ -237,7 +297,7 @@ export function createGuard(opts = {}) {
237
297
  * @param {Array<{standardKey:string,document:object}>} [p.standards] enforced Standards bound to the agent
238
298
  * @param {Array<{standardKey:string,document:object}>} [p.sops] active SOPs assigned to the agent
239
299
  * @param {object} [p.mandate] the ODRL mandate document (omit to skip the mandate layer)
240
- * @param {{action:string,amount?:number,merchant?:string,context?:object,cumulativeSpend?:number,now?:string}} p.request
300
+ * @param {{action:string,amount?:number,currency?:string,merchant?:string,resource?:string,context?:object,cumulativeSpend?:number,now?:string}} p.request
241
301
  * @returns {{decision:'allow'|'block'|'escalate',reasonCode:string|null,authorizationId:null,remaining:null,proofRef:null}}
242
302
  */
243
303
  function evaluateLocally({ contained = null, operatingMode = null, standards = [], sops = [], mandate, request }) {
@@ -250,7 +310,7 @@ export function createGuard(opts = {}) {
250
310
  const reasonCode = contained.status === 'quarantined' ? 'AGENT_QUARANTINED' : 'AGENT_SUSPENDED';
251
311
  return { decision, reasonCode, authorizationId: null, remaining: null, proofRef: null };
252
312
  }
253
- const { action, amount = 0, currency = 'USD', merchant = '', context = {}, cumulativeSpend = amount, now } = request;
313
+ const { action, amount = 0, currency = 'USD', merchant = '', resource = null, context = {}, cumulativeSpend = amount, now } = request;
254
314
  // Operating-mode autonomy ladder (Phase 2.5b): the trust-driven posture rides as a
255
315
  // SIBLING (like `contained`) and biases the edge verdict identically to the gate.
256
316
  // READ_ONLY denies a value-bearing action up-front; SUPERVISED/RESTRICTED only
@@ -266,7 +326,11 @@ export function createGuard(opts = {}) {
266
326
  standards,
267
327
  sops,
268
328
  mandate,
269
- context: applySignedLast(context, { action, agentDid, amount }),
329
+ // currency/merchant/resource are signed fields too, same as action/agentDid/amount
330
+ // omitting them here means a currency-scoped amount-over/cumulative-over Standards/SOP
331
+ // atom always sees currency as absent and fires closed. Mirrors mandate.service.ts's
332
+ // ruleCtx (PR #588) and the same fix in agentsafe-mcp-guard.mjs's verdictFromBundle.
333
+ context: applySignedLast(context, { action, agentDid, amount, currency, merchant, resource }),
270
334
  mandateRequest: mandate
271
335
  ? {
272
336
  target: action,
@@ -281,6 +345,9 @@ export function createGuard(opts = {}) {
281
345
  // amount, since undefined never equals a real unit. Defaults to 'USD' to match
282
346
  // the same default this file already uses for authorize()/buildSignedRequest().
283
347
  'mm:currency': currency,
348
+ // Unprefixed `resource` (not `mm:resource`) to match the constraint's own
349
+ // leftOperand (ResourceService.scopeConstraint()) — mirrors mandate.service.ts.
350
+ resource,
284
351
  }),
285
352
  }
286
353
  : undefined,
@@ -312,14 +379,29 @@ export function createGuard(opts = {}) {
312
379
  return b;
313
380
  }
314
381
 
315
- /** Map a fetched bundle into the shape evaluateLocally expects, for one action. */
382
+ /**
383
+ * Map a fetched bundle into the shape evaluateLocally expects, for one action.
384
+ *
385
+ * `mandateFound` distinguishes "no mandate matches THIS action" from "omit mandate to
386
+ * skip the layer" (evaluateLocally's own documented, intentional behavior for a caller
387
+ * that never resolves one at all, e.g. guardToolLocal's caller-supplied bundle) — found
388
+ * live: this used to fall back to `mandates[0]` (an ARBITRARY, possibly unrelated
389
+ * mandate for a completely different action) rather than correctly reporting no
390
+ * authority for this one, and `authorizeLocal` never distinguished either case from a
391
+ * genuinely-mandate-less bundle, so an ungranted action with no Standard/SOP rule
392
+ * happening to also catch it was silently ALLOWED instead of NO_PERMISSION_FOR_ACTION.
393
+ */
316
394
  function _bundleFor(b, action) {
395
+ const mandates = b.mandates ?? [];
396
+ const match = mandates.find((m) => m.action === action);
317
397
  return {
318
398
  contained: b.contained ?? null,
319
399
  operatingMode: b.operatingMode ?? null,
320
400
  standards: (b.standards ?? []).map((s) => ({ standardKey: s.id ?? s.standardKey ?? 'standard', document: s.document })).filter((s) => s.document),
321
401
  sops: (b.sops ?? []).map((s) => ({ standardKey: s.id ?? s.sopId ?? 'sop', document: s.document })).filter((s) => s.document),
322
- mandate: ((b.mandates ?? []).find((m) => m.action === action) ?? (b.mandates ?? [])[0])?.document,
402
+ mandate: match?.document,
403
+ mandateFound: !!match,
404
+ anyMandates: mandates.length > 0,
323
405
  };
324
406
  }
325
407
 
@@ -355,6 +437,37 @@ export function createGuard(opts = {}) {
355
437
  return _anchor;
356
438
  }
357
439
 
440
+ // Verdicts the backend's /policy/decisions/local will actually accept (local-decision.service.ts's
441
+ // LOCAL_DECISIONS) — 'quarantine'/'suspend' are containment, a SERVER-state decision whose audit
442
+ // trail already lives on the server (the `contained` flag this very evaluation read came FROM
443
+ // the server's own bundle response), so there is nothing new to report for those.
444
+ const REPORTABLE_LOCAL_DECISIONS = new Set(['allow', 'observe', 'block', 'escalate']);
445
+
446
+ /**
447
+ * Best-effort, NEVER awaited by the caller: reports a purely-local verdict to the gate
448
+ * for audit VISIBILITY only (Activity Log / fleet decision-mix / regulator log otherwise
449
+ * show nothing for the large majority of decisions under the default local-first mode —
450
+ * see docs/design/... and this package's own README "Enforcement mode" section for why
451
+ * that's a deliberate security tradeoff, not a bug, but one that used to leave zero trail
452
+ * anywhere). Silently does nothing if the keyProvider doesn't support it (e.g. the daemon
453
+ * keyProvider, which doesn't implement signLocalDecision in this version) or the verdict
454
+ * isn't one the endpoint accepts. Never throws, never delays the caller.
455
+ */
456
+ function reportLocalDecision(action, decision, reasonCode) {
457
+ if (typeof keyProvider.signLocalDecision !== 'function') return;
458
+ if (!REPORTABLE_LOCAL_DECISIONS.has(decision)) return;
459
+ const nonce = crypto.randomUUID();
460
+ const issuedAt = new Date().toISOString();
461
+ void (async () => {
462
+ const signature = await keyProvider.signLocalDecision({ agentDid, action, decision, reasonCode, nonce, issuedAt });
463
+ await fetch(`${base}/policy/decisions/local`, {
464
+ method: 'POST',
465
+ headers: { 'Content-Type': 'application/json' },
466
+ body: JSON.stringify({ agentDid, action, decision, reasonCode, nonce, issuedAt, signature }),
467
+ });
468
+ })().catch(() => {});
469
+ }
470
+
358
471
  /**
359
472
  * LOCAL-FIRST decision (the default). Evaluates the rule layer against the cached
360
473
  * bundle with the same policy-core the gate runs — so a block/escalate is decided
@@ -379,11 +492,34 @@ export function createGuard(opts = {}) {
379
492
  const sig = b?.proof?.signature;
380
493
  if (!anchor?.sigDigest || !sig || _sha256(sig) !== anchor.sigDigest) return authorize(input);
381
494
  }
382
- const local = evaluateLocally({ ..._bundleFor(b, action), request: input });
495
+ const { mandateFound, anyMandates, ...bundleForAction } = _bundleFor(b, action);
496
+ // No mandate covers this action at all — refuse outright rather than let evaluateLocally
497
+ // silently allow (its documented "omit mandate to skip the layer" behavior is for a
498
+ // caller that never intended a mandate check, not for one that looked and found none).
499
+ // Standards/SOP containment still applies first — a suspended/quarantined agent is
500
+ // refused for THAT reason, not misreported as merely lacking this one action.
501
+ if (!mandateFound) {
502
+ const contained = bundleForAction.contained;
503
+ if (contained && contained.status) {
504
+ const decision = contained.status === 'quarantined' ? 'quarantine' : 'suspend';
505
+ const reasonCode = contained.status === 'quarantined' ? 'AGENT_QUARANTINED' : 'AGENT_SUSPENDED';
506
+ const local = { decision, reasonCode, authorizationId: null, remaining: null, proofRef: null };
507
+ reportLocalDecision(action, local.decision, local.reasonCode);
508
+ return local;
509
+ }
510
+ const local = { decision: 'block', reasonCode: anyMandates ? 'NO_PERMISSION_FOR_ACTION' : 'NO_MANDATE', authorizationId: null, remaining: null, proofRef: null };
511
+ reportLocalDecision(action, local.decision, local.reasonCode);
512
+ return local;
513
+ }
514
+ const local = evaluateLocally({ ...bundleForAction, request: input });
383
515
  // allow/observe both PERMIT; block/escalate/contain are decided locally with no network.
384
516
  const permits = local.decision === 'allow' || local.decision === 'observe';
385
- if (!permits) return local; // denied/escalated locally, no network
517
+ if (!permits) {
518
+ reportLocalDecision(action, local.decision, local.reasonCode); // fire-and-forget — see above
519
+ return local; // denied/escalated locally, no network
520
+ }
386
521
  if (amount > 0 && sealValueActions) return authorize(input); // seal value action remotely (allow or observe)
522
+ reportLocalDecision(action, local.decision, local.reasonCode); // non-value permit — fire-and-forget
387
523
  return local; // non-value permit — local is sufficient
388
524
  }
389
525
 
@@ -445,6 +581,12 @@ export function createGuard(opts = {}) {
445
581
  * the gate — cooperative-mode, low-latency governance (spec §9.2). Fails CLOSED:
446
582
  * any error during local evaluation throws GovernanceBlocked, never allows.
447
583
  *
584
+ * Unlike `guardTool()`'s default local-first path (`authorizeLocal()`), this one makes
585
+ * NO network call of any kind, ever — that is its entire purpose (pure-offline,
586
+ * cooperative-mode use). It does NOT report to /policy/decisions/local, so a verdict
587
+ * decided this way has NO central audit trail at all, by design — a deliberate,
588
+ * pre-existing tradeoff this package leaves unchanged.
589
+ *
448
590
  * @param {string} action
449
591
  * @param {(args:any, decision:any)=>any} handler
450
592
  * @param {(args:any)=>{amount?:number,currency?:string,merchant?:string,context?:object}} mapArgs
@@ -514,7 +656,7 @@ export function createGuard(opts = {}) {
514
656
  * the DIDs, §4.1.2). Returns { hello, prove } to drive the exchange:
515
657
  * const hs = guard.handshake();
516
658
  * const { nonceA, message } = hs.hello(); // → send HELLO to the Service
517
- * const { sigA, handshakeId } = hs.prove({ nonceA, challenge }); // verifies the Service, → send PROVE
659
+ * const { sigA, handshakeId } = await hs.prove({ nonceA, challenge }); // verifies the Service, → send PROVE
518
660
  * `prove` throws HandshakeFailed if the Service's CHALLENGE does not verify.
519
661
  */
520
662
  function handshake() {
@@ -523,7 +665,7 @@ export function createGuard(opts = {}) {
523
665
  const nonceA = crypto.randomUUID();
524
666
  return { nonceA, message: { fromDid: agentDid, nonceA, protoVersion: '1.0' } };
525
667
  },
526
- prove({ nonceA, challenge } = {}) {
668
+ async prove({ nonceA, challenge } = {}) {
527
669
  const { toDid, nonceB, sigB, handshakeId } = challenge ?? {};
528
670
  if (!toDid || !nonceB || !sigB) throw new Error('malformed CHALLENGE');
529
671
  if (!verifyDidSignature(toDid, nonceA, sigB)) {
@@ -531,7 +673,7 @@ export function createGuard(opts = {}) {
531
673
  e.name = 'HandshakeFailed';
532
674
  throw e;
533
675
  }
534
- return { handshakeId, sigA: sign(nonceB), remoteDid: toDid };
676
+ return { handshakeId, sigA: await keyProvider.signHandshakeNonce(nonceB), remoteDid: toDid };
535
677
  },
536
678
  };
537
679
  }
@@ -700,7 +842,7 @@ export function createGuard(opts = {}) {
700
842
  const res = await fetch(`${base}/agent-identity/${encodeURIComponent(ref)}/verify-key`, {
701
843
  method: 'POST',
702
844
  headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
703
- body: JSON.stringify({ signature: sign(challenge) }),
845
+ body: JSON.stringify({ signature: await keyProvider.signKeyControlChallenge(challenge) }),
704
846
  });
705
847
  const body = await res.json().catch(() => null);
706
848
  if (!res.ok) {
@@ -711,10 +853,10 @@ export function createGuard(opts = {}) {
711
853
  return body?.data ?? { verified: true };
712
854
  }
713
855
 
714
- /** Sign a BYOK challenge with the agent's key (hex) — for integrators who submit verify-key themselves. */
715
- function signChallenge(challenge) {
856
+ /** Sign a BYOK challenge with the agent's key — for integrators who submit verify-key themselves. */
857
+ async function signChallenge(challenge) {
716
858
  if (!challenge) throw new Error('signChallenge requires the challenge nonce');
717
- return sign(challenge);
859
+ return keyProvider.signKeyControlChallenge(challenge);
718
860
  }
719
861
 
720
862
  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.8.0",
3
+ "version": "0.12.1",
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
@@ -514,7 +514,15 @@ function escapeField(v) {
514
514
  return v.replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
515
515
  }
516
516
  function buildAuthMessage(f) {
517
- 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("|");
518
526
  }
519
527
 
520
528
  // src/policy-core/context.ts
@@ -573,6 +581,8 @@ export {
573
581
  asOperatingMode,
574
582
  authorityFailure,
575
583
  buildAuthMessage,
584
+ buildCheckpointAnchorMessage,
585
+ buildLocalDecisionMessage,
576
586
  canAuthorize,
577
587
  evaluate,
578
588
  evaluateBoundStandards,