@metamynd/agentsafe-mcp-guard 0.4.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,8 +35,8 @@ const guard = createMcpGuard({
35
35
  });
36
36
 
37
37
  // Responder side, over your HTTP transport:
38
- const challenge = guard.handshakeChallenge(hello); // POST /magp/handshake (HELLO → CHALLENGE)
39
- const ready = guard.handshakeVerify(prove); // POST /magp/handshake (PROVE → READY, or throws)
38
+ const challenge = await guard.handshakeChallenge(hello); // POST /magp/handshake (HELLO → CHALLENGE)
39
+ const ready = guard.handshakeVerify(prove); // POST /magp/handshake (PROVE → READY, or throws)
40
40
  ```
41
41
 
42
42
  The agent drives the initiator side with `createGuard(...).handshake()` from `agentsafe-guard`.
@@ -110,6 +110,55 @@ $5,000" protection never engaged, verifier configured or not. New `requireCapabi
110
110
  an omitted capability a hard block (`CAPABILITY_REQUIRED`) instead of a silent pass-through. Both
111
111
  off by default — existing embeds are unchanged.
112
112
 
113
+ **0.5.0 — the `keyProvider` seam
114
+ ([docs/design/agent-key-custody-local-signer-daemon-plan.md](../../docs/design/agent-key-custody-local-signer-daemon-plan.md)).**
115
+ `serviceKey` no longer has to be a raw hex key living in this process. Pass
116
+ `keyProvider: 'daemon'` + `daemonSocketPath` instead, and `handshakeChallenge` gets its signature
117
+ from a separate `@metamynd/agentsafe-signer` daemon (`role: 'service'`) over a local socket — the
118
+ key never enters this process at all. `serviceKey` (unchanged) still works exactly as before and
119
+ remains the default. **Breaking, disclosed plainly**: `handshakeChallenge` and
120
+ `createHandshakeInitiator(...).prove()` are now `async` (a daemon-backed provider needs a socket
121
+ round trip); every caller needs an `await` added. New internal module `key-providers.mjs` — still
122
+ zero external dependencies.
123
+
124
+ **0.5.3 — `requireAuthorization`'s claim now actually enforces revoked authority and mandate
125
+ expiry, and the claim-binding check closes the action axis too.** A four-client readiness review's
126
+ P0 finding required a tested counterparty-verification pattern covering, by name, replay, wrong
127
+ identity, wrong action, expired mandate, and revoked authority. Building the end-to-end trial
128
+ against a real backend (not the mocked cases below) surfaced that two of those five were silently
129
+ unenforced at the claim step itself, not just untested:
130
+
131
+ - **Revoked authority.** Revoking a mandate (`POST /mandate/:ref/revoke`) only ever flipped the
132
+ hold's `mandate_event.status` to `voided` — it never touched the `effect_transition` chain, and
133
+ `markEffect`'s transition check only validated the state-machine shape (`authorized →
134
+ dispatching` is always syntactically legal), never the hold's own status. A resource relying on
135
+ `requireAuthorization` could successfully claim and execute a hold whose mandate had already been
136
+ revoked. Now refused with `AUTHORIZATION_VOIDED`.
137
+ - **Expired mandate.** A hold's expiry (`isWithinHoldWindow`/the TTL) is a *derived* property —
138
+ nothing writes it back to the row when time passes, so the same claim step had no way to see a
139
+ hold was stale. Now refused with `AUTHORIZATION_EXPIRED`.
140
+ - **Wrong action.** `claimAuthorization()`'s mismatch checks compared the claimed hold's
141
+ `agentDid`/`amount`/`currency`/`merchant` against the request being executed, but never `action`
142
+ — because the backend's claim response never carried it. A real, unclaimed authorization minted
143
+ for one action (e.g. `office-supplies-purchase`) could be claimed while executing a *different*
144
+ action at the identical agent/amount/currency/merchant — the same confused-deputy shape the 0.3.6
145
+ merchant check closed, one axis short. The backend now resolves and returns the hold's authorized
146
+ `action` on claim (backward-compatible — an older backend simply omits it, same
147
+ skip-when-absent tolerance as every other field here). Now refused with
148
+ `AUTHORIZATION_ACTION_MISMATCH`.
149
+
150
+ All three are backend-side (`MandateService.markEffect`) except the action check, which also
151
+ needed this guard to compare the new field. See
152
+ [`docs/design/mcp-reverification-quickstart.md`](../../docs/design/mcp-reverification-quickstart.md) (§7)
153
+ for the end-to-end trial these gaps were found and closed against.
154
+
155
+ **0.5.1 — `keyProvider: 'daemon'` retries a transient connect failure.** Same fix as
156
+ `@metamynd/agentsafe-guard` 0.9.1: on Windows the signer daemon's socket is a pool of independent
157
+ named-pipe instances, each consumed by one connection and replaced asynchronously, so two
158
+ signing requests close together could race that replacement window and fail with
159
+ `DAEMON_UNREACHABLE` even though the daemon was healthy. `key-providers.mjs` now retries a
160
+ connection that fails with `ENOENT` for up to 3 seconds before giving up. No API change.
161
+
113
162
  ### Replay, cumulative spend, rate limits, breakers, spend anomalies (`requireAuthorization`)
114
163
 
115
164
  Re-evaluating policy per request (above) proves the request is well-formed and in-policy — it
@@ -16,6 +16,7 @@ import { evaluate, buildAuthMessage, applySignedLast, operatingModeGate } from '
16
16
  import { verifyDidSignature } from './magp-did.mjs';
17
17
  import { buildPaymentRequirements, checkSettlementBinding } from './x402.mjs';
18
18
  import { verifyBundle } from './magp-policy.mjs';
19
+ import { resolveKeyProvider } from './key-providers.mjs';
19
20
 
20
21
  /** Freshness window for signed requests and handshake nonces (spec §7.7). How far `issuedAt`
21
22
  * may be BEHIND server time — network/processing delay. */
@@ -57,19 +58,15 @@ const CLOCK_SKEW_TOLERANCE_MS = 30 * 1000;
57
58
  * integrator's un-capability-aware callers must keep working); set true on any Service where
58
59
  * capability binding is meant to be mandatory, not opt-in.
59
60
  */
60
- export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle, policyPublicKey, settlementStore, verifyCapability, requireAuthorization = false, requireCapability = false } = {}) {
61
+ export function createMcpGuard({ serviceDid, serviceKey, keyProvider: keyProviderOpt, daemonSocketPath, issuerApi, fetchBundle, policyPublicKey, settlementStore, verifyCapability, requireAuthorization = false, requireCapability = false } = {}) {
61
62
  if (!serviceDid) throw new Error('createMcpGuard requires { serviceDid }');
62
63
  const base = issuerApi ? issuerApi.replace(/\/$/, '') : null;
63
- const privateKey = serviceKey
64
- ? crypto.createPrivateKey({ key: Buffer.from(serviceKey, 'hex'), format: 'der', type: 'pkcs8' })
65
- : null;
64
+ // keyProvider seam (docs/design/agent-key-custody-local-signer-daemon-plan.md): null when
65
+ // neither `serviceKey` nor `keyProvider` is configured handshakeChallenge throws its own
66
+ // clear error only if actually called, matching the original lazy-throw behavior exactly.
67
+ const keyProvider = resolveKeyProvider({ keyProvider: keyProviderOpt, serviceKey, daemonSocketPath });
66
68
  const pending = new Map(); // handshakeId -> { fromDid, nonceB, expiresAt }
67
69
 
68
- function sign(message) {
69
- if (!privateKey) throw new Error('serviceKey is required to sign handshake messages');
70
- return crypto.sign(null, Buffer.from(message, 'utf8'), privateKey).toString('hex');
71
- }
72
-
73
70
  // --- Mutual handshake, RESPONDER side (spec §8.2) ---
74
71
  // A → B HELLO { fromDid, nonceA }
75
72
  // B → A CHALLENGE { toDid, nonceB, sigB(nonceA) } ← proves B controls toDid
@@ -77,12 +74,13 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
77
74
  // B → A READY { channelId }
78
75
 
79
76
  /** Step 1 (B): on HELLO, sign nonceA to prove control of serviceDid, issue nonceB. */
80
- function handshakeChallenge({ fromDid, nonceA, protoVersion } = {}) {
77
+ async function handshakeChallenge({ fromDid, nonceA, protoVersion } = {}) {
81
78
  if (!fromDid || !nonceA) throw new Error('HELLO requires { fromDid, nonceA }');
79
+ if (!keyProvider) throw new Error('serviceKey (or keyProvider) is required to sign handshake messages');
82
80
  const handshakeId = crypto.randomUUID();
83
81
  const nonceB = crypto.randomUUID();
84
82
  pending.set(handshakeId, { fromDid, nonceB, expiresAt: Date.now() + FRESHNESS_MS });
85
- return { handshakeId, toDid: serviceDid, nonceB, sigB: sign(nonceA), protoVersion: protoVersion ?? '1.0' };
83
+ return { handshakeId, toDid: serviceDid, nonceB, sigB: await keyProvider.signHandshakeNonce(nonceA), protoVersion: protoVersion ?? '1.0' };
86
84
  }
87
85
 
88
86
  /** Step 2 (B): on PROVE, verify sigA over nonceB against fromDid's key-in-DID. */
@@ -110,12 +108,12 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
110
108
  * against the stateful issuer gate already checked both (agentsafe-guard.mjs's authorizeLocal()
111
109
  * seals any value-bearing action through the real remote authorize() by default).
112
110
  *
113
- * On success, also returns the hold's OWN bound `agentDid`/`amount`/`currency`/`merchant` — the
114
- * caller MUST compare these to the request actually being executed. A claim alone only proves
115
- * "some real, unclaimed authorization exists"; without this check, a legitimately-obtained
116
- * authorization for a small, honest transaction could be presented to unlock a completely
117
- * different one the same confused-deputy shape payload binding closes at the request layer,
118
- * recurring one layer deeper.
111
+ * On success, also returns the hold's OWN bound `agentDid`/`action`/`amount`/`currency`/
112
+ * `merchant` — the caller MUST compare these to the request actually being executed. A claim
113
+ * alone only proves "some real, unclaimed authorization exists"; without this check, a
114
+ * legitimately-obtained authorization for a small, honest transaction (or a DIFFERENT action
115
+ * entirely) could be presented to unlock a completely different one the same confused-deputy
116
+ * shape payload binding closes at the request layer, recurring one layer deeper.
119
117
  */
120
118
  async function claimAuthorization({ authorizationId } = {}) {
121
119
  if (!authorizationId) return { claimed: false, reasonCode: 'AUTHORIZATION_REQUIRED' };
@@ -124,7 +122,14 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
124
122
  const res = await fetch(`${base}/policy/mandate/authorize/${encodeURIComponent(authorizationId)}/effect/dispatching`, { method: 'POST' });
125
123
  const body = await res.json().catch(() => null);
126
124
  if (!res.ok) return { claimed: false, reasonCode: body?.message ?? body?.data?.reasonCode ?? `AUTHORIZATION_CLAIM_HTTP_${res.status}` };
127
- return { claimed: true, agentDid: body?.data?.agentDid, amount: body?.data?.amount, currency: body?.data?.currency, merchant: body?.data?.merchant };
125
+ return {
126
+ claimed: true,
127
+ agentDid: body?.data?.agentDid,
128
+ action: body?.data?.action,
129
+ amount: body?.data?.amount,
130
+ currency: body?.data?.currency,
131
+ merchant: body?.data?.merchant,
132
+ };
128
133
  } catch (err) {
129
134
  return { claimed: false, reasonCode: 'AUTHORIZATION_CLAIM_UNREACHABLE', error: String(err?.message ?? err) };
130
135
  }
@@ -147,13 +152,28 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
147
152
 
148
153
  /** Evaluate the agent's bundle against the request via policy-core (signed fields last). */
149
154
  function verdictFromBundle(bundle, req) {
150
- const { agentDid, action, amount = 0, currency = 'USD', merchant = '', itinerary = {}, cumulativeSpend = amount, now } = req;
151
- const mandate = (bundle.mandates ?? []).find((m) => m.action === action)?.document;
155
+ const { agentDid, action, amount = 0, currency = 'USD', merchant = '', resource = null, itinerary = {}, cumulativeSpend = amount, now } = req;
156
+ const mandates = bundle.mandates ?? [];
157
+ const mandate = mandates.find((m) => m.action === action)?.document;
158
+ // No mandate covers this action at all — refuse outright, matching mandate.service.ts's
159
+ // own first check (before signature/standards/anything else). Without this, `evaluate()`
160
+ // treats an omitted `mandate` as "skip the mandate layer" (its own documented, intentional
161
+ // behavior for a caller that never resolves one at all) — found live: an ungranted action
162
+ // with no Standard/SOP molecule happening to also catch it was silently ALLOWED here,
163
+ // while the hosted gate and the agent SDK both correctly refused the identical request.
164
+ if (!mandate) {
165
+ return { decision: 'block', reasonCode: mandates.length > 0 ? 'NO_PERMISSION_FOR_ACTION' : 'NO_MANDATE', authorizationId: null, remaining: null, proofRef: null };
166
+ }
152
167
  return evaluate({
153
168
  standards: (bundle.standards ?? []).map((s) => ({ standardKey: s.key, document: s.document })),
154
169
  sops: (bundle.sops ?? []).map((s) => ({ standardKey: `sop:${s.id}`, document: s.document })),
155
170
  mandate,
156
- context: applySignedLast(itinerary, { action, agentDid, amount }),
171
+ // currency/merchant/resource are signed fields, same as action/agentDid/amount above —
172
+ // omitting them here (found live: they were) means a currency-scoped amount-over/
173
+ // cumulative-over Standards/SOP atom always sees currency as absent and fires closed
174
+ // (SOP_SPEND_CAP on a genuinely in-cap request), and a resource-scope atom never runs
175
+ // at all. Mirrors mandate.service.ts's ruleCtx (PR #588), the parity target for this.
176
+ context: applySignedLast(itinerary, { action, agentDid, amount, currency, merchant, resource }),
157
177
  mandateRequest: mandate
158
178
  ? {
159
179
  target: action,
@@ -167,6 +187,9 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
167
187
  // — omitting this would make EVERY unit-bearing cap fail regardless of amount.
168
188
  // Defaults to 'USD', matching verifyRequest()'s own default for this field.
169
189
  'mm:currency': currency,
190
+ // Unprefixed `resource` (not `mm:resource`) to match the constraint's own
191
+ // leftOperand (ResourceService.scopeConstraint()) — mirrors mandate.service.ts.
192
+ resource,
170
193
  }),
171
194
  }
172
195
  : undefined,
@@ -177,17 +200,19 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
177
200
  * Verify an agent's presented signed authorize request, then re-evaluate policy
178
201
  * locally against the agent's issuer-hosted bundle. Fails CLOSED: any bad
179
202
  * signature, staleness, fetch error, or evaluation error returns a block.
180
- * @param {{agentDid,action,amount?,currency?,merchant?,itinerary?,nonce,issuedAt,signature}} signed
203
+ * @param {{agentDid,action,amount?,currency?,merchant?,resource?,itinerary?,nonce,issuedAt,signature}} signed
181
204
  * @returns {Promise<{decision:'allow'|'observe'|'block'|'escalate'|'suspend'|'quarantine',reasonCode:string|null}>}
182
205
  */
183
206
  async function verifyRequest(signed = {}) {
184
207
  try {
185
- const { agentDid, action, amount = 0, currency = 'USD', merchant = '', nonce, issuedAt, signature } = signed;
208
+ const { agentDid, action, amount = 0, currency = 'USD', merchant = '', resource = null, nonce, issuedAt, signature } = signed;
186
209
  if (!agentDid || !action || !nonce || !issuedAt || !signature) {
187
210
  return { decision: 'block', reasonCode: 'MALFORMED_REQUEST' };
188
211
  }
189
212
  // 1. Signature over the canonical message (§7.3), verified via key-in-DID (§4.1.2).
190
- const message = buildAuthMessage({ agentDid, action, amount, currency, merchant, nonce, issuedAt });
213
+ // `resource` MUST be included it's the 8th signed field (canonical.ts); omitting it
214
+ // here (found live: it was) rejects every genuinely-valid resource-bearing signature.
215
+ const message = buildAuthMessage({ agentDid, action, amount, currency, merchant, resource, nonce, issuedAt });
191
216
  if (!verifyDidSignature(agentDid, message, signature)) {
192
217
  return { decision: 'block', reasonCode: 'SIGNATURE_INVALID' };
193
218
  }
@@ -253,10 +278,12 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
253
278
  // re-open the confused-deputy gap this claim exists to close, and nothing else here
254
279
  // would notice.
255
280
  if (claim.agentDid === undefined) console.warn('[mcp-guard] claim response omitted agentDid — binding degraded to "some valid unclaimed authorization exists"');
281
+ if (claim.action === undefined) console.warn('[mcp-guard] claim response omitted action — action binding degraded');
256
282
  if (Number(amount) > 0 && claim.amount === undefined) console.warn('[mcp-guard] claim response omitted amount for a value-bearing request — amount binding degraded');
257
283
  if (Number(amount) > 0 && claim.currency === undefined) console.warn('[mcp-guard] claim response omitted currency for a value-bearing request — currency binding degraded');
258
284
  if (merchant && claim.merchant === undefined) console.warn('[mcp-guard] claim response omitted merchant for a request that signed one — merchant binding degraded');
259
285
  if (claim.agentDid !== undefined && claim.agentDid !== agentDid) return { decision: 'block', reasonCode: 'AUTHORIZATION_AGENT_MISMATCH' };
286
+ if (claim.action !== undefined && claim.action !== action) return { decision: 'block', reasonCode: 'AUTHORIZATION_ACTION_MISMATCH' };
260
287
  if (claim.amount !== undefined && Number(claim.amount) !== Number(amount)) return { decision: 'block', reasonCode: 'AUTHORIZATION_AMOUNT_MISMATCH' };
261
288
  if (claim.currency !== undefined && claim.currency !== currency) return { decision: 'block', reasonCode: 'AUTHORIZATION_CURRENCY_MISMATCH' };
262
289
  if (claim.merchant !== undefined && claim.merchant !== merchant) return { decision: 'block', reasonCode: 'AUTHORIZATION_MERCHANT_MISMATCH' };
@@ -274,7 +301,17 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
274
301
  */
275
302
  function guardIncomingTool(action, handler) {
276
303
  return async (signed, ...rest) => {
277
- const decision = await verifyRequest({ ...signed, action: signed?.action ?? action });
304
+ // The WRAPPED TOOL's own `action` is authoritative — never `signed?.action` (the caller's
305
+ // own claim). A Service that wraps more than one tool with ONE guard instance (the normal
306
+ // MCP-server shape: many tools, one guard) previously let a genuinely-valid signature for
307
+ // action A verify successfully — correctly, it really was valid for A — and then run
308
+ // action B's handler, because verifyRequest was asked to check whatever the SIGNED payload
309
+ // claimed instead of which wrapped function was actually being invoked. A caller with a
310
+ // real, cheap, in-policy authorization (e.g. a $0 read) could invoke ANY other tool sharing
311
+ // this guard (e.g. a wire transfer) and have it execute under that unrelated verification.
312
+ // Mirrors gateway.mjs's `route.action ?? signed.action` — "the route pins the action ...
313
+ // the client can't pick it" — for exactly the same reason, one layer down at the tool call.
314
+ const decision = await verifyRequest({ ...signed, action });
278
315
  // allow/observe both PERMIT the tool call; observe is permit-but-flag (SAFR §11).
279
316
  if (decision.decision !== 'allow' && decision.decision !== 'observe') {
280
317
  const err = new Error(`MCP guard ${decision.decision.toUpperCase()} "${action}": ${decision.reasonCode}`);
@@ -389,7 +426,9 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
389
426
  * then, given the responder's CHALLENGE, verifies the responder proved control of
390
427
  * its DID before producing PROVE.
391
428
  *
392
- * @param {{fromDid:string, sign:(msg:string)=>string}} p sign() uses the initiator's own key
429
+ * @param {{fromDid:string, sign:(msg:string)=>(string|Promise<string>)}} p sign() uses the
430
+ * initiator's own key — may be sync (a raw local key) or async (e.g. a keyProvider backed by
431
+ * agentsafe-signer); `prove()` awaits it either way, see key-providers.mjs.
393
432
  */
394
433
  export function createHandshakeInitiator({ fromDid, sign } = {}) {
395
434
  if (!fromDid || typeof sign !== 'function') throw new Error('createHandshakeInitiator requires { fromDid, sign }');
@@ -400,7 +439,7 @@ export function createHandshakeInitiator({ fromDid, sign } = {}) {
400
439
  return { nonceA, message: { fromDid, nonceA, protoVersion: '1.0' } };
401
440
  },
402
441
  /** Step 3 (A): verify CHALLENGE proves the responder controls toDid, then PROVE. */
403
- prove({ nonceA, challenge } = {}) {
442
+ async prove({ nonceA, challenge } = {}) {
404
443
  const { toDid, nonceB, sigB, handshakeId } = challenge ?? {};
405
444
  if (!toDid || !nonceB || !sigB) throw new Error('malformed CHALLENGE');
406
445
  if (!verifyDidSignature(toDid, nonceA, sigB)) {
@@ -408,7 +447,7 @@ export function createHandshakeInitiator({ fromDid, sign } = {}) {
408
447
  e.name = 'HandshakeFailed';
409
448
  throw e;
410
449
  }
411
- return { handshakeId, sigA: sign(nonceB), remoteDid: toDid };
450
+ return { handshakeId, sigA: await sign(nonceB), remoteDid: toDid };
412
451
  },
413
452
  };
414
453
  }
@@ -0,0 +1,91 @@
1
+ // key-providers.mjs — the keyProvider seam for the SERVICE side (docs/design/
2
+ // agent-key-custody-local-signer-daemon-plan.md). Much smaller than agentsafe-guard's own copy:
3
+ // serviceKey is only ever used for one thing (signing a handshake nonce, MAGP §8.2), so there is
4
+ // only one provider method here, not four.
5
+ import crypto from 'node:crypto';
6
+ import net from 'node:net';
7
+ import path from 'node:path';
8
+
9
+ export function createStaticKeyProvider(serviceKeyHex) {
10
+ const privateKey = crypto.createPrivateKey({ key: Buffer.from(serviceKeyHex, 'hex'), format: 'der', type: 'pkcs8' });
11
+ return {
12
+ async signHandshakeNonce(nonce) {
13
+ return crypto.sign(null, Buffer.from(nonce, 'utf8'), privateKey).toString('hex');
14
+ },
15
+ };
16
+ }
17
+
18
+ /** Must match agentsafe-guard/key-providers.mjs's and agentsafe-signer/daemon.mjs's own copy — see either's own comment for why this isn't a shared import. */
19
+ function toPlatformSocketPath(logicalPath) {
20
+ if (process.platform !== 'win32') return logicalPath;
21
+ const name = crypto.createHash('sha256').update(path.resolve(logicalPath)).digest('hex').slice(0, 32);
22
+ return `\\\\.\\pipe\\agentsafe-signer-${name}`;
23
+ }
24
+
25
+ const PROTOCOL_VERSION = 1;
26
+
27
+ function daemonRequest(socketPath, op, params, { connectTimeoutMs = 3000 } = {}) {
28
+ return new Promise((resolve, reject) => {
29
+ const deadline = Date.now() + connectTimeoutMs;
30
+ function attempt() {
31
+ const sock = net.connect(toPlatformSocketPath(socketPath));
32
+ const requestId = crypto.randomUUID();
33
+ let buf = '';
34
+ const cleanup = () => sock.destroy();
35
+ sock.once('error', (err) => {
36
+ cleanup();
37
+ // See agentsafe-guard/key-providers.mjs's own copy of this function for why: on Windows
38
+ // the signing socket is a pool of independent named-pipe instances, each consumed by one
39
+ // connection and replaced asynchronously, so a request can transiently race that
40
+ // replacement window (ENOENT) even though the daemon is healthy. Only ENOENT retries — a
41
+ // genuinely down daemon still fails fast.
42
+ if (err.code === 'ENOENT' && Date.now() < deadline) {
43
+ setTimeout(attempt, 20);
44
+ return;
45
+ }
46
+ reject(Object.assign(new Error(`agentsafe-signer daemon unreachable at ${socketPath}: ${err.message}`), { code: 'DAEMON_UNREACHABLE' }));
47
+ });
48
+ sock.once('connect', () => {
49
+ sock.write(JSON.stringify({ protocolVersion: PROTOCOL_VERSION, requestId, op, params }) + '\n');
50
+ });
51
+ sock.on('data', (chunk) => {
52
+ buf += chunk.toString('utf8');
53
+ const idx = buf.indexOf('\n');
54
+ if (idx === -1) return;
55
+ let res;
56
+ try {
57
+ res = JSON.parse(buf.slice(0, idx));
58
+ } catch (err) {
59
+ cleanup();
60
+ reject(err);
61
+ return;
62
+ }
63
+ cleanup();
64
+ if (res.ok) resolve(res.result);
65
+ else reject(Object.assign(new Error(res.error?.message || res.error?.code || 'daemon rejected request'), { code: res.error?.code }));
66
+ });
67
+ }
68
+ attempt();
69
+ });
70
+ }
71
+
72
+ /** The key never enters this process — a service-role agentsafe-signer daemon signs instead. */
73
+ export function createDaemonKeyProvider({ socketPath }) {
74
+ if (!socketPath) throw new Error('createDaemonKeyProvider requires { socketPath }');
75
+ return {
76
+ async signHandshakeNonce(nonce) {
77
+ const { signature } = await daemonRequest(socketPath, 'sign-handshake-nonce', { nonce });
78
+ return signature;
79
+ },
80
+ };
81
+ }
82
+
83
+ /** Resolves a keyProvider from createMcpGuard's opts — null (not thrown) when neither
84
+ * keyProvider nor serviceKey is configured, matching today's "handshakeChallenge throws only if
85
+ * actually called without one" behavior rather than failing construction eagerly. */
86
+ export function resolveKeyProvider({ keyProvider, serviceKey, daemonSocketPath } = {}) {
87
+ if (keyProvider && typeof keyProvider === 'object' && typeof keyProvider.signHandshakeNonce === 'function') return keyProvider;
88
+ if (keyProvider === 'daemon') return createDaemonKeyProvider({ socketPath: daemonSocketPath });
89
+ if (!serviceKey) return null;
90
+ return createStaticKeyProvider(serviceKey);
91
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metamynd/agentsafe-mcp-guard",
3
- "version": "0.4.0",
3
+ "version": "0.6.2",
4
4
  "description": "Zero-dependency trustless governance for the SERVICE side. An MCP server or API re-verifies a calling agent's signed request against the agent's own published policy \u00e2\u20ac\u201d so an agent that ignores its own guard still cannot make your service act.",
5
5
  "type": "module",
6
6
  "main": "./agentsafe-mcp-guard.mjs",
@@ -15,6 +15,7 @@
15
15
  },
16
16
  "files": [
17
17
  "agentsafe-mcp-guard.mjs",
18
+ "key-providers.mjs",
18
19
  "magp-policy.mjs",
19
20
  "magp-did.mjs",
20
21
  "policy-core.mjs",
@@ -23,7 +24,7 @@
23
24
  "LICENSE"
24
25
  ],
25
26
  "scripts": {
26
- "test": "node mcp-guard.smoke.mjs && node pay.smoke.mjs && node decision-token.smoke.mjs && node claim-authorization.smoke.mjs"
27
+ "test": "node mcp-guard.smoke.mjs && node pay.smoke.mjs && node decision-token.smoke.mjs && node claim-authorization.smoke.mjs && node guard-incoming-tool.smoke.mjs"
27
28
  },
28
29
  "engines": {
29
30
  "node": ">=18"
package/policy-core.mjs CHANGED
@@ -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,