@muhaven/mcp 0.2.1 → 0.2.3

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/dist/index.d.cts CHANGED
@@ -9,14 +9,52 @@ import { z } from 'zod';
9
9
  * (Windows). Each request is a single JSON object; each response is a
10
10
  * single JSON object. No request pipelining, no streaming.
11
11
  *
12
- * **Protocol version 0.3.0** — additive bump from 0.2.0 in @muhaven/mcp@0.1.3
13
- * to add `hello.hasSessionKey` + `hello.effectiveConfig` (so a daemon
14
- * booted without `MUHAVEN_BROKER_SESSION_KEY` can serve read paths AND
15
- * surface its effective backend/dashboard URLs to `muhaven-broker login
16
- * --from-daemon`). The 0.2.0 bump from 0.1.0 (Wave 4 P3 ADR-3) added the
17
- * `store_jwt` / `get_jwt` / `clear_jwt` triple — the broker is the single
18
- * keeper of the device-flow JWT (per ADR-3 D1 "polling, not loopback
19
- * callback") in addition to the session-key private half.
12
+ * **Protocol version 0.4.0** — additive bump from 0.3.0 for Wave 5
13
+ * Path D Slice 1. Adds the policy-snapshot subsystem so the broker can
14
+ * enforce scope + per-op spend cap BEFORE signing a UserOp:
15
+ * - `sign_userop` like `sign_hash` but carries the structured inner
16
+ * call (target + callData) so the broker validates against the active
17
+ * policy snapshot before delegating to the signer. The MCP server
18
+ * computes the UserOp hash from the prepared UserOp; the broker
19
+ * signs it once policy passes.
20
+ * - `store_policy_snapshot` / `get_policy_snapshot` /
21
+ * `clear_policy_snapshot` — per-session policy CRUD. Snapshot
22
+ * persists across daemon restarts (file-backed under
23
+ * `~/.muhaven/policy-snapshots/<sessionId>.json`).
24
+ * - `get_active_session_id` — narrow "which session is live?" probe used
25
+ * by the MCP server to bootstrap Path D without needing the operator
26
+ * to thread the sessionId through env vars. Returns the sessionId of
27
+ * the SINGLE non-expired snapshot whose `signerAddress` matches the
28
+ * broker's loaded signer; returns `null` when zero match OR when
29
+ * multiple non-expired snapshots match (ambiguous case — caller falls
30
+ * back to Path C). Intentionally narrower than `list()` so RD-3 stays
31
+ * honoured (list() is daemon-internal only).
32
+ * Also adds error codes `rate_limited`, `max_spend_exceeded`,
33
+ * `policy_violation`, `scope_violation`, `no_active_snapshot`. Rate
34
+ * limiting is enforced in Slice 5; the enum value ships now so
35
+ * future-additive protocol bumps don't churn the codeset again.
36
+ *
37
+ * Trust model for `sign_userop`: per-selector, the broker decodes the
38
+ * uint256 at the snapshot-declared `capArgIndex` of `innerCall.callData`
39
+ * and enforces it against the matched `selectorCaps[i].maxAmount`. The
40
+ * broker does NOT re-compute the UserOp hash from packed fields (that
41
+ * would require entry-point + chain-id + packing knowledge inside the
42
+ * broker). The hash itself is trusted as supplied by the MCP server; the
43
+ * broker's job is to refuse signing when scope + per-op cap + signer
44
+ * binding don't match. The on-chain CallPolicy validator is the
45
+ * structural backstop for "what calldata can be executed at all" per
46
+ * RD-2 / RD-5 in `PATH_D_PLAN.md`. Slice 4 wildcard MUST graduate to
47
+ * canonical userOpHash reconstruction (RD-5).
48
+ *
49
+ * **Protocol version 0.3.0** (history) — additive bump from 0.2.0 in
50
+ * @muhaven/mcp@0.1.3 to add `hello.hasSessionKey` + `hello.effectiveConfig`
51
+ * (so a daemon booted without `MUHAVEN_BROKER_SESSION_KEY` can serve read
52
+ * paths AND surface its effective backend/dashboard URLs to
53
+ * `muhaven-broker login --from-daemon`). The 0.2.0 bump from 0.1.0 (Wave
54
+ * 4 P3 ADR-3) added the `store_jwt` / `get_jwt` / `clear_jwt` triple —
55
+ * the broker is the single keeper of the device-flow JWT (per ADR-3 D1
56
+ * "polling, not loopback callback") in addition to the session-key
57
+ * private half.
20
58
  *
21
59
  * @muhaven/mcp@0.1.5 added `hello.pid` (still protocol 0.3.0 — additive
22
60
  * optional field) so `muhaven-broker stop` can reach into the daemon
@@ -28,14 +66,16 @@ import { z } from 'zod';
28
66
  * - The broker NEVER reaches out to the network. It only:
29
67
  * (a) signs hashes that the MCP server received from the backend,
30
68
  * (b) stores / returns / clears a JWT that the MCP server
31
- * received from the backend.
69
+ * received from the backend,
70
+ * (c) stores / returns / clears per-session policy snapshots that
71
+ * the MCP server (or operator CLI) provides at mint time.
32
72
  * Splitting network egress (MCP server) from signing + secret
33
73
  * storage (broker) is the lethal-trifecta mitigation in
34
74
  * `THREAT_MODEL_P0.md` §"Lethal-trifecta self-audit".
35
75
  * - Requests are size-capped (`maxRequestBytes`) — a malformed peer
36
76
  * cannot exhaust broker memory by sending an unbounded JSON blob.
37
77
  */
38
- declare const BROKER_PROTOCOL_VERSION = "0.3.0";
78
+ declare const BROKER_PROTOCOL_VERSION = "0.4.0";
39
79
  interface BrokerHelloRequest {
40
80
  readonly type: 'hello';
41
81
  }
@@ -63,6 +103,178 @@ interface BrokerGetJwtRequest {
63
103
  interface BrokerClearJwtRequest {
64
104
  readonly type: 'clear_jwt';
65
105
  }
106
+ /**
107
+ * Wave 5 Path D Slice 1 — request a policy-gated UserOp signature. The
108
+ * MCP server has computed `userOpHash` from a prepared UserOp; the broker
109
+ * looks up the snapshot for `sessionId`, validates `innerCall` against
110
+ * the snapshot's allowlist + per-op cap, signs `userOpHash`, and returns
111
+ * the signature. See protocol-version JSDoc above for the trust model.
112
+ */
113
+ interface BrokerSignUserOpRequest {
114
+ readonly type: 'sign_userop';
115
+ /** Snapshot key — must match a snapshot previously stored via
116
+ * `store_policy_snapshot`. */
117
+ readonly sessionId: string;
118
+ /** EIP-4337 v0.7 userOpHash, 0x-prefixed 32-byte hex. Signed as-is. */
119
+ readonly userOpHash: `0x${string}`;
120
+ /** Structured representation of the kernel-inner call the prepared
121
+ * UserOp will execute. Broker uses this to validate scope + per-op
122
+ * cap. The MCP server is responsible for ensuring this matches what
123
+ * the on-chain executor will see. */
124
+ readonly innerCall: {
125
+ /** 0x-prefixed 20-byte hex (lowercased for compare-stability). */
126
+ readonly target: `0x${string}`;
127
+ /** Encoded selector + args. Selector = bytes 0..3; ABI words at
128
+ * bytes 4..35, 36..67, ... read by `decodeUint256ArgAt(callData,
129
+ * wordIndex)` per the matched `selectorCaps[i].capArgIndex`. */
130
+ readonly callData: `0x${string}`;
131
+ };
132
+ /** Free-form context for the audit log. NOT trusted as policy input. */
133
+ readonly intent?: {
134
+ readonly tool: string;
135
+ readonly summary?: string;
136
+ };
137
+ }
138
+ /**
139
+ * Wave 5 Path D Slice 1 — write a per-session policy snapshot. The MCP
140
+ * server (or `muhaven-broker set-policy` CLI in Slice 3) calls this
141
+ * after the frontend mint ceremony to give the broker the rules it
142
+ * should enforce when `sign_userop` arrives.
143
+ */
144
+ interface BrokerStorePolicySnapshotRequest {
145
+ readonly type: 'store_policy_snapshot';
146
+ readonly snapshot: PolicySnapshotWire;
147
+ }
148
+ interface BrokerGetPolicySnapshotRequest {
149
+ readonly type: 'get_policy_snapshot';
150
+ readonly sessionId: string;
151
+ }
152
+ interface BrokerClearPolicySnapshotRequest {
153
+ readonly type: 'clear_policy_snapshot';
154
+ readonly sessionId: string;
155
+ }
156
+ /**
157
+ * Wave 5 Path D Slice 1 (Commit 3) — narrow "active session" probe. No
158
+ * arguments; the broker resolves uniqueness against its loaded signer's
159
+ * address. The MCP server uses this to bootstrap Path D's broker-side
160
+ * signing path before Slice 2's backend-mirror `agent_scoped_sessions`
161
+ * table lands. Distinct from `get_policy_snapshot(sessionId)`: that
162
+ * fetches a known-id snapshot; this discovers the active id when there
163
+ * is exactly one.
164
+ *
165
+ * Returns `sessionId: null` (rather than an error) for both the "no
166
+ * active snapshot" AND "ambiguous, multiple match" cases — callers must
167
+ * treat them identically (fall back to Path C). Enumerating expired
168
+ * snapshots OR snapshots for other signers is daemon-internal; never
169
+ * surfaced over IPC.
170
+ */
171
+ interface BrokerGetActiveSessionIdRequest {
172
+ readonly type: 'get_active_session_id';
173
+ }
174
+ /**
175
+ * Per-selector enforcement rule. The broker matches `innerCall`'s
176
+ * selector against `selector`, then — if `capArgIndex` is not null —
177
+ * decodes the 32-byte word at that index after the 4-byte selector and
178
+ * compares to `maxAmount` (≤ semantics).
179
+ *
180
+ * `capArgIndex` is the 0-based word index INTO THE ABI-ENCODED ARG TAIL.
181
+ * For `subscription.purchase(address token, InEuint128 encShares,
182
+ * uint128 maxSharesHint, address ephemeralEOA)`, the layout is:
183
+ * word 0 (bytes 4..35): address token (left-zero-padded to 32 bytes)
184
+ * word 1 (bytes 36..67): InEuint128 dynamic-offset (FHE-encrypted tail)
185
+ * word 2 (bytes 68..99): uint128 maxSharesHint (left-zero-padded to 32 bytes per Solidity ABI v1; value in the low 16 bytes)
186
+ * word 3 (bytes 100..131): address ephemeralEOA (left-zero-padded)
187
+ * So `capArgIndex: 2` caps `maxSharesHint` — the plaintext upper bound
188
+ * on the encrypted share amount. The actual `encShares` is FHE-encrypted
189
+ * and the broker cannot decrypt it; `maxSharesHint` is the structural
190
+ * ceiling the broker enforces.
191
+ *
192
+ * `capArgIndex: null` means "selector is allowed, no arg-cap enforced"
193
+ * (intended for nullary-value selectors like `claim()` in future slices).
194
+ * `maxAmount` MUST be null when `capArgIndex` is null, and a uint256
195
+ * decimal string otherwise.
196
+ *
197
+ * **Static-arg assumption (Slice 1):** the broker assumes the calldata
198
+ * is ABI-encoded with no dynamic-type args at-or-before `capArgIndex`.
199
+ * For dynamic-arg targets (`bytes`, `string`, dynamic struct head), the
200
+ * 32-byte slot at the cap offset would be an OFFSET, not the value, and
201
+ * the cap would trivially pass on a tiny "offset" value while the real
202
+ * value sits in the dynamic tail. Slice 4 must NOT add such targets
203
+ * without a selector-aware ABI decoder. (`subscription.purchase` is safe
204
+ * — `InEuint128 calldata` is a dynamic struct, but `maxSharesHint` at
205
+ * word 2 is statically encoded after the dynamic-struct head-offset.)
206
+ */
207
+ interface PolicySelectorCap {
208
+ /** 0x-prefixed 4-byte hex (lowercased). */
209
+ readonly selector: `0x${string}`;
210
+ /** 0-based word index after the selector. Null = no arg-cap enforced. */
211
+ readonly capArgIndex: number | null;
212
+ /** uint256 decimal string. Null iff `capArgIndex` is null. Unit is
213
+ * whatever the target arg's base unit is (e.g. for purchase, uint128
214
+ * maxSharesHint → shares, not mhUSDC base-6). MCP server is
215
+ * responsible for converting user-intent units to the on-chain unit
216
+ * at snapshot-mint time. */
217
+ readonly maxAmount: string | null;
218
+ }
219
+ /**
220
+ * Wire shape of a policy snapshot. Strings (not bigints) so JSON
221
+ * round-trips cleanly.
222
+ *
223
+ * Per-selector caps are carried in `selectorCaps`. The set of allowed
224
+ * selectors is exactly `selectorCaps.map(c => c.selector)` — no
225
+ * `allowedSelectors` field, single source of truth. See
226
+ * `PolicySelectorCap` JSDoc for the static-arg-encoding assumption +
227
+ * `capArgIndex` semantics.
228
+ */
229
+ interface PolicySnapshotWire {
230
+ readonly sessionId: string;
231
+ readonly mode: 'scoped';
232
+ /** Address derived from the session-key private half. Broker compares
233
+ * this against its loaded `signer.address` at sign time; mismatch
234
+ * rejects with `policy_violation` to defend against snapshots minted
235
+ * for a rotated session-key being applied to the new key. */
236
+ readonly signerAddress: `0x${string}`;
237
+ /** Lowercased 0x-addresses that the broker will accept as
238
+ * `innerCall.target`. */
239
+ readonly targetContracts: readonly `0x${string}`[];
240
+ /** Per-selector rules. Selector must appear in this list AND
241
+ * `innerCall.target` must be in `targetContracts` for the selector
242
+ * match to authorize signing. */
243
+ readonly selectorCaps: readonly PolicySelectorCap[];
244
+ /** Epoch seconds. Snapshot is rejected on lookup after this time. */
245
+ readonly validUntilSec: number;
246
+ /** Epoch seconds at which the snapshot was created (audit). */
247
+ readonly mintedAtSec: number;
248
+ /** Optional Slice 1, REQUIRED Slice 4 wildcard. Hex-32 hash of the
249
+ * authorizing ConfirmToken's `actionHash` so the audit chain
250
+ * {userop → tier transition → consent token} is correlatable by
251
+ * stable key. Future-compat reserve per Trust Architect §4. */
252
+ readonly consentActionHash?: `0x${string}`;
253
+ /** Optional Slice 1, REQUIRED Slice 4 wildcard. Hex-32 hash of the
254
+ * consent text the user saw at mint time (Slice 4 gate #5). */
255
+ readonly consentTextSha256?: `0x${string}`;
256
+ /**
257
+ * Wave 5 Path D Slice 1 Commit 3.5 — `getPermissionId()` for the
258
+ * installed `@zerodev/permissions` PermissionValidator. 4-byte hex
259
+ * (`keccak256(policyAndSignerData).slice(0, 4)`). Used by the MCP
260
+ * server to compose the 24-byte nonce-key composite Kernel v3.1
261
+ * requires for UserOps routed through the permission validator
262
+ * (`pad(concat([VALIDATOR_MODE, VALIDATOR_TYPE.PERMISSION,
263
+ * identifier(20), customKey(2)]))`). Without this, the bundler reads
264
+ * the SUDO-validator nonce slot and the UserOp is routed through
265
+ * the wrong validator → `AA24 InvalidSigner`.
266
+ *
267
+ * **Optional in the wire shape** so the protocol stays back-compat
268
+ * with pre-Pickup-B mints (DB row + broker keystore snapshots
269
+ * predating the Pickup B commit `1a28618` may have NULL here).
270
+ * Pickup B's frontend mint flow ALWAYS populates this field; any
271
+ * non-Pickup-B client / legacy row that omits it surfaces as Path D
272
+ * fallback reason `no_permission_id_in_snapshot` and degrades
273
+ * cleanly to Path C. Broker daemon enforces no tighter today
274
+ * (additive optional field, no protocol version bump).
275
+ */
276
+ readonly permissionId?: `0x${string}`;
277
+ }
66
278
  interface BrokerHelloResponse {
67
279
  readonly type: 'hello';
68
280
  readonly version: string;
@@ -128,14 +340,45 @@ interface BrokerClearJwtResponse {
128
340
  readonly type: 'clear_jwt';
129
341
  readonly cleared: true;
130
342
  }
343
+ interface BrokerSignUserOpResponse {
344
+ readonly type: 'sign_userop';
345
+ /** 0x-prefixed 65-byte ECDSA signature (r || s || v). */
346
+ readonly signature: `0x${string}`;
347
+ readonly signerAddress: `0x${string}`;
348
+ readonly sessionId: string;
349
+ }
350
+ interface BrokerStorePolicySnapshotResponse {
351
+ readonly type: 'store_policy_snapshot';
352
+ readonly stored: true;
353
+ readonly sessionId: string;
354
+ }
355
+ interface BrokerGetPolicySnapshotResponse {
356
+ readonly type: 'get_policy_snapshot';
357
+ /** Null when no snapshot for the requested sessionId, OR when the
358
+ * snapshot exists but is past `validUntilSec` (caller treats both as
359
+ * "no active snapshot"). */
360
+ readonly snapshot: PolicySnapshotWire | null;
361
+ }
362
+ interface BrokerClearPolicySnapshotResponse {
363
+ readonly type: 'clear_policy_snapshot';
364
+ readonly cleared: true;
365
+ readonly sessionId: string;
366
+ }
367
+ interface BrokerGetActiveSessionIdResponse {
368
+ readonly type: 'get_active_session_id';
369
+ /** Null when zero non-expired snapshots match the broker's loaded
370
+ * signer, OR when 2+ match (ambiguous). Callers fall back to Path C
371
+ * in either case. */
372
+ readonly sessionId: string | null;
373
+ }
131
374
  interface BrokerErrorResponse {
132
375
  readonly type: 'error';
133
376
  readonly code: BrokerErrorCode;
134
377
  readonly message: string;
135
378
  }
136
- type BrokerErrorCode = 'invalid_request' | 'payload_too_large' | 'unsupported_type' | 'internal' | 'forbidden' | 'keystore_unavailable' | 'session_key_unavailable';
137
- type BrokerRequest = BrokerHelloRequest | BrokerSignHashRequest | BrokerStoreJwtRequest | BrokerGetJwtRequest | BrokerClearJwtRequest;
138
- type BrokerResponse = BrokerHelloResponse | BrokerSignHashResponse | BrokerStoreJwtResponse | BrokerGetJwtResponse | BrokerClearJwtResponse | BrokerErrorResponse;
379
+ type BrokerErrorCode = 'invalid_request' | 'payload_too_large' | 'unsupported_type' | 'internal' | 'forbidden' | 'keystore_unavailable' | 'session_key_unavailable' | 'no_active_snapshot' | 'policy_violation' | 'scope_violation' | 'max_spend_exceeded' | 'rate_limited';
380
+ type BrokerRequest = BrokerHelloRequest | BrokerSignHashRequest | BrokerStoreJwtRequest | BrokerGetJwtRequest | BrokerClearJwtRequest | BrokerSignUserOpRequest | BrokerStorePolicySnapshotRequest | BrokerGetPolicySnapshotRequest | BrokerClearPolicySnapshotRequest | BrokerGetActiveSessionIdRequest;
381
+ type BrokerResponse = BrokerHelloResponse | BrokerSignHashResponse | BrokerStoreJwtResponse | BrokerGetJwtResponse | BrokerClearJwtResponse | BrokerSignUserOpResponse | BrokerStorePolicySnapshotResponse | BrokerGetPolicySnapshotResponse | BrokerClearPolicySnapshotResponse | BrokerGetActiveSessionIdResponse | BrokerErrorResponse;
139
382
  /**
140
383
  * Parse a single-line request payload. Returns either the validated
141
384
  * request or a structured error — the daemon converts errors to a
@@ -160,12 +403,84 @@ type BrokerClientErrorCode = 'connect_failed' | 'timeout' | 'protocol_error' | '
160
403
  declare class BrokerClientError extends Error {
161
404
  readonly code: BrokerClientErrorCode;
162
405
  readonly cause?: unknown | undefined;
163
- constructor(code: BrokerClientErrorCode, message: string, cause?: unknown | undefined);
406
+ /**
407
+ * When `code === 'broker_error'`, this carries the typed upstream
408
+ * error code from the daemon (e.g. `unsupported_type`,
409
+ * `policy_violation`, `no_active_snapshot`). Callers in the tool
410
+ * handler layer use it to map to fine-grained fallback reasons
411
+ * without substring-matching on the message. Undefined for non-
412
+ * broker_error variants (connect_failed / timeout / protocol_error)
413
+ * since they never carry a daemon-side code.
414
+ *
415
+ * Added in Wave 5 Path D Slice 1 Commit 3 to close the
416
+ * MCP-Builder H-1: a 0.3.x daemon returning `unsupported_type` for
417
+ * `get_active_session_id` was previously mapped to the generic
418
+ * `broker_internal` Path D fallback — operator-confusing. With
419
+ * `brokerCode`, callers can route `unsupported_type` to
420
+ * `version_too_old` explicitly.
421
+ */
422
+ readonly brokerCode?: BrokerErrorCode | undefined;
423
+ constructor(code: BrokerClientErrorCode, message: string, cause?: unknown | undefined,
424
+ /**
425
+ * When `code === 'broker_error'`, this carries the typed upstream
426
+ * error code from the daemon (e.g. `unsupported_type`,
427
+ * `policy_violation`, `no_active_snapshot`). Callers in the tool
428
+ * handler layer use it to map to fine-grained fallback reasons
429
+ * without substring-matching on the message. Undefined for non-
430
+ * broker_error variants (connect_failed / timeout / protocol_error)
431
+ * since they never carry a daemon-side code.
432
+ *
433
+ * Added in Wave 5 Path D Slice 1 Commit 3 to close the
434
+ * MCP-Builder H-1: a 0.3.x daemon returning `unsupported_type` for
435
+ * `get_active_session_id` was previously mapped to the generic
436
+ * `broker_internal` Path D fallback — operator-confusing. With
437
+ * `brokerCode`, callers can route `unsupported_type` to
438
+ * `version_too_old` explicitly.
439
+ */
440
+ brokerCode?: BrokerErrorCode | undefined);
164
441
  }
165
442
  interface BrokerClientOptions {
166
443
  endpoint: string;
167
444
  timeoutMs: number;
168
445
  }
446
+ /**
447
+ * Result of `BrokerClient.preflight()`. Discriminated on `supported`.
448
+ * The unsupported variants carry enough structured info that the host
449
+ * LLM can render an actionable remediation message — semver-mismatch
450
+ * tells the operator to upgrade the broker; broker-unreachable tells
451
+ * them to start it; session-key-unavailable tells them to rotate env.
452
+ */
453
+ type PreflightResult = {
454
+ readonly supported: true;
455
+ readonly daemonVersion: string;
456
+ readonly signerAddress: `0x${string}`;
457
+ } | {
458
+ readonly supported: false;
459
+ readonly reason: 'version_too_old';
460
+ readonly daemonVersion: string;
461
+ readonly requiredVersion: string;
462
+ } | {
463
+ readonly supported: false;
464
+ readonly reason: 'session_key_unavailable';
465
+ readonly daemonVersion: string;
466
+ readonly requiredVersion: string;
467
+ } | {
468
+ readonly supported: false;
469
+ readonly reason: 'broker_unreachable';
470
+ readonly message: string;
471
+ readonly requiredVersion: string;
472
+ };
473
+ /**
474
+ * Tiny semver-gte for "is X ≥ Y". Both inputs MUST be M.m.p (no
475
+ * pre-release / build-metadata suffixes) — the broker protocol version
476
+ * is locked to that shape. Non-conforming inputs throw rather than
477
+ * silently mis-compare.
478
+ *
479
+ * Kept inline to avoid adding a `semver` dep just for one three-segment
480
+ * comparison; the broker's version space is small enough that a regex
481
+ * + numeric compare is bulletproof.
482
+ */
483
+ declare function semverGte(a: string, b: string): boolean;
169
484
  declare class BrokerClient {
170
485
  private readonly options;
171
486
  constructor(options: BrokerClientOptions);
@@ -177,6 +492,35 @@ declare class BrokerClient {
177
492
  storeJwt(jwt: string, expiresAtSec?: number): Promise<BrokerStoreJwtResponse>;
178
493
  getJwt(): Promise<BrokerGetJwtResponse>;
179
494
  clearJwt(): Promise<void>;
495
+ signUserOp(args: {
496
+ sessionId: string;
497
+ userOpHash: `0x${string}`;
498
+ innerCall: {
499
+ target: `0x${string}`;
500
+ callData: `0x${string}`;
501
+ };
502
+ intent?: {
503
+ tool: string;
504
+ summary?: string;
505
+ };
506
+ }): Promise<BrokerSignUserOpResponse>;
507
+ storePolicySnapshot(snapshot: PolicySnapshotWire): Promise<BrokerStorePolicySnapshotResponse>;
508
+ getPolicySnapshot(sessionId: string): Promise<BrokerGetPolicySnapshotResponse>;
509
+ clearPolicySnapshot(sessionId: string): Promise<BrokerClearPolicySnapshotResponse>;
510
+ getActiveSessionId(): Promise<BrokerGetActiveSessionIdResponse>;
511
+ /**
512
+ * Detect whether the running daemon speaks Path D (protocol 0.4.0+).
513
+ * Wraps `hello()` with a semver-gte comparison so the MCP tool layer
514
+ * can short-circuit to Path C with a clear `version_too_old` reason
515
+ * instead of surfacing the opaque `unsupported_type` error a stale
516
+ * 0.3.0 daemon would emit on `sign_userop` / `get_active_session_id`
517
+ * (Backend Architect H-2, round 2).
518
+ *
519
+ * Returns `{ supported: false }` on broker connect failure too — the
520
+ * caller treats "daemon down" identically to "version too old": Path D
521
+ * not available, fall through to Path C.
522
+ */
523
+ preflight(): Promise<PreflightResult>;
180
524
  private exchange;
181
525
  }
182
526
 
@@ -276,6 +620,245 @@ declare class BackendClient {
276
620
  private exchange;
277
621
  }
278
622
 
623
+ /**
624
+ * ERC-4337 v0.7 bundler JSON-RPC client. Lives in the MCP SERVER (not
625
+ * the broker daemon) because submission is a network-egress action and
626
+ * the broker is bound by the R-1 zero-egress invariant (see
627
+ * `broker/protocol.ts` JSDoc + THREAT_MODEL_P0.md §"Lethal-trifecta
628
+ * self-audit"). The broker signs hashes; the MCP server (which already
629
+ * speaks HTTPS to the backend) speaks JSON-RPC to the bundler.
630
+ *
631
+ * Wave 5 Path D Slice 1 (Commit 3) scope: the BundlerClient surface +
632
+ * tests ship now, even though the actual UserOp build is deferred to
633
+ * Commit 3.5 (the FHE encrypt + kernel-execute encoding pieces have
634
+ * unresolved design points — see PATH_D_PLAN.md Commit 3 scope-cut).
635
+ *
636
+ * Three RPCs in scope:
637
+ * - `eth_sendUserOperation(userOp, entryPoint)` → returns
638
+ * `userOpHash: Hex32`.
639
+ * - `eth_getUserOperationReceipt(userOpHash)` → returns receipt or
640
+ * `null` when the userOp hasn't been bundled into a block yet.
641
+ * - `eth_chainId()` → returns the bundler's view of the chain id;
642
+ * used to detect operator misconfiguration (wrong network).
643
+ *
644
+ * Trust model: the bundler is a network peer; treat its responses as
645
+ * untrusted input. Every parse step throws a structured BundlerClientError
646
+ * on shape mismatch — never silently coerces. RPC errors (`{code, message,
647
+ * data}`) surface as `rpc_error` with the upstream `code` preserved so the
648
+ * host LLM can map known bundler error classes (`AA21`, `AA23`, etc.)
649
+ * without parsing message text.
650
+ */
651
+ type BundlerClientErrorCode = 'config' | 'network' | 'timeout' | 'http_error' | 'invalid_response' | 'rpc_error' | 'receipt_timeout' | 'chain_mismatch';
652
+ declare class BundlerClientError extends Error {
653
+ readonly code: BundlerClientErrorCode;
654
+ /** Optional structured detail — for rpc_error, the upstream JSON-RPC
655
+ * error object (e.g. `{ code: -32600, message: "AA23 reverted", data: ... }`).
656
+ * Surface fields that help the LLM steer; never the raw upstream
657
+ * fields (they may carry implementation-specific data). */
658
+ readonly detail?: unknown | undefined;
659
+ constructor(code: BundlerClientErrorCode, message: string,
660
+ /** Optional structured detail — for rpc_error, the upstream JSON-RPC
661
+ * error object (e.g. `{ code: -32600, message: "AA23 reverted", data: ... }`).
662
+ * Surface fields that help the LLM steer; never the raw upstream
663
+ * fields (they may carry implementation-specific data). */
664
+ detail?: unknown | undefined);
665
+ }
666
+ /**
667
+ * EIP-4337 v0.7 packed UserOperation as accepted by `eth_sendUserOperation`.
668
+ * Bigint-valued fields are hex strings on the wire (`0x` + ≤64 hex).
669
+ *
670
+ * We accept a permissive shape (Record<string, string>) at the client
671
+ * boundary — the caller (Commit 3.5 UserOp builder) is responsible for
672
+ * stringifying its bigints. The client validates `0x`-prefix shape only;
673
+ * deeper shape correctness is the bundler's job to enforce.
674
+ */
675
+ interface UserOperationV07Wire {
676
+ readonly sender: `0x${string}`;
677
+ readonly nonce: `0x${string}`;
678
+ readonly factory?: `0x${string}`;
679
+ readonly factoryData?: `0x${string}`;
680
+ readonly callData: `0x${string}`;
681
+ readonly callGasLimit: `0x${string}`;
682
+ readonly verificationGasLimit: `0x${string}`;
683
+ readonly preVerificationGas: `0x${string}`;
684
+ readonly maxFeePerGas: `0x${string}`;
685
+ readonly maxPriorityFeePerGas: `0x${string}`;
686
+ readonly paymaster?: `0x${string}`;
687
+ readonly paymasterVerificationGasLimit?: `0x${string}`;
688
+ readonly paymasterPostOpGasLimit?: `0x${string}`;
689
+ readonly paymasterData?: `0x${string}`;
690
+ readonly signature: `0x${string}`;
691
+ }
692
+ /**
693
+ * Wave 5 Path D Slice 1 Commit 3.5 — the unsigned UserOp shape passed
694
+ * to `pm_sponsorUserOperation`. ZeroDev's paymaster fills in the gas
695
+ * limits + paymaster fields and returns them; the caller then composes
696
+ * the full `UserOperationV07Wire` with the placeholder signature
697
+ * replaced by the broker's session-key signature.
698
+ *
699
+ * Optional fields the bundler tolerates being absent: gas + paymaster
700
+ * fields. `signature` is required (use a non-zero high-entropy
701
+ * placeholder so the bundler simulates a worst-case verifier cost).
702
+ */
703
+ interface PartialUserOpForSponsorship {
704
+ readonly sender: `0x${string}`;
705
+ readonly nonce: `0x${string}`;
706
+ readonly callData: `0x${string}`;
707
+ readonly maxFeePerGas: `0x${string}`;
708
+ readonly maxPriorityFeePerGas: `0x${string}`;
709
+ /** Worst-case placeholder so paymaster simulates realistic gas. */
710
+ readonly signature: `0x${string}`;
711
+ }
712
+ interface SponsoredUserOpFields {
713
+ readonly paymaster: `0x${string}`;
714
+ readonly paymasterVerificationGasLimit: `0x${string}`;
715
+ readonly paymasterPostOpGasLimit: `0x${string}`;
716
+ readonly paymasterData: `0x${string}`;
717
+ readonly callGasLimit: `0x${string}`;
718
+ readonly verificationGasLimit: `0x${string}`;
719
+ readonly preVerificationGas: `0x${string}`;
720
+ }
721
+ interface EstimatedUserOpGas {
722
+ readonly callGasLimit: `0x${string}`;
723
+ readonly verificationGasLimit: `0x${string}`;
724
+ readonly preVerificationGas: `0x${string}`;
725
+ }
726
+ interface FeeData {
727
+ readonly maxFeePerGas: `0x${string}`;
728
+ readonly maxPriorityFeePerGas: `0x${string}`;
729
+ }
730
+ /**
731
+ * Receipt shape as returned by `eth_getUserOperationReceipt`. Subset
732
+ * we depend on; bundlers commonly return additional fields we ignore.
733
+ */
734
+ interface UserOperationReceipt {
735
+ readonly userOpHash: `0x${string}`;
736
+ readonly sender: `0x${string}`;
737
+ readonly success: boolean;
738
+ /** Bundler-reported revert reason (string or hex); present on failure. */
739
+ readonly reason?: string;
740
+ /** The underlying L1/L2 tx that bundled this UserOp. */
741
+ readonly receipt: {
742
+ readonly transactionHash: `0x${string}`;
743
+ readonly blockNumber: `0x${string}`;
744
+ readonly blockHash: `0x${string}`;
745
+ };
746
+ }
747
+ interface BundlerClientOptions {
748
+ /** Bundler RPC endpoint — MUHAVEN_BUNDLER_URL. https-or-loopback validated
749
+ * at config-load time (see `config.ts::validatePublicUrlEnv`). */
750
+ readonly endpoint: string;
751
+ /** Per-RPC HTTP timeout (ms). Default 15s — same as backend client. */
752
+ readonly requestTimeoutMs: number;
753
+ /** Expected chain id (Arb Sepolia = 421614). When set, `assertChainId()`
754
+ * refuses to proceed if the bundler reports a different chain. */
755
+ readonly expectedChainId?: number;
756
+ /**
757
+ * Wave 5 Path D 0.2.3 — `Origin` header sent on every bundler RPC.
758
+ *
759
+ * Why: ZeroDev's bundler URLs gate access via an IP+domain allowlist.
760
+ * Browser requests from `https://muhaven.app` pass because the
761
+ * project's allowlist includes that domain; Node `fetch` (the MCP
762
+ * server's transport) sends no `Origin` header by default and so
763
+ * hits a 403 "Neither IP nor domain is on the allowlist". Sending
764
+ * an `Origin` matching the project's allowlisted domain unblocks
765
+ * the MCP server without requiring an operator-side ZeroDev
766
+ * dashboard edit. Mirrors how ethers.js + viem's HTTP transports
767
+ * stamp a default `Origin` against EVM RPC providers.
768
+ *
769
+ * Defaults to `https://muhaven.app` at the call site
770
+ * (`server.ts::buildMcpServer`) — operators on a custom dashboard
771
+ * URL override via `MUHAVEN_DASHBOARD_URL`.
772
+ */
773
+ readonly originHeader?: string;
774
+ /** Inject for tests. */
775
+ readonly fetchImpl?: typeof fetch;
776
+ }
777
+ declare class BundlerClient {
778
+ private readonly options;
779
+ private readonly fetchImpl;
780
+ private nextRpcId;
781
+ constructor(options: BundlerClientOptions);
782
+ /**
783
+ * Submit a signed UserOperation. Returns the userOpHash the bundler
784
+ * computed (which must match the hash the broker signed — caller is
785
+ * responsible for the consistency check; broker policy snapshot
786
+ * captures the signer-binding piece).
787
+ */
788
+ sendUserOp(userOp: UserOperationV07Wire, entryPoint: `0x${string}`): Promise<`0x${string}`>;
789
+ /** Return the receipt for a userOpHash, or null when the UserOp has
790
+ * not yet been bundled. */
791
+ getReceipt(userOpHash: `0x${string}`): Promise<UserOperationReceipt | null>;
792
+ /**
793
+ * Poll until the bundler returns a receipt, or `timeoutMs` elapses.
794
+ * Caller decides retry / fallback behaviour on `receipt_timeout`.
795
+ *
796
+ * Poll interval grows linearly from `initialIntervalMs` to
797
+ * `maxIntervalMs` to avoid burning the bundler quota when blocks are
798
+ * slow. Default tuning: 500ms → 2000ms over the first 6 polls; then
799
+ * pinned at 2000ms.
800
+ */
801
+ waitForReceipt(userOpHash: `0x${string}`, opts: {
802
+ readonly timeoutMs: number;
803
+ readonly initialIntervalMs?: number;
804
+ readonly maxIntervalMs?: number;
805
+ /** Inject for tests that want deterministic timing. */
806
+ readonly clockMs?: () => number;
807
+ readonly sleep?: (ms: number) => Promise<void>;
808
+ }): Promise<UserOperationReceipt>;
809
+ /**
810
+ * Wave 5 Path D Slice 1 Commit 3.5 — `pm_sponsorUserOperation`.
811
+ * ZeroDev's bundler URL serves both bundler RPCs AND paymaster RPCs
812
+ * at the same endpoint, so we don't need a separate paymaster URL.
813
+ * Returns the paymaster fields + the gas limits the paymaster's
814
+ * simulation computed (the caller doesn't need a separate
815
+ * `estimateUserOpGas` round-trip on the happy path).
816
+ */
817
+ sponsorUserOp(userOp: PartialUserOpForSponsorship, entryPoint: `0x${string}`): Promise<SponsoredUserOpFields>;
818
+ /**
819
+ * Wave 5 Path D Slice 1 Commit 3.5 — `eth_estimateUserOperationGas`.
820
+ * Not used in the happy path (sponsorship returns gas), but lives as
821
+ * a fallback for unsponsored flows OR if the operator's paymaster
822
+ * goes down. Reading gas separately also makes the failure modes
823
+ * distinguishable for the LLM-facing fallback reasons.
824
+ */
825
+ estimateUserOpGas(userOp: PartialUserOpForSponsorship, entryPoint: `0x${string}`): Promise<EstimatedUserOpGas>;
826
+ /**
827
+ * Wave 5 Path D Slice 1 Commit 3.5 — `eth_call` against the
828
+ * EntryPoint's `getNonce(sender, key)`. Uses the bundler URL as a
829
+ * full Arb Sepolia node (ZeroDev's bundler accepts read-side RPCs).
830
+ *
831
+ * Pass `key = 0n` for the default nonce key — Path D never uses a
832
+ * non-default key in Slice 1; reserved for batched UserOps in
833
+ * later slices.
834
+ */
835
+ getNonce(sender: `0x${string}`, entryPoint: `0x${string}`, key?: bigint): Promise<bigint>;
836
+ /**
837
+ * Wave 5 Path D Slice 1 Commit 3.5 — fetch the fee market via
838
+ * `eth_gasPrice` (returns a single value the bundler will accept for
839
+ * both maxFee + maxPriorityFee on Arb Sepolia, which has effectively
840
+ * no priority-vs-base distinction).
841
+ *
842
+ * Simple-on-purpose: a full EIP-1559 fee market read would need two
843
+ * RPCs (`eth_maxPriorityFeePerGas` + `eth_getBlock`); Arb Sepolia's
844
+ * fee dynamics don't require that precision and the paymaster pays
845
+ * either way. A future caller wanting EIP-1559 precision can add a
846
+ * sibling method.
847
+ */
848
+ getFeeData(): Promise<FeeData>;
849
+ /**
850
+ * Verify the bundler's reported chainId matches `expectedChainId`. Cheap
851
+ * to call once at MCP server boot (or lazily before the first send) so
852
+ * a misconfigured bundler URL surfaces as `chain_mismatch` before any
853
+ * user-facing send rather than after a guaranteed-failing submit.
854
+ *
855
+ * Throws `BundlerClientError(config)` if no `expectedChainId` is set —
856
+ * caller asked for an assert without configuring the expectation.
857
+ */
858
+ assertChainId(): Promise<void>;
859
+ private rpc;
860
+ }
861
+
279
862
  /**
280
863
  * Tool descriptions are the **single source of truth** for what each MCP
281
864
  * tool advertises to the host LLM. They are also hashed (SHA-256) at
@@ -396,6 +979,16 @@ interface SessionKeyRequiredPayload {
396
979
  interface ToolDeps {
397
980
  backend: BackendClient;
398
981
  broker?: BrokerClient;
982
+ /**
983
+ * Wave 5 Path D Slice 1 (Commit 3) — ERC-4337 bundler JSON-RPC client.
984
+ * Undefined → Path D autonomous-buy disabled, position tools fall back
985
+ * to Path C deep-link (existing behaviour). Configured at MCP boot via
986
+ * `MUHAVEN_BUNDLER_URL`. Slice 1 ships the probe + cap-check chain;
987
+ * the actual UserOp build lands in Commit 3.5 (the FHE encrypt + kernel-
988
+ * execute encoding pieces have unresolved design points — see
989
+ * PATH_D_PLAN.md Commit 3 scope-cut).
990
+ */
991
+ bundler?: BundlerClient;
399
992
  /** Surface this MCP server is configured for. Always 'mcp' here, but
400
993
  * carried as a dep so the audit tool can filter to the local surface. */
401
994
  surface: 'mcp';
@@ -406,6 +999,26 @@ interface ToolDeps {
406
999
  * production default when absent.
407
1000
  */
408
1001
  dashboardBaseUrl?: string;
1002
+ /**
1003
+ * Wave 5 Path D Slice 1 Commit 3.5 — chain id Path D's UserOp hash
1004
+ * computation needs. Sourced from `MUHAVEN_CHAIN_ID` env (default
1005
+ * Arb Sepolia 421614). Always present; not optional even when Path
1006
+ * D is disabled (cheap default + future read tools may want it).
1007
+ */
1008
+ chainId?: number;
1009
+ /**
1010
+ * Wave 5 Path D Slice 1 Commit 3.5 — `MuHavenSubscription` contract
1011
+ * address. Sourced from `MUHAVEN_SUBSCRIPTION_ADDRESS`. Undefined
1012
+ * disables Path D's UserOp build path; position tools fall back to
1013
+ * Path C with reason `subscription_address_unset`.
1014
+ */
1015
+ subscriptionAddress?: `0x${string}`;
1016
+ /**
1017
+ * Wave 5 Path D Slice 1 Commit 3.5 — ERC-4337 EntryPoint v0.7
1018
+ * address. Sourced from `MUHAVEN_ENTRY_POINT` env (default canonical
1019
+ * deployment).
1020
+ */
1021
+ entryPointAddress?: `0x${string}`;
409
1022
  }
410
1023
  type ToolResult<T> = {
411
1024
  ok: true;
@@ -461,12 +1074,25 @@ interface BuildServerOptions {
461
1074
  registry: readonly ToolEntry[];
462
1075
  backend: BackendClient;
463
1076
  broker: BrokerClient | undefined;
1077
+ /**
1078
+ * Wave 5 Path D Slice 1 (Commit 3) — bundler client wired through to
1079
+ * `ToolDeps.bundler`. Undefined → Path D autonomous mode off; position
1080
+ * tools stay on Path C deep-link (existing behaviour). Configured from
1081
+ * `MUHAVEN_BUNDLER_URL` env at MCP boot.
1082
+ */
1083
+ bundler?: BundlerClient;
464
1084
  /**
465
1085
  * Threaded into `ToolDeps` so the `SESSION_KEY_REQUIRED` payload's
466
1086
  * `mintUrl` points at the operator's actual dashboard, not a hardcoded
467
1087
  * production URL.
468
1088
  */
469
1089
  dashboardBaseUrl?: string;
1090
+ /** Wave 5 Path D Slice 1 (Commit 3.5) — Arb Sepolia chain id (421614). */
1091
+ chainId?: number;
1092
+ /** Wave 5 Path D Slice 1 (Commit 3.5) — MuHavenSubscription target. */
1093
+ subscriptionAddress?: `0x${string}`;
1094
+ /** Wave 5 Path D Slice 1 (Commit 3.5) — ERC-4337 EntryPoint v0.7 addr. */
1095
+ entryPointAddress?: `0x${string}`;
470
1096
  }
471
1097
  declare function buildMcpServer(opts: BuildServerOptions): Server;
472
1098
  /**
@@ -522,6 +1148,41 @@ interface McpRuntimeConfig {
522
1148
  allowedBackendHosts: readonly string[];
523
1149
  /** In-process JWT cache TTL in seconds. Default 30. */
524
1150
  jwtCacheTtlSec: number;
1151
+ /**
1152
+ * Wave 5 Path D Slice 1 (Commit 3) — ERC-4337 v0.7 bundler JSON-RPC URL.
1153
+ * Undefined → Path D autonomous-buy mode disabled (position tools fall
1154
+ * back to Path C deep-link). Validated via the same https-or-loopback
1155
+ * rule as backend/dashboard URLs.
1156
+ */
1157
+ bundlerUrl: string | undefined;
1158
+ /** Path D request timeout for bundler RPC calls. Default 20s
1159
+ * (slightly higher than backend default to absorb head-of-line block
1160
+ * delays). */
1161
+ bundlerTimeoutMs: number;
1162
+ /** Wave 5 Path D — expected chain id (Arb Sepolia = 421614). Sourced
1163
+ * from env so a future mainnet rollout doesn't require a code change.
1164
+ * Default 421614. */
1165
+ chainId: number;
1166
+ /**
1167
+ * Wave 5 Path D Slice 1 (Commit 3.5) — the
1168
+ * `MuHavenSubscription.purchase` target the autonomous-buy UserOp
1169
+ * calls into. Undefined → Path D's UserOp build path is disabled
1170
+ * (handler falls back to Path C with reason
1171
+ * `subscription_address_unset`). Lives in env (NOT a contract
1172
+ * deployment lookup) so the MCP package stays free of the deployment
1173
+ * JSON files. Source of truth: `deployments/arb-sepolia-v2.json`
1174
+ * (prod) / `deployments/arb-sepolia-v2.staging.json` (stage) →
1175
+ * `subscription` field.
1176
+ */
1177
+ subscriptionAddress: `0x${string}` | undefined;
1178
+ /**
1179
+ * Wave 5 Path D Slice 1 (Commit 3.5) — ERC-4337 EntryPoint v0.7
1180
+ * address. Defaults to the canonical deployment
1181
+ * `0x0000000071727De22E5E9d8BAf0edAc6f37da032` (same on every EVM
1182
+ * chain). Operators on a future EntryPoint rotation override via
1183
+ * `MUHAVEN_ENTRY_POINT`.
1184
+ */
1185
+ entryPointAddress: `0x${string}`;
525
1186
  }
526
1187
  interface BrokerRuntimeConfig {
527
1188
  /** Endpoint to bind: socket path on POSIX, named pipe name on Windows. */
@@ -670,6 +1331,22 @@ declare class DeviceFlowClient {
670
1331
  interface ISigner {
671
1332
  readonly address: `0x${string}`;
672
1333
  signHash(hash: `0x${string}`): Promise<`0x${string}`>;
1334
+ /**
1335
+ * Wave 5 Path D Slice 1 Commit 3.5 — EIP-191 personal-sign over a
1336
+ * raw 32-byte hash. ZeroDev's permission validator on Kernel v3.1
1337
+ * (via `@zerodev/permissions::signUserOperation`) does
1338
+ * `signer.account.signMessage({ message: { raw: userOpHash } })` —
1339
+ * i.e., it ECDSA-signs `keccak256("\x19Ethereum Signed Message:\n32"
1340
+ * || userOpHash)`, NOT the raw userOpHash. For Path D autonomous
1341
+ * UserOps, the broker MUST sign via this path or the on-chain
1342
+ * `ecrecover` yields a different address → `AA24 InvalidSigner`.
1343
+ *
1344
+ * `signHash` (raw ECDSA over the supplied hash) stays for back-compat
1345
+ * with `sign_hash` IPC verb / Wave 4 placeholder envelope; the new
1346
+ * `signRawMessage` is the verb Path D's `sign_userop` daemon path
1347
+ * calls.
1348
+ */
1349
+ signRawMessage(hash: `0x${string}`): Promise<`0x${string}`>;
673
1350
  }
674
1351
  /**
675
1352
  * Sentinel signer for the read-only daemon posture (no
@@ -687,12 +1364,14 @@ declare const ZERO_ADDRESS: "0x0000000000000000000000000000000000000000";
687
1364
  declare class NullSigner implements ISigner {
688
1365
  readonly address: "0x0000000000000000000000000000000000000000";
689
1366
  signHash(_hash: `0x${string}`): Promise<`0x${string}`>;
1367
+ signRawMessage(_hash: `0x${string}`): Promise<`0x${string}`>;
690
1368
  }
691
1369
  declare class ViemSigner implements ISigner {
692
1370
  private readonly account;
693
1371
  constructor(privateKey: `0x${string}`);
694
1372
  get address(): `0x${string}`;
695
1373
  signHash(hash: `0x${string}`): Promise<`0x${string}`>;
1374
+ signRawMessage(hash: `0x${string}`): Promise<`0x${string}`>;
696
1375
  }
697
1376
 
698
1377
  /**
@@ -748,6 +1427,71 @@ declare function openKeystore(options?: OpenKeystoreOptions): Promise<{
748
1427
  fallbackReason: string | null;
749
1428
  }>;
750
1429
 
1430
+ /**
1431
+ * Wave 5 Path D Slice 1 — per-session policy snapshot subsystem for the
1432
+ * broker daemon. Each snapshot describes the rules the broker enforces
1433
+ * BEFORE signing a UserOp: target-contract allowlist, selector allowlist,
1434
+ * per-op amount cap, expiry.
1435
+ *
1436
+ * Persistence model: one JSON file per snapshot under
1437
+ * `~/.muhaven/policy-snapshots/<sessionId>.json`. Atomic writes via
1438
+ * tmp-file + rename (POSIX & Windows). Mode 0600 on POSIX; Windows ACL
1439
+ * is whatever the user's profile dir provides.
1440
+ *
1441
+ * Why a directory not a single keystore record (cf. `keystore.ts` for
1442
+ * the JWT): users can have multiple concurrent scoped sessions
1443
+ * (different surfaces, different tiers), and the broker needs lookup-by-
1444
+ * sessionId during sign_userop. The JWT is a single-tenant record;
1445
+ * snapshots are a multi-record store keyed by sessionId.
1446
+ *
1447
+ * The OS keychain backend is intentionally NOT used here. Keychain APIs
1448
+ * generally hold one value per (service, account) pair and are awkward
1449
+ * for the "list all keys" lookup the doctor command needs. File-only
1450
+ * keeps it simple. Snapshots aren't long-term secrets — they describe
1451
+ * policy, not credentials — so the security posture is "operator
1452
+ * filesystem permissions" not "OS-trust-zone".
1453
+ */
1454
+
1455
+ /** Public alias — callers can import either name. */
1456
+ type PolicySnapshot = PolicySnapshotWire;
1457
+
1458
+ interface IPolicyStore {
1459
+ /** Return the snapshot for `sessionId`, or null if absent OR past
1460
+ * `validUntilSec`. Callers treat both as "no active snapshot." */
1461
+ get(sessionId: string, nowSec: number): Promise<PolicySnapshot | null>;
1462
+ /** Overwrite any existing record for the snapshot's sessionId. */
1463
+ put(snapshot: PolicySnapshot): Promise<void>;
1464
+ /** Delete the snapshot. No-op when sessionId is absent. */
1465
+ delete(sessionId: string): Promise<void>;
1466
+ /** Return every snapshot in the store (including expired). Used by
1467
+ * doctor / hello surfaces for diagnostics. NEVER exposed over IPC —
1468
+ * see RD-3 commentary in PATH_D_PLAN.md and Backend Architect M-4. */
1469
+ list(): Promise<PolicySnapshot[]>;
1470
+ /**
1471
+ * Return the sessionId of the SINGLE non-expired snapshot whose
1472
+ * `signerAddress` (case-insensitive) matches `activeSignerAddress`, or
1473
+ * `null` when zero match OR when 2+ match (ambiguous case). Backs the
1474
+ * narrow `get_active_session_id` IPC verb (Wave 5 Path D Slice 1
1475
+ * Commit 3) — intentionally narrower than `list()` so RD-3 / M-4 stays
1476
+ * honoured (snapshot enumeration never crosses the IPC boundary; only
1477
+ * the unique-id answer does).
1478
+ *
1479
+ * Callers fall back to Path C on null. The "ambiguous" case is a
1480
+ * design choice — operators rotating through scoped sessions
1481
+ * intentionally must clear the prior session before the new one
1482
+ * becomes "active" — so we never auto-pick a winner the operator
1483
+ * didn't ask for.
1484
+ */
1485
+ activeSessionId(activeSignerAddress: `0x${string}`, nowSec: number): Promise<string | null>;
1486
+ /** Optional one-shot async setup. The file-backed impl doesn't need
1487
+ * this — the dir is created lazily on first put. Slice 5's spend-
1488
+ * ledger impl will need to seed the SHA-256 chain from disk at boot;
1489
+ * daemon.start() awaits `policyStore.init?.()` so the seed runs
1490
+ * before any IPC request. Adding the optional method now avoids a
1491
+ * refactor when Slice 5 lands (Backend Architect M-1). */
1492
+ init?(): Promise<void>;
1493
+ }
1494
+
751
1495
  /**
752
1496
  * `muhaven-broker` daemon — the single-purpose process that holds the
753
1497
  * session-key private half AND the device-flow JWT, exposing only IPC
@@ -775,6 +1519,14 @@ interface BrokerDaemonOptions {
775
1519
  signer?: ISigner;
776
1520
  /** Inject a keystore for tests; default opens the configured backend. */
777
1521
  keystore?: IKeystore;
1522
+ /**
1523
+ * Inject a policy store for tests; defaults to a `FilePolicyStore` rooted
1524
+ * at `~/.muhaven/policy-snapshots/`. The store is consulted by
1525
+ * `sign_userop` (validate before signing) and exposed via the
1526
+ * `store_policy_snapshot` / `get_policy_snapshot` / `clear_policy_snapshot`
1527
+ * verbs added in Wave 5 Path D Slice 1 (protocol 0.4.0).
1528
+ */
1529
+ policyStore?: IPolicyStore;
778
1530
  /** Override for the connection-handler logger; defaults to silent. */
779
1531
  logger?: (event: BrokerLogEvent) => void;
780
1532
  }
@@ -814,13 +1566,14 @@ interface HandleBrokerRequestOptions {
814
1566
  */
815
1567
  pid?: number;
816
1568
  }
817
- declare function handleBrokerRequest(req: BrokerRequest, signer: ISigner, keystore: IKeystore, nowSec?: () => number, options?: HandleBrokerRequestOptions): Promise<BrokerResponse>;
1569
+ declare function handleBrokerRequest(req: BrokerRequest, signer: ISigner, keystore: IKeystore, nowSec?: () => number, options?: HandleBrokerRequestOptions, policyStore?: IPolicyStore): Promise<BrokerResponse>;
818
1570
  declare class BrokerDaemon {
819
1571
  private readonly server;
820
1572
  private readonly signer;
821
1573
  private readonly log;
822
1574
  private readonly config;
823
1575
  private keystore;
1576
+ private readonly policyStore;
824
1577
  /**
825
1578
  * Whether a session-key private half is actually loaded. `false` =
826
1579
  * daemon booted in read-only posture (no `MUHAVEN_BROKER_SESSION_KEY`
@@ -834,4 +1587,4 @@ declare class BrokerDaemon {
834
1587
  private runAndRespond;
835
1588
  }
836
1589
 
837
- export { BROKER_PROTOCOL_VERSION, BackendClient, BackendError, type BackendErrorCode, BrokerClient, BrokerClientError, type BrokerClientErrorCode, BrokerDaemon, type BrokerDaemonOptions, type BrokerRequest, type BrokerResponse, type BrokerRuntimeConfig, DeviceFlowAbortedError, DeviceFlowClient, type DeviceFlowEvent, type HandleBrokerRequestOptions, type IKeystore, type ISigner, JwtSource, type KeystoreBackend, KeystoreError, type McpRuntimeConfig, MissingSessionKeyError, NoJwtAvailableError, NullSigner, type RunMcpStdioCliOptions, SERVER_VERSION, TOOL_DESCRIPTORS, type ToolDescriptor, type ToolEntry, type ToolHashEntry, ViemSigner, ZERO_ADDRESS, buildMcpServer, buildToolHashTable, defaultBrokerEndpoint, fullToolRegistry, handleBrokerRequest, hashToolDescriptor, loadBrokerConfig, loadMcpConfig, openKeystore, parseBrokerRequest, registryForReadOnly, runMcpStdioCli, selectRegistry, serializeResponse, verifyDescriptorAgainstPin };
1590
+ export { BROKER_PROTOCOL_VERSION, BackendClient, BackendError, type BackendErrorCode, BrokerClient, BrokerClientError, type BrokerClientErrorCode, BrokerDaemon, type BrokerDaemonOptions, type BrokerRequest, type BrokerResponse, type BrokerRuntimeConfig, BundlerClient, BundlerClientError, type BundlerClientErrorCode, type BundlerClientOptions, DeviceFlowAbortedError, DeviceFlowClient, type DeviceFlowEvent, type HandleBrokerRequestOptions, type IKeystore, type ISigner, JwtSource, type KeystoreBackend, KeystoreError, type McpRuntimeConfig, MissingSessionKeyError, NoJwtAvailableError, NullSigner, type PreflightResult, type RunMcpStdioCliOptions, SERVER_VERSION, TOOL_DESCRIPTORS, type ToolDescriptor, type ToolEntry, type ToolHashEntry, type UserOperationReceipt, type UserOperationV07Wire, ViemSigner, ZERO_ADDRESS, buildMcpServer, buildToolHashTable, defaultBrokerEndpoint, fullToolRegistry, handleBrokerRequest, hashToolDescriptor, loadBrokerConfig, loadMcpConfig, openKeystore, parseBrokerRequest, registryForReadOnly, runMcpStdioCli, selectRegistry, semverGte, serializeResponse, verifyDescriptorAgainstPin };