sealnet-mcp 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1325 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { WorkloadCredentials } from '@seal/crypto/node';
4
+ import { S } from './open-DUqF6AyG.js';
5
+ export { B as BackendError, S as SealApi, G as SealApiOptions, a0 as SealMetadata } from './open-DUqF6AyG.js';
6
+
7
+ /**
8
+ * MCP server bootstrap — wires the five tools (`seal_share`,
9
+ * `seal_request`, `seal_open`, `seal_list`, `seal_revoke`) to a
10
+ * stdio-transport `McpServer` instance, and `seal_pro_secret_use`,
11
+ * `seal_pro_secret_request` and `seal_pro_file_get` when SEAL Pro workload
12
+ * credentials exist (contract `pro_tools`).
13
+ *
14
+ * The bootstrap is intentionally factored as a `createServer()`
15
+ * factory that returns an awaitable handle, NOT a hard-coded
16
+ * `main()` entry. That shape:
17
+ * - lets `cli.ts` configure passphrase delivery, state path,
18
+ * backend base URL, etc. before launching;
19
+ * - lets test code spawn an in-process MCP without spawning a
20
+ * subprocess;
21
+ * - keeps the "where do creds come from?" decision out of the
22
+ * pure tool implementations.
23
+ *
24
+ * Tool `title`, `description` and `annotations` come from
25
+ * `shared/contracts/seal_mcp.v1.json` (contract first, §6.2), and so do
26
+ * the descriptions of the inputs the contract describes; the zod input
27
+ * schemas live here.
28
+ *
29
+ * Three stateful concerns the factory owns (all three skipped in
30
+ * ephemeral mode, `state: null`, where nothing touches the disk):
31
+ * - **Exclusive state lock (ISSUE-0623)**: `acquireStateLock` makes
32
+ * this process the ONLY writer of the state dir for its lifetime;
33
+ * a second `serve` on the same dir fails fast instead of silently
34
+ * clobbering handles. Released in `close()` / on process exit.
35
+ * - **State persistence**: each tool mutates `ctx.state` in-place
36
+ * and calls `await ctx.persist()`; persist() re-encrypts the
37
+ * view and writes atomically via `state.saveState`. A mutex
38
+ * serialises persist() calls so concurrent tool calls cannot
39
+ * race on the same on-disk envelope. Startup runs a journal
40
+ * recovery pass (`recovery.ts`) before any tool registers.
41
+ * - **Tool errors**: thrown `ToolError`s are converted to MCP's
42
+ * `{ isError: true, content: [...] }` payload with the typed
43
+ * `code` echoed back to the model. Other errors bubble as
44
+ * internal_error so the model never sees raw stack traces.
45
+ */
46
+
47
+ interface McpServerOptions {
48
+ /** Encrypted state on disk; `null` = ephemeral: identity and handles in memory only. */
49
+ readonly state: {
50
+ readonly path: string;
51
+ readonly passphrase: string;
52
+ } | null;
53
+ readonly backendBaseUrl?: string;
54
+ readonly publicHost?: string;
55
+ /** SEAL Pro workload; omitted = read the environment or the enrollment file, `null` = none. */
56
+ readonly workload?: WorkloadCredentials | null;
57
+ }
58
+ interface McpServerHandle {
59
+ readonly mcp: McpServer;
60
+ readonly transport: StdioServerTransport;
61
+ readonly close: () => Promise<void>;
62
+ }
63
+ /**
64
+ * Bootstrap an MCP server. Loads + decrypts the state, instantiates
65
+ * the backend client and the five tools, then connects over stdio.
66
+ *
67
+ * @throws `StatePassphraseInvalid` / `StateCorrupt` from state.ts.
68
+ * @throws `Error` if `globalThis.fetch` is missing (Node <18).
69
+ */
70
+ declare function createServer(options: McpServerOptions): Promise<McpServerHandle>;
71
+
72
+ /**
73
+ * Single URL resolver for the two origins the MCP server talks about
74
+ * (ISSUE-0622).
75
+ *
76
+ * The two origins are INDEPENDENT in production:
77
+ *
78
+ * - backend → `https://api.seal.net` — FastAPI, where uploads /
79
+ * metadata / revoke calls go;
80
+ * - public → `https://seal.net` — the frontend that share
81
+ * URLs must point at so a recipient's browser can open them.
82
+ *
83
+ * Before this module the public host was DERIVED from the backend URL
84
+ * ("strip a trailing `/api`"), which worked for single-origin
85
+ * self-hosted setups but produced `https://api.seal.net/s/…` links in
86
+ * the default production configuration — the API origin serves no
87
+ * frontend, so the link was dead on arrival.
88
+ *
89
+ * Resolution precedence (same for both origins):
90
+ * 1. explicit value (CLI flag / `McpServerOptions` / library caller)
91
+ * 2. environment variable (`SEAL_MCP_BACKEND_URL` / `SEAL_MCP_PUBLIC_HOST`)
92
+ * 3. production default
93
+ *
94
+ * Self-hosted deployments where frontend and backend live on other
95
+ * origins must set BOTH knobs — the resolver never guesses one from
96
+ * the other.
97
+ */
98
+ declare const DEFAULT_BACKEND_URL = "https://api.seal.net";
99
+ declare const DEFAULT_PUBLIC_HOST = "https://seal.net";
100
+ declare const BACKEND_URL_ENV_VAR = "SEAL_MCP_BACKEND_URL";
101
+ declare const PUBLIC_HOST_ENV_VAR = "SEAL_MCP_PUBLIC_HOST";
102
+ /** Backend base URL: explicit → `SEAL_MCP_BACKEND_URL` → production default. */
103
+ declare function resolveBackendUrl(explicit?: string): string;
104
+ /** Public share-URL origin: explicit → `SEAL_MCP_PUBLIC_HOST` → production default. */
105
+ declare function resolvePublicHost(explicit?: string): string;
106
+
107
+ /**
108
+ * Streaming Uploader Types
109
+ *
110
+ * CANON: All upload logic is in internal/upload-engine.ts
111
+ * This file exports ONLY types for backwards compatibility.
112
+ */
113
+ /**
114
+ * Progress of one upload call, in ONE base.
115
+ *
116
+ * It used to come in two: the single-request path reported a percentage
117
+ * WITHIN each phase, over plaintext bytes, re-zeroing at every phase
118
+ * boundary; the multipart path reported one global percentage over delivered
119
+ * ciphertext. Nothing in the event said which, so every consumer guessed from
120
+ * the file size and carried its own projection — six of them, all different
121
+ * (F-150, F-159, F-165, F-165a, F-177). The emitter is fixed instead, and the
122
+ * projections are gone.
123
+ */
124
+ interface UploadProgress {
125
+ phase: "hashing" | "encrypting" | "uploading" | "verifying" | "done";
126
+ /**
127
+ * 0–100 for the whole call, and it never decreases — on either path,
128
+ * across phases, across retries. 100 only with `phase: 'done'`.
129
+ */
130
+ percent: number;
131
+ /**
132
+ * Ciphertext bytes that storage has CONFIRMED. Never bytes handed to a
133
+ * socket: `xhr.upload.onprogress` reports what the OS buffered, which runs
134
+ * ahead of the wire and then freezes — the lie the owner banned. So this
135
+ * moves in steps of one part, and a single-request file is 0 until it
136
+ * lands.
137
+ */
138
+ deliveredBytes: number;
139
+ /** Ciphertext bytes this call will deliver in all. */
140
+ totalBytes: number;
141
+ /**
142
+ * Ciphertext bytes ENCRYPTED so far, delivered or not.
143
+ *
144
+ * Local work, and honest about being that: it is not a claim that anything
145
+ * left the machine (`deliveredBytes` is the only such claim). A screen uses
146
+ * it for the stretch before the first part lands, which on a large file is
147
+ * seconds of a bar that would otherwise sit still — the first thing the
148
+ * owner called broken when he looked at the extension's popup.
149
+ */
150
+ preparedBytes?: number;
151
+ /** Confirmed bytes per second over the recent past; absent before the first delivery. */
152
+ speedBps?: number;
153
+ /** Time left at `speedBps`; absent when there is no speed yet. */
154
+ etaMs?: number;
155
+ /**
156
+ * `stalled`: the upload has been failing and retrying for a while with
157
+ * nothing delivered — a screen says "no connection, retrying" instead of
158
+ * showing a bar that stopped for no visible reason.
159
+ */
160
+ state: "running" | "stalled";
161
+ /** 1 on a clean run; grows with each retry since the last delivery. */
162
+ attempt: number;
163
+ /** Set when one callback covers several files (append): which one this is. */
164
+ fileIndex?: number;
165
+ totalFiles?: number;
166
+ }
167
+
168
+ /**
169
+ * Transport policy: when a failed network step is tried again, and when an
170
+ * upload gives up.
171
+ *
172
+ * Every network step of an upload goes through here — multipart init, the
173
+ * presign calls, each part PUT, complete, and the single-request leg's
174
+ * initiate / PUT / confirm. Before this the rules were literals scattered
175
+ * over two files and they disagreed: a part got three attempts inside four
176
+ * seconds, the single-request leg and `init` / `complete` got one, and a
177
+ * five-second network dip killed a 490 MB upload that had already delivered
178
+ * 480 MB of it (F-146…F-148, F-179).
179
+ *
180
+ * The rule is a STALL CLOCK for the whole upload, not a budget per step:
181
+ *
182
+ * * a failure is retried for as long as the upload as a whole keeps
183
+ * making progress — every confirmed part (or confirmed file) stops the
184
+ * clock, and the next failure starts it from zero;
185
+ * * the clock is only ever read when something FAILS. A part that is
186
+ * merely slow — 16 MiB on a 256 KB/s link takes minutes — is never
187
+ * timed by it: no watchdog may kill a healthy slow transfer. A DEAD
188
+ * socket is another matter and is not this clock's business either — see
189
+ * "liveness" in `multipart-uploader.ts` (audit/SPEC-UPLOAD-SCALE.md §4);
190
+ * * attempts are not counted at all. What ends an upload is `maxStallMs`
191
+ * with nothing delivered, a failure that cannot heal (most 4xx), or the
192
+ * caller's cancel.
193
+ *
194
+ * The numbers live in `shared/contracts/upload.json` so the other clients
195
+ * can read the same ones (F-167).
196
+ *
197
+ * @internal - NOT exported from SDK public API
198
+ */
199
+
200
+ interface TransportPolicy {
201
+ /** Delay before retry N (the last entry repeats). */
202
+ retryBackoffMs: readonly number[];
203
+ /** Each delay is scaled by a random factor in `1 ± ratio`. */
204
+ retryJitterRatio: number;
205
+ /** Failing with nothing delivered for this long ⇒ state `stalled`. */
206
+ stallNoticeMs: number;
207
+ /**
208
+ * Failing with nothing delivered for this long ⇒ the upload gives up.
209
+ * `Infinity` for a caller whose user has a Cancel button and would
210
+ * rather wait out an outage than lose the upload.
211
+ */
212
+ maxStallMs: number;
213
+ /** Slowest link a single PUT is still allowed to finish on. */
214
+ putFloorBytesPerSecond: number;
215
+ putMinDeadlineMs: number;
216
+ controlTimeoutMs: number;
217
+ completeTimeoutMs: number;
218
+ /**
219
+ * Parallel part PUTs: where the pool starts, and how wide it may get.
220
+ * Storage speaks HTTP/1.1 only, so every PUT is a connection of its own and
221
+ * a browser gives a host six: `poolMax` + `hedgeMaxConcurrent` must fit.
222
+ */
223
+ poolStart: number;
224
+ poolMax: number;
225
+ /**
226
+ * How narrow the pool may get on a line so slow that its flows are near
227
+ * `putFloorBytesPerSecond` each — the speed below which a PUT's deadline
228
+ * calls it dead. Fewer flows there means each one stays clear of it.
229
+ */
230
+ poolMin: number;
231
+ /** Widen when the aggregate rate grew by this factor since the last resize… */
232
+ poolGrowRatio: number;
233
+ /** …step back when it fell to this fraction of it. */
234
+ poolShrinkRatio: number;
235
+ /** Encrypted parts that may wait for a slot (bounds memory). */
236
+ partQueueLimit: number;
237
+ /** Never duplicate a part before it has been in flight this long. */
238
+ hedgeMinInflightMs: number;
239
+ /** A part is an outlier when it is this many times slower than its peers' median. */
240
+ hedgeLagFactor: number;
241
+ hedgePollMs: number;
242
+ /** Duplicate copies in flight across the whole upload, at most. */
243
+ hedgeMaxConcurrent: number;
244
+ /**
245
+ * No part delivered and no socket of ANY copy taking bytes for this long,
246
+ * while some copy still has bytes to send ⇒ the connections are dead: every
247
+ * part in flight is reconnected. Only where the transport reports socket
248
+ * activity (XHR in a window).
249
+ */
250
+ livenessSilenceMs: number;
251
+ /**
252
+ * The server verifies a delivered file INSIDE `complete` / `confirm` and says
253
+ * so in its answer (audit/SPEC-UPLOAD-SCALE.md §3). Only when it could not
254
+ * ask storage does it answer `accepted` and leave the verdict to a worker;
255
+ * then the sender polls, with these pauses (the last one repeats), for at
256
+ * most `verifyFallbackWaitMs`. The check is a HEAD, so the wait no longer
257
+ * grows with the size of the file.
258
+ */
259
+ verifyPollBackoffMs: readonly number[];
260
+ verifyFallbackWaitMs: number;
261
+ /** Window `speedBps` is averaged over. Display only; decides nothing. */
262
+ speedWindowMs: number;
263
+ }
264
+
265
+ /**
266
+ * What an upload is told by its caller: where to send, how to authenticate,
267
+ * and the size policy. Shared by the registration flows (`engine.ts`) and the
268
+ * one primitive that moves bytes (`session.ts`), so neither imports the other
269
+ * for a type.
270
+ *
271
+ * @internal - NOT exported from SDK public API
272
+ */
273
+
274
+ interface UploadContext {
275
+ /** API base URL */
276
+ baseUrl: string;
277
+ /** Authentication */
278
+ auth: AuthConfig;
279
+ /** Seal ID (for file uploads after seal creation) */
280
+ sealId?: string;
281
+ /** Room ID (for Vault uploads) */
282
+ roomId?: string;
283
+ /** TTL (for Seal creation) */
284
+ ttl?: string;
285
+ /** SHORT LINKS: SHA256 hash of client-generated owner_token (64 hex chars) */
286
+ ownerTokenHash?: string;
287
+ /** SHORT LINKS: Client-generated owner token for auth (P2/P5 fix) */
288
+ ownerToken?: string;
289
+ /**
290
+ * Where `appendMany` posts, as a path template containing `{sealId}`.
291
+ *
292
+ * §4.4: the protocol must not know which product is calling it. It used
293
+ * to choose between `/seal/{id}/append` and
294
+ * `/vault/org/seals/{id}/files/append` by looking at the auth kind — and
295
+ * the second route does not exist on any server; it is a rewrite rule in
296
+ * one web's `next.config.js`. A protocol that names a route its server
297
+ * never serves is a protocol guessing about its caller.
298
+ *
299
+ * Defaults to the anonymous seal route, which every SEAL client uses.
300
+ * A caller whose deployment maps append elsewhere passes its own.
301
+ */
302
+ appendPath?: string;
303
+ /** Backend OTP / "Password". 6-10 chars (backend pattern). */
304
+ otp?: string;
305
+ /** Recipient must accept NDA before downloading. */
306
+ nda?: boolean;
307
+ /** Owner email (required if nda=true; backend Pydantic validator
308
+ * enforces). Used for NDA-PDF receipt delivery. */
309
+ ownerEmail?: string;
310
+ /** Single-use: first download deletes ciphertext. */
311
+ singleUse?: boolean;
312
+ /** Geo allowlist (ISO 3166-1 alpha-2 country codes). */
313
+ geo?: string[];
314
+ /** Max number of total downloads across all recipients. */
315
+ maxOpens?: number;
316
+ /**
317
+ * Paid tier for a seal above the free limit (`5gb` | `50gb` | `500gb`,
318
+ * SPEC-AGENTS §5.2). The seal is born unpaid: its uploads answer 402
319
+ * until the payment lands, and then `UploadSession.resume()` goes on.
320
+ */
321
+ tier?: string;
322
+ /**
323
+ * fetch credentials mode for API calls. The seal.net web app relies on
324
+ * cookies for its OTP/NDA session, so `'include'` stays the default.
325
+ * The public send-only SDK passes `'omit'`: the wildcard-CORS send
326
+ * endpoints answer `Access-Control-Allow-Origin: *`, which the browser
327
+ * rejects for credentialed requests — send auth is the X-Owner-Token
328
+ * header, never a cookie.
329
+ */
330
+ credentials?: RequestCredentials;
331
+ }
332
+ interface AuthConfig {
333
+ kind: 'none' | 'ownerToken' | 'bearer';
334
+ value?: string;
335
+ }
336
+ interface UploadPolicy {
337
+ /** Threshold for switching to multipart upload (default: 8 MiB = one fine part) */
338
+ multipartThresholdBytes: number;
339
+ /** Part size for multipart upload (default: 16MB) */
340
+ partSizeBytes: number;
341
+ /** Max size for in-memory ZIP (default: 100MB) */
342
+ zipSmallLimitBytes: number;
343
+ /**
344
+ * Overrides of the contract's transport policy. The one a product sets is
345
+ * `maxStallMs: Infinity`: a screen with a Cancel button would rather sit
346
+ * out an outage than lose the upload; a headless caller keeps the
347
+ * contract's ceiling so it cannot hang forever.
348
+ */
349
+ transport?: Partial<TransportPolicy>;
350
+ }
351
+ /** Whether every file of a result was seen `verified` before it returned. */
352
+ type VerificationOutcome = 'verified' | 'pending';
353
+
354
+ /**
355
+ * Internal Upload Engine
356
+ *
357
+ * The registration flows: what each entry point tells the server before any
358
+ * byte moves (`POST /seal` with one file, with several, with a folder's
359
+ * paths, or an append to an existing seal). That is the ONLY thing the flows
360
+ * differ in. Moving a registered file's bytes — the small-file-or-multipart
361
+ * choice, the transport, resume and cancel — is `./session`, once.
362
+ *
363
+ * @internal - NOT exported from SDK public API
364
+ */
365
+
366
+ interface UploadCallbacks {
367
+ onProgress?: (progress: UploadProgress) => void;
368
+ signal?: AbortSignal;
369
+ /**
370
+ * Awaited immediately after `POST /seal` returns an id, BEFORE any
371
+ * bytes are uploaded. Lets journaling callers (seal-mcp) persist the
372
+ * (seal_id, owner_token) pair so the seal can be compensated even if
373
+ * the rest of the upload fails or the process crashes mid-flight —
374
+ * without this the id only surfaces when the whole pipeline resolves,
375
+ * and a lost confirm response orphans a fully-live seal (DR-mcp).
376
+ * A rejection here aborts the upload; the caller owns compensation.
377
+ */
378
+ onSealCreated?: (sealId: string) => void | Promise<void>;
379
+ }
380
+ /**
381
+ * SHORT LINKS: Single file upload result
382
+ *
383
+ * NOTE: shareUrl and ownerToken are NOT returned here.
384
+ * They are constructed in use_seal_submit.ts from:
385
+ * - sealId (from response)
386
+ * - linkKey (client-generated)
387
+ * - ownerToken (client-generated, stored in encrypted blob)
388
+ */
389
+ interface SingleFileUploadResult {
390
+ sealId: string;
391
+ fileId: string;
392
+ encryptionKey: Uint8Array<ArrayBuffer>;
393
+ sha256_hex: string;
394
+ /**
395
+ * Server-assigned expiry from the create-seal response, when the backend
396
+ * sends one. The server is the only authority here: it may clamp the
397
+ * requested TTL (plan limits, policy), and the client's clock may be
398
+ * skewed — a caller that recomputes `now + ttl` locally reports a number
399
+ * the seal does not actually have. Optional because older/other backends
400
+ * may omit the field; callers fall back to their own estimate and should
401
+ * say so.
402
+ */
403
+ expiresAt?: string;
404
+ /**
405
+ * `verified`: the server re-hashed every file before this returned — the
406
+ * link can be handed out. `pending`: the bytes were accepted but
407
+ * verification outlasted the wait; a recipient is not offered a file
408
+ * until it passes.
409
+ */
410
+ verification: VerificationOutcome;
411
+ }
412
+ /**
413
+ * Upload a single file into a new seal.
414
+ *
415
+ * A file below the multipart threshold is encrypted FIRST, so its row is
416
+ * registered under the real ciphertext digest rather than a placeholder
417
+ * (the digest is what the signed `seal_created` audit line records). A
418
+ * larger file cannot know its digest before streaming and registers a
419
+ * placeholder, which `/multipart/complete` replaces.
420
+ */
421
+ declare function uploadSingleFile(ctx: UploadContext, file: File, policy?: UploadPolicy, callbacks?: UploadCallbacks): Promise<SingleFileUploadResult>;
422
+
423
+ /**
424
+ * Out-of-band delivery of the share link to the human user. The link is
425
+ * the whole capability (key in the fragment); there is no password.
426
+ *
427
+ * Implements `shared/contracts/seal_mcp.v1.json::handoff_delivery`.
428
+ *
429
+ * # Why handoff at all?
430
+ *
431
+ * The MCP design principle is: **the model never holds capability by
432
+ * default**. If the share link were returned in the
433
+ * `seal_share` tool result, the model would have a copy in its context
434
+ * window — visible to any agent in the chain, exfiltrated by any
435
+ * `chat.history.export` tool, persisted in trace logs forever.
436
+ *
437
+ * Handoff routes the secret around the model entirely: into the OS
438
+ * clipboard (primary) or onto a `0o600` file on the same machine
439
+ * (fallback). The model only sees `{ handle, receipt, handoff_channel,
440
+ * handoff_file? }` — none of which lets it reconstruct the share.
441
+ *
442
+ * # Channels
443
+ *
444
+ * 1. **Clipboard** — `clipboardy.write(\`${url}\n\`)`.
445
+ * Works on macOS (`pbcopy`), Linux X11/Wayland (`xclip`/`xsel`/
446
+ * `wl-copy`), WSL (`clip.exe`), Windows (`clip`). Fails fast in
447
+ * headless SSH with no `DISPLAY`.
448
+ * 2. **File** — atomic `fs.open(path, 'wx', 0o600)` inside
449
+ * `${XDG_RUNTIME_DIR or os.tmpdir()}/seal-mcp/`, content
450
+ * `url\n` plain text (user `cat`s it). Best-effort
451
+ * `setTimeout fs.unlink` after `max(expire_ms, 1 hour)`.
452
+ *
453
+ * On total failure both channels return `handoff_both_channels_failed`
454
+ * — better than silently leaking the secret into the model's output.
455
+ */
456
+ declare class HandoffBothChannelsFailedError extends Error {
457
+ readonly code = "handoff_both_channels_failed";
458
+ readonly clipboardError?: unknown;
459
+ readonly fileError?: unknown;
460
+ constructor(message: string, clipboardError?: unknown, fileError?: unknown);
461
+ }
462
+ type HandoffChannel = 'clipboard' | 'file';
463
+ interface HandoffPayload {
464
+ readonly handle: string;
465
+ readonly shareUrl: string;
466
+ readonly expireMs: number;
467
+ }
468
+ interface HandoffResult {
469
+ readonly channel: HandoffChannel;
470
+ readonly file?: string;
471
+ }
472
+ interface HandoffDeps {
473
+ readonly clipboardWrite?: (text: string) => Promise<void>;
474
+ readonly tmpdirOverride?: string;
475
+ readonly disableClipboard?: boolean;
476
+ readonly scheduleCleanup?: (path: string, delayMs: number) => void;
477
+ }
478
+ /**
479
+ * Deliver a share link out-of-band.
480
+ *
481
+ * Order: clipboard → file. On total failure throws
482
+ * `HandoffBothChannelsFailedError` and emits NO surface containing
483
+ * the secret — caller MUST NOT log either result. The model gets
484
+ * `{ channel, file? }` per the contract; never the URL.
485
+ *
486
+ * @throws HandoffBothChannelsFailedError if both channels fail.
487
+ */
488
+ declare function performHandoff(payload: HandoffPayload, deps?: HandoffDeps): Promise<HandoffResult>;
489
+
490
+ /**
491
+ * Encrypted state envelope for `~/.config/seal-mcp/state.json`.
492
+ *
493
+ * Wire format = `shared/contracts/seal_mcp.v1.json::state_envelope`:
494
+ *
495
+ * Argon2id(passphrase, salt) → 32-byte KEK
496
+ * AES-256-GCM(KEK, nonce, plaintext_json) → ciphertext || 16-byte tag
497
+ *
498
+ * Everything except the envelope metadata (algo params, salt, nonce)
499
+ * lives inside the AEAD ciphertext. Identity secret + handle owner-tokens
500
+ * + audit log are all owner-equivalent capability — encrypting only the
501
+ * identity and leaving tokens in plaintext would be a security illusion.
502
+ *
503
+ * No plaintext field is EVER persisted unencrypted: every save() goes
504
+ * through `encrypt()`, every load() through `decrypt()`. Atomic write
505
+ * via `fs.writeFile(tmp) + fs.rename(tmp, final)` so a crash mid-write
506
+ * leaves either the old envelope or the new one, never a half-written
507
+ * one.
508
+ */
509
+ interface IdentityFields {
510
+ readonly x25519_secret_b64: string;
511
+ readonly x25519_public_b64: string;
512
+ }
513
+ interface HandleEntry {
514
+ readonly seal_id: string;
515
+ readonly owner_token_b64: string;
516
+ readonly created_at: string;
517
+ readonly mode: 'handoff' | 'forward';
518
+ readonly expires_at: string;
519
+ /**
520
+ * Set when `seal_revoke` succeeds. Keeping the row (rather than
521
+ * deleting it) is what makes revoke idempotent per
522
+ * `seal_mcp.v1.json::tools.seal_revoke.output`: a second call on the
523
+ * same handle returns `{revoked: true}` without re-hitting the
524
+ * backend. `seal_list` filters revoked handles unless
525
+ * `include_expired = true`, so the model can't accidentally probe
526
+ * them either.
527
+ */
528
+ revoked_at?: string;
529
+ /**
530
+ * FINAL out-of-band delivery channel actually used (handoff mode
531
+ * only; ISSUE-0623 requires attempted + final channels to be
532
+ * durable). Attempted channels live on the `PendingOperation`
533
+ * while the op is in flight; the final one is recorded here at
534
+ * completion.
535
+ */
536
+ handoff_channel?: 'clipboard' | 'file';
537
+ /**
538
+ * Backend base URL the seal was created against (DR-mcp: backend
539
+ * provenance). Revoke refuses to run against a DIFFERENT backend,
540
+ * because that backend's 404 would be misread as idempotent success
541
+ * while the seal stays live on its real authority. Absent on
542
+ * pre-provenance entries — those are treated as belonging to the
543
+ * currently configured backend (the only guess available).
544
+ */
545
+ backend_url?: string;
546
+ }
547
+ /**
548
+ * Durable operation journal (ISSUE-0623).
549
+ *
550
+ * A `seal_share` writes a `PendingOperation` BEFORE its first remote
551
+ * effect and updates it at every step boundary, so a crash at any
552
+ * point leaves enough encrypted-at-rest context to either revoke an
553
+ * orphaned seal or adopt an already-delivered one at next startup
554
+ * (`recovery.ts`). Completion deletes the entry in the same envelope
555
+ * write that inserts the durable `HandleEntry` — an op is never in
556
+ * both maps.
557
+ *
558
+ * Step semantics:
559
+ * - `preparing` → journaled, upload NOT yet confirmed. No
560
+ * `seal_id`; nothing remotely addressable.
561
+ * - `awaiting_payment` → the seal is over 1 GB and unpaid (SPEC-AGENTS
562
+ * §5.2); what would finish it lives in memory only,
563
+ * so recovery revokes it like `uploaded`.
564
+ * - `uploaded` → ciphertext live on backend, URL has NOT been
565
+ * delivered anywhere. Safe (and correct) to
566
+ * revoke on recovery.
567
+ * - `delivering` → handoff attempt started; the URL may already
568
+ * sit in the user's clipboard / 0o600 file.
569
+ * Recovery must NOT revoke — it adopts the seal
570
+ * as a normal handle so the owner keeps control.
571
+ * - `compensating`→ a revoke compensation was attempted and failed
572
+ * (`compensation.outcome === 'failed'`); retried
573
+ * at next startup.
574
+ */
575
+ interface PendingCompensation {
576
+ readonly action: 'revoke';
577
+ readonly outcome: 'failed';
578
+ readonly error: string;
579
+ readonly at: string;
580
+ }
581
+ type PendingStep = 'preparing' | 'awaiting_payment' | 'uploaded' | 'delivering' | 'compensating';
582
+ interface PendingOperation {
583
+ readonly tool: 'seal_share';
584
+ readonly mode: 'handoff' | 'forward';
585
+ readonly started_at: string;
586
+ readonly owner_token_b64: string;
587
+ readonly expires_at: string;
588
+ step: PendingStep;
589
+ seal_id?: string;
590
+ /** Channels `performHandoff` will try, in order (handoff mode only). */
591
+ handoff_channels_attempted?: ReadonlyArray<'clipboard' | 'file'>;
592
+ compensation?: PendingCompensation;
593
+ /**
594
+ * Backend base URL the operation runs against (DR-mcp: backend
595
+ * provenance). Recovery must not compensate against a different
596
+ * backend — see `HandleEntry.backend_url`.
597
+ */
598
+ backend_url?: string;
599
+ }
600
+ interface AuditEntry {
601
+ readonly ts: string;
602
+ readonly tool: 'seal_share' | 'seal_request' | 'seal_open' | 'seal_list' | 'seal_revoke';
603
+ readonly handle: string | null;
604
+ /**
605
+ * Free-form contextual label per tool. Contract permits any string
606
+ * (`{ "type": ["string", "null"] }`) and the value carries
607
+ * tool-specific meaning:
608
+ * - seal_share → 'handoff' | 'forward'
609
+ * - seal_request → 'local' | 'link'
610
+ * - seal_open → 'metadata' | 'inline' | 'file'
611
+ * - seal_list → null
612
+ * - seal_revoke → original handle mode echoed back
613
+ */
614
+ readonly mode: string | null;
615
+ }
616
+ interface StateView {
617
+ version: 1;
618
+ identity: IdentityFields;
619
+ handles: Record<string, HandleEntry>;
620
+ audit: AuditEntry[];
621
+ /**
622
+ * Durable operation journal keyed by handle (the handle is minted
623
+ * before the first remote effect, so it doubles as the op id).
624
+ * Absent in pre-ISSUE-0623 envelopes — `loadState` normalises to
625
+ * `{}` so old state files keep loading.
626
+ */
627
+ pending: Record<string, PendingOperation>;
628
+ }
629
+ declare class StatePassphraseInvalid extends Error {
630
+ constructor();
631
+ }
632
+ declare class StateCorrupt extends Error {
633
+ constructor(reason: string);
634
+ }
635
+ declare function createEmptyState(identity: IdentityFields): StateView;
636
+ /**
637
+ * Decrypt and validate a state file. Throws:
638
+ * - `StatePassphraseInvalid` on AEAD tag failure (wrong passphrase
639
+ * or tampered file).
640
+ * - `StateCorrupt` on JSON / envelope / plaintext schema violations.
641
+ * - Node fs errors on missing file / permissions (let them propagate).
642
+ */
643
+ declare function loadState(passphrase: string, statePath: string): Promise<StateView>;
644
+ /**
645
+ * Encrypt + atomically write a state file. Creates parent dir with
646
+ * 0o700 if missing. File is written 0o600. Pattern: write tmp, fsync,
647
+ * then rename — POSIX guarantees rename atomicity.
648
+ *
649
+ * A fresh 16-byte salt is generated on every save (overkill — Argon2id
650
+ * is non-adaptive — but cheap and follows defence-in-depth).
651
+ */
652
+ declare function saveState(state: StateView, passphrase: string, statePath: string): Promise<void>;
653
+
654
+ /**
655
+ * X25519 identity for the MCP server (incoming targeted-share recipient).
656
+ *
657
+ * The keys come from `@seal/protocol` (`generateRecipientKeyPair`,
658
+ * `recipientPublicKey`), which derives them in the same core the Rust CLI's
659
+ * `seal keygen`, the SDK and `sealnet` use — byte-for-byte compatible by
660
+ * construction, not by spec.
661
+ *
662
+ * The `Identity` view is held only in process memory; it is reconstructed
663
+ * from `state.identity` on every server start. The serialised form lives
664
+ * inside the AES-256-GCM ciphertext of `state.ts`.
665
+ */
666
+
667
+ interface Identity {
668
+ readonly secret: Uint8Array;
669
+ readonly public: Uint8Array;
670
+ }
671
+ /** A fresh X25519 keypair for this server. */
672
+ declare function generateIdentity(): Promise<Identity>;
673
+ /**
674
+ * Project an `Identity` into the serialised form that lives in
675
+ * `state.identity` (base64-encoded raw bytes; matches contract
676
+ * `shared/contracts/seal_mcp.v1.json::state_envelope.plaintext_schema.properties.identity`).
677
+ */
678
+ declare function identityToFields(id: Identity): IdentityFields;
679
+ /**
680
+ * Reconstruct an `Identity` from `state.identity`. Validates both key
681
+ * lengths AND that `public` actually corresponds to `secret` — catches a
682
+ * corrupted / tampered state where the two fields drifted apart.
683
+ */
684
+ declare function loadIdentity(fields: IdentityFields): Promise<Identity>;
685
+
686
+ /**
687
+ * Exclusive lifetime lock for the seal-mcp state directory (ISSUE-0623).
688
+ *
689
+ * # Why
690
+ *
691
+ * `state.json` is a whole-envelope read-modify-write store: the server
692
+ * loads the decrypted view once at boot and every `persist()` re-encrypts
693
+ * and atomically replaces the file. Two MCP processes pointed at the same
694
+ * state dir (the DEFAULT for every MCP host on the machine — Cursor +
695
+ * Claude Desktop both spawn `seal-mcp serve`) would silently overwrite
696
+ * each other's handles and audit entries: last writer wins, the other
697
+ * process's owner tokens are gone forever.
698
+ *
699
+ * The fix is a single-writer discipline: exactly one process may hold
700
+ * the state open for writing for its lifetime. Second `serve` (or a
701
+ * concurrent `init`/`reset`) fails fast with a legible error instead of
702
+ * corrupting capability state.
703
+ *
704
+ * # Mechanism (DR-mcp: TOCTOU-hardened)
705
+ *
706
+ * Creation is atomic WITH content: the holder info JSON is written to a
707
+ * private temp file first, then `link(tmp, state.lock)` publishes it —
708
+ * `link` fails with `EEXIST` if the lock exists (atomic on POSIX and
709
+ * NTFS), and a contender can never observe a half-written lock from a
710
+ * healthy holder. (The previous `open('wx')`-then-write protocol had an
711
+ * empty-file window that a contender misread as a stale lock and
712
+ * unlinked, yielding two simultaneous owners.)
713
+ *
714
+ * The lock file holds `{ pid, hostname, acquired_at }` so a contender
715
+ * can detect a stale lock: if the recorded pid is no longer alive ON
716
+ * THE SAME HOST, the previous holder crashed without cleanup. Breaking
717
+ * a stale lock is also atomic: the contender `rename`s the lock aside
718
+ * to a private name — only ONE contender wins the rename — verifies the
719
+ * claimed file still describes the stale holder (renaming it back if a
720
+ * fresh holder slipped in between probe and rename), and only then
721
+ * creates its own lock. A lock from a different hostname (network
722
+ * volume) is never broken automatically — we cannot probe liveness
723
+ * across hosts.
724
+ *
725
+ * PID-recycling is the accepted residual risk: the probe window is
726
+ * milliseconds and the failure mode (refusing to start) is safe.
727
+ *
728
+ * Release verifies ownership: the lock is unlinked only if it still
729
+ * carries OUR pid+hostname, so a process can never release a lock a
730
+ * contender legitimately re-acquired. Same check runs in the
731
+ * best-effort sync unlink on `process.on('exit')`.
732
+ */
733
+ declare const LOCK_FILE_NAME = "state.lock";
734
+ interface LockHolderInfo {
735
+ readonly pid: number;
736
+ readonly hostname: string;
737
+ readonly acquired_at: string;
738
+ }
739
+ declare class StateLockedError extends Error {
740
+ readonly code = "state_locked";
741
+ readonly holder: LockHolderInfo | null;
742
+ readonly lockPath: string;
743
+ constructor(lockPath: string, holder: LockHolderInfo | null);
744
+ }
745
+ interface StateLock {
746
+ readonly lockPath: string;
747
+ /** Idempotent. Removes the lock file (if still ours) and the process-exit hook. */
748
+ release: () => Promise<void>;
749
+ }
750
+ interface AcquireLockDeps {
751
+ /** Override liveness probe for tests. Default: `process.kill(pid, 0)`. */
752
+ readonly isPidAlive?: (pid: number) => boolean;
753
+ /** Override own pid for tests. */
754
+ readonly pid?: number;
755
+ /** Override hostname for tests. */
756
+ readonly host?: string;
757
+ }
758
+ /**
759
+ * Acquire the exclusive state-dir lock or throw `StateLockedError`.
760
+ *
761
+ * Creates `stateDir` (0o700) if missing so `init` can lock before the
762
+ * first `saveState`. Refuses to operate on a symlinked state dir and
763
+ * re-tightens permissions on a pre-existing one (DR-mcp: `mkdir` with
764
+ * `mode` does not repair an existing directory).
765
+ */
766
+ declare function acquireStateLock(stateDir: string, deps?: AcquireLockDeps): Promise<StateLock>;
767
+ /**
768
+ * Read-only probe for `doctor`: report who (if anyone) holds the lock
769
+ * and whether it looks stale from this host's perspective.
770
+ */
771
+ declare function probeStateLock(stateDir: string, deps?: AcquireLockDeps): Promise<{
772
+ held: boolean;
773
+ holder: LockHolderInfo | null;
774
+ stale: boolean;
775
+ }>;
776
+
777
+ /**
778
+ * Startup recovery for the durable operation journal (ISSUE-0623).
779
+ *
780
+ * Runs once in `createServer()` after the state lock is held and the
781
+ * envelope is decrypted, BEFORE any tool can execute. Each surviving
782
+ * `state.pending` entry is a `seal_share` that crashed (or lost its
783
+ * process) mid-flight; the step recorded at the last checkpoint tells
784
+ * us exactly what is safe to do:
785
+ *
786
+ * - `preparing` — WITHOUT a seal_id: no remote effect ever
787
+ * confirmed. Nothing to compensate; drop the entry
788
+ * (backend GC owns incomplete multipart uploads).
789
+ * WITH a seal_id (journaled by the upload engine's
790
+ * onSealCreated hook): the seal is live — treat it
791
+ * like `uploaded` and revoke the orphan.
792
+ * - `uploaded` — ciphertext live, URL never delivered anywhere.
793
+ * A live seal whose link nobody holds is pure
794
+ * orphan storage: revoke it (awaited).
795
+ * - `awaiting_payment` — an unpaid seal over 1 GB; what would finish
796
+ * it died with the process. Revoked like `uploaded`
797
+ * (the revoke closes its payment page).
798
+ * - `delivering` — the handoff attempt started, so the URL may
799
+ * already sit in the user's clipboard / 0o600
800
+ * file. Revoking would kill a link the user may
801
+ * have sent already — instead ADOPT the op as a
802
+ * durable handle. The owner keeps control: it
803
+ * shows up in `seal_list` and can be revoked
804
+ * explicitly via `seal_revoke`.
805
+ * - `compensating`— an earlier awaited revoke failed and was
806
+ * journaled; retry it now.
807
+ *
808
+ * Revoke failures stay in the journal (`step='compensating'`, outcome
809
+ * recorded) and are retried at the next startup — compensation is
810
+ * durable, never fire-and-forget.
811
+ */
812
+
813
+ /** Minimal client slice recovery needs — keeps tests trivial. */
814
+ interface RevokeClient {
815
+ /** Backend base URL this client talks to (provenance comparisons). */
816
+ readonly baseUrl: string;
817
+ revoke(sealId: string, ownerTokenB64: string): Promise<unknown>;
818
+ }
819
+ interface RecoveryReport {
820
+ /** `delivering` ops adopted as durable handles. */
821
+ readonly adopted: string[];
822
+ /** Orphan seals successfully revoked (`uploaded` / retry). */
823
+ readonly revoked: string[];
824
+ /** Revoke retries that failed again — kept in the journal. */
825
+ readonly stillFailing: string[];
826
+ /** `preparing` entries dropped (no remote effect to undo). */
827
+ readonly dropped: string[];
828
+ }
829
+ /**
830
+ * Reconcile `view.pending` in place. Returns the report + whether the
831
+ * view changed (caller persists once at the end — a crash during
832
+ * recovery just means the same idempotent pass reruns next start).
833
+ */
834
+ declare function recoverPendingOperations(view: StateView, client: RevokeClient, opts?: {
835
+ now?: () => Date;
836
+ log?: (line: string) => void;
837
+ }): Promise<{
838
+ report: RecoveryReport;
839
+ changed: boolean;
840
+ }>;
841
+
842
+ /**
843
+ * State-path resolver for `sealnet-mcp` (the directory keeps the name `seal-mcp`).
844
+ *
845
+ * Resolution priority (highest wins):
846
+ * 1. `--state-dir <path>` CLI flag (caller passes via `cliFlag`).
847
+ * 2. `SEAL_MCP_CONFIG_DIR` environment variable. Mirrors the Rust
848
+ * `seal-cli` (`seal/clients/core/src/config.rs::Config::dir`)
849
+ * so power users with multiple profiles get the same override
850
+ * knob across both clients.
851
+ * 3. OS-native default:
852
+ * - macOS: `~/Library/Application Support/seal-mcp`
853
+ * - Linux/*BSD: `$XDG_CONFIG_HOME ?? ~/.config/seal-mcp`
854
+ * - Windows: `$APPDATA/seal-mcp` (or `~/AppData/Roaming/seal-mcp`)
855
+ * Matches `env-paths` (Sindre Sorhus) without taking the dep.
856
+ *
857
+ * Pure function — does not touch the filesystem. Callers `mkdir` lazily
858
+ * via `state.saveState` which already enforces `0o700` on the dir and
859
+ * `0o600` on the envelope.
860
+ */
861
+ declare const APP_NAME = "seal-mcp";
862
+ declare const STATE_FILE_NAME = "state.json";
863
+ type StateDirSource = 'flag' | 'env' | 'os-default';
864
+ interface PathResolution {
865
+ readonly dir: string;
866
+ readonly statePath: string;
867
+ readonly source: StateDirSource;
868
+ }
869
+ interface ResolveOptions {
870
+ /** Explicit `--state-dir <path>` from commander. Empty/undefined = skip. */
871
+ readonly cliFlag?: string;
872
+ /** Override process.env for tests. */
873
+ readonly env?: NodeJS.ProcessEnv;
874
+ /** Override process.platform for tests (default current). */
875
+ readonly platform?: NodeJS.Platform;
876
+ /** Override os.homedir() for tests. */
877
+ readonly home?: string;
878
+ }
879
+ declare function resolveStateDir(opts?: ResolveOptions): PathResolution;
880
+
881
+ /**
882
+ * Passphrase resolution + hidden TTY prompt for `seal-mcp`.
883
+ *
884
+ * Priority chain (per `shared/contracts/seal_mcp.v1.json::passphrase_delivery`):
885
+ *
886
+ * 1. OS keychain via `keytar` (service `'seal-mcp'`, account `'state'`).
887
+ * 2. `SEAL_MCP_PASSPHRASE` environment variable. Logs a warning to
888
+ * stderr because env vars are visible via `/proc/<pid>/environ`
889
+ * on Linux, `ps -E`/`procstat -e` on BSD, and via the process
890
+ * object on Windows.
891
+ * 3. Cleartext on disk — REFUSED. Listed only to document the
892
+ * refusal; the resolver has no codepath that reads from a file.
893
+ *
894
+ * `keytar` is loaded lazily (same pattern as `clipboardy` in
895
+ * `handoff.ts`) so headless environments lacking `libsecret-1.so.0`
896
+ * (Linux) can still fall back to the env var without crashing the CLI
897
+ * at import time.
898
+ */
899
+ declare const KEYCHAIN_SERVICE: "seal-mcp";
900
+ declare const KEYCHAIN_ACCOUNT: "state";
901
+ declare const PASSPHRASE_ENV_VAR: "SEAL_MCP_PASSPHRASE";
902
+ type PassphraseSource = 'keychain' | 'env';
903
+ interface ResolvedPassphrase {
904
+ readonly passphrase: string;
905
+ readonly source: PassphraseSource;
906
+ }
907
+ /**
908
+ * Surface used by `passphrase.ts` — kept narrow on purpose so tests can
909
+ * stub keytar without pulling in the native module.
910
+ */
911
+ interface KeytarLike {
912
+ getPassword(service: string, account: string): Promise<string | null>;
913
+ setPassword(service: string, account: string, password: string): Promise<void>;
914
+ deletePassword(service: string, account: string): Promise<boolean>;
915
+ }
916
+ declare class KeytarUnavailableError extends Error {
917
+ readonly code = "keytar_unavailable";
918
+ constructor(detail: string);
919
+ }
920
+ declare class PassphraseNotFoundError extends Error {
921
+ readonly code = "passphrase_not_found";
922
+ constructor();
923
+ }
924
+ /**
925
+ * Try keychain first, then env var. NEVER reads from a file. Returns
926
+ * `null` if neither source has a passphrase — caller decides whether to
927
+ * prompt interactively (`init`) or hard-refuse (`serve`).
928
+ */
929
+ declare function resolvePassphraseFromStores(env?: NodeJS.ProcessEnv): Promise<ResolvedPassphrase | null>;
930
+ /**
931
+ * Probe whether the OS keychain is usable AND whether the seal-mcp
932
+ * entry already exists. Used by `sealnet-mcp doctor`.
933
+ */
934
+ declare function probeKeychain(): Promise<{
935
+ available: boolean;
936
+ hasEntry: boolean;
937
+ }>;
938
+ declare function setKeychainPassphrase(passphrase: string): Promise<void>;
939
+ declare function deleteKeychainPassphrase(): Promise<boolean>;
940
+ /**
941
+ * Prompt for a passphrase on the TTY without echoing keystrokes.
942
+ *
943
+ * Requires `process.stdin.isTTY === true` — caller must verify ahead
944
+ * of time and route non-TTY callers to the env-var path.
945
+ *
946
+ * Special keys:
947
+ * - `\r` / `\n` → resolve with the accumulated buffer.
948
+ * - `\u0003` (Ctrl-C) → reject with `cancelled`.
949
+ * - `\u0004` (Ctrl-D) on empty buffer → reject with `cancelled`.
950
+ * - `\u007f` / `\b` (Backspace) → delete last char.
951
+ */
952
+ declare function promptHiddenPassphrase(prompt: string): Promise<string>;
953
+
954
+ /**
955
+ * Shared `ToolContext` and helpers used by every tool implementation.
956
+ *
957
+ * Each tool is a pure async function `(ctx, input) → output` so the
958
+ * `server.ts` bootstrap can wire tools to the MCP SDK by registering
959
+ * arrow-functions that close over a single `ToolContext` instance.
960
+ * Keeps the tool implementations test-friendly (inject a fake
961
+ * context) and prevents accidental global state.
962
+ */
963
+
964
+ /** Mutable, single-source-of-truth state passed across tool calls. */
965
+ interface ToolContext {
966
+ readonly state: StateView;
967
+ readonly identity: Identity;
968
+ readonly client: S;
969
+ /**
970
+ * Persistence hook. The server is responsible for serialising and
971
+ * encrypting `ctx.state` to disk after a successful mutation; tools
972
+ * mutate `ctx.state` in-place and call `await ctx.persist()` when
973
+ * ready. Errors propagate to the tool which surfaces them as a
974
+ * structured MCP error to the model.
975
+ */
976
+ persist: () => Promise<void>;
977
+ /** No keychain, nothing on disk: handles die with the process (receipt says so). */
978
+ readonly ephemeral?: boolean;
979
+ /** `seal_request` handles waiting on a link, to their intake id; in memory only. */
980
+ readonly requests?: Map<string, string>;
981
+ /**
982
+ * `seal_share` handles waiting for a payment (SPEC-AGENTS §5.2), to what
983
+ * finishes them; in memory only — a restart revokes the unpaid seal
984
+ * (`recovery.ts`, step `awaiting_payment`).
985
+ */
986
+ readonly payments?: Map<string, PendingPayment>;
987
+ /** UTC-now hook for deterministic testing. */
988
+ now?: () => Date;
989
+ }
990
+ /** A share stopped on 402: `finish` waits for the payment and completes it. */
991
+ interface PendingPayment {
992
+ readonly finish: (onProgress?: (done: number, total: number) => void) => Promise<unknown>;
993
+ }
994
+ /**
995
+ * Generate an opaque handle of the form `h_<12 base64url chars>`,
996
+ * matching `seal_mcp.v1.json::output_handoff.handle.pattern`.
997
+ *
998
+ * 9 random bytes = 72 bits of entropy. With ≤10^6 handles in flight
999
+ * the birthday-collision probability is ≈ 2·10^-11 — well below the
1000
+ * MCP server's effective lifetime.
1001
+ */
1002
+ declare function generateHandle(): string;
1003
+ /** Validate a handle against the contract pattern. */
1004
+ declare function isValidHandle(handle: string): boolean;
1005
+ /**
1006
+ * Tool-side error type. The server converts these into structured
1007
+ * MCP error responses with `code` as the error symbol.
1008
+ */
1009
+ declare class ToolError extends Error {
1010
+ readonly code: string;
1011
+ readonly cause?: unknown;
1012
+ constructor(code: string, message: string, cause?: unknown);
1013
+ }
1014
+
1015
+ /**
1016
+ * `seal_list` — return the model-visible view of locally-tracked
1017
+ * handles. Pure local state read; no backend round-trip required.
1018
+ *
1019
+ * Output shape and field names track
1020
+ * `shared/contracts/seal_mcp.v1.json::tools.seal_list.output` exactly
1021
+ * (FINDINGS Bug #5 fix, 2026-05-26). Pre-fix history:
1022
+ *
1023
+ * - Pre-Bug-#5 wire: `{ entries: [{ handle, createdAt, expiresAt,
1024
+ * mode, revokedAt? }] }`. snake_vs_camel + missing `status` +
1025
+ * missing `file_count` + extra `expiresAt`/`revokedAt`. Any host
1026
+ * validating against the published contract would have rejected
1027
+ * it; golden phase 3.6b ("strictly matches the v1 contract shape")
1028
+ * surfaced this divergence.
1029
+ *
1030
+ * Why wrap in `{entries:[]}` instead of returning a bare array (which
1031
+ * the original contract said): MCP's `CallToolResult.structuredContent`
1032
+ * is typed `Record<string, unknown>` and a validating host will reject
1033
+ * a top-level array there. The contract was updated to match this
1034
+ * MCP-level constraint in the same patch as this file.
1035
+ *
1036
+ * The expired/revoked filter still excludes "dead" handles by default
1037
+ * so the model doesn't waste turns trying to `seal_open` /
1038
+ * `seal_revoke` them. Revoke on an already-revoked handle is
1039
+ * idempotent per `seal_revoke`, but listing it is just noise.
1040
+ */
1041
+
1042
+ interface SealListInput {
1043
+ /** If true, include entries with status 'expired' or 'revoked'; defaults false. */
1044
+ readonly includeExpired?: boolean;
1045
+ }
1046
+ type SealListStatus = 'active' | 'expired' | 'revoked';
1047
+ /**
1048
+ * One row in `seal_list.output.entries`. Field set and casing must
1049
+ * stay aligned with `seal_mcp.v1.json::seal_list.output.entries.items`
1050
+ * — that schema has `additionalProperties: false`, so adding a field
1051
+ * here without bumping the contract will be rejected by validating
1052
+ * MCP hosts.
1053
+ */
1054
+ interface SealListEntry {
1055
+ readonly handle: string;
1056
+ readonly created_at: string;
1057
+ readonly status: SealListStatus;
1058
+ /**
1059
+ * Always `1` today: `seal_share` accepts a single `path`. The
1060
+ * contract reserves the field so a future multi-file `seal_share`
1061
+ * doesn't need a v2 bump.
1062
+ */
1063
+ readonly file_count: number;
1064
+ readonly mode: 'handoff' | 'forward';
1065
+ }
1066
+ interface SealListOutput {
1067
+ readonly entries: SealListEntry[];
1068
+ }
1069
+ declare function sealList(ctx: ToolContext, input?: SealListInput): Promise<SealListOutput>;
1070
+
1071
+ /**
1072
+ * `seal_open` — read a SEAL link: describe it, return small text inline, or
1073
+ * stream every file it carries to disk.
1074
+ *
1075
+ * Reading the link is the protocol's (`openShareLink`: parse, unwrap a
1076
+ * targeted fragment with THIS server's X25519 identity, public metadata,
1077
+ * share blob, file names) — the same code the SDK opens links with (И4). What
1078
+ * stays here is this tool's policy:
1079
+ *
1080
+ * Metadata (default): the first file's name, MIME and size, the file count,
1081
+ * the seal's flags. No download, no counter tick.
1082
+ *
1083
+ * Inline: refused WITHOUT a download — so a refusal never burns
1084
+ * `single_use`/`max_opens` — when the MIME is not JSON/CSV/plain text
1085
+ * (HTML/PDF/XHTML carry prompt-injection risk, binary cannot be sanitised),
1086
+ * when the sender marked it a secret (`inline_blocked.secret`, И2), or when
1087
+ * the ciphertext exceeds `INLINE_CIPHERTEXT_MAX_BYTES`. Otherwise one
1088
+ * download, decrypted through `decryptVerified` (ciphertext digest checked
1089
+ * against the one registered at upload, ISSUE-0606), a plaintext cap, and a
1090
+ * fatal UTF-8 decode.
1091
+ *
1092
+ * File (SPEC-AGENTS §5.1): every file the link carries a key for, streamed to
1093
+ * disk by `@seal/protocol/node` — the name given only after the digest
1094
+ * matched, 0600, memory one segment whatever the size. The model gets paths,
1095
+ * sizes and plaintext digests, never content. Each download counts against
1096
+ * the seal like a browser's.
1097
+ */
1098
+
1099
+ interface SealOpenInput {
1100
+ readonly url: string;
1101
+ readonly mode?: 'metadata' | 'inline' | 'file';
1102
+ /** `file` mode: directory to write into; default `~/Downloads`, created if missing. */
1103
+ readonly dir?: string;
1104
+ /** `file` mode: ciphertext bytes received so far, out of the seal's total. */
1105
+ readonly onProgress?: (done: number, total: number) => void;
1106
+ }
1107
+ /** One file of the seal, written to disk (`mode: 'file'`). */
1108
+ interface SealOpenFileEntry {
1109
+ readonly path: string;
1110
+ readonly filename: string;
1111
+ readonly sizeBytes: number;
1112
+ readonly mime: string;
1113
+ readonly sha256: string;
1114
+ }
1115
+ interface SealOpenFileOutput {
1116
+ readonly handle: string;
1117
+ readonly files: readonly SealOpenFileEntry[];
1118
+ }
1119
+ interface SealOpenMetadataOutput {
1120
+ readonly handle: string;
1121
+ readonly filename: string;
1122
+ readonly sizeBytes: number;
1123
+ readonly mime: string;
1124
+ readonly fileCount: number;
1125
+ readonly ownerEmailHash: string | null;
1126
+ readonly geoRestriction: string | null;
1127
+ readonly singleUse: boolean;
1128
+ readonly maxReads: number;
1129
+ readonly expiresAt: string;
1130
+ }
1131
+ interface SealOpenInlineRefusal {
1132
+ readonly handle: string;
1133
+ readonly refusal: 'inline_blocked.binary' | 'inline_blocked.injection_risk' | 'inline_blocked.too_large' | 'inline_blocked.secret';
1134
+ readonly mime: string | null;
1135
+ readonly note: string;
1136
+ }
1137
+ /** Successful inline body — the model gets the decrypted UTF-8 text. */
1138
+ interface SealOpenInlineSuccess {
1139
+ readonly handle: string;
1140
+ readonly mime: 'application/json' | 'text/csv' | 'text/plain';
1141
+ readonly sizeBytes: number;
1142
+ readonly encoding: 'utf-8';
1143
+ readonly content: string;
1144
+ }
1145
+ type SealOpenOutput = SealOpenMetadataOutput | SealOpenInlineRefusal | SealOpenInlineSuccess | SealOpenFileOutput;
1146
+ declare function sealOpen(ctx: ToolContext, input: SealOpenInput): Promise<SealOpenOutput>;
1147
+
1148
+ /**
1149
+ * `seal_revoke` — invalidate a SEAL by opaque handle.
1150
+ *
1151
+ * The model never learns the raw `seal_id` or `owner_token`; we look
1152
+ * them up from the handle row in `state.handles`. Idempotent:
1153
+ * revoking an already-revoked / already-expired handle returns
1154
+ * `{revoked: true}` without error, per
1155
+ * `seal_mcp.v1.json::tools.seal_revoke.output`.
1156
+ */
1157
+
1158
+ interface SealRevokeInput {
1159
+ readonly handle: string;
1160
+ }
1161
+ interface SealRevokeOutput {
1162
+ readonly revoked: boolean;
1163
+ readonly revokedAt: string;
1164
+ }
1165
+ declare function sealRevoke(ctx: ToolContext, input: SealRevokeInput): Promise<SealRevokeOutput>;
1166
+
1167
+ /**
1168
+ * `seal_share` — create a new SEAL from a local file or folder and deliver the
1169
+ * share link (key in its fragment, no separate password) out-of-band
1170
+ * (handoff) or back to the model (forward, targeted recipient only,
1171
+ * hard policy gate).
1172
+ *
1173
+ * # Modes
1174
+ *
1175
+ * **`handoff` (default)** — URL delivered via clipboard or 0o600 file.
1176
+ * Model receives only an opaque `handle` and a receipt. Anonymous_v1
1177
+ * fragment (raw link_key in URL).
1178
+ *
1179
+ * **`forward` (opt-in, Phase 3.5d)** — URL returned in the tool
1180
+ * response so the model can paste it into chat. Hard policy:
1181
+ * - `to` REQUIRED (X25519 recipient pubkey, 32 B base64url-no-pad)
1182
+ * - `expire ≤ 30m` (enforced client-side; backend also caps at
1183
+ * `MAX_FORWARD_EXPIRE_MINUTES`)
1184
+ * - `max_reads = 1` (locked — any other value rejected)
1185
+ * Wire format: `targeted_x25519_v1` (see
1186
+ * `shared/contracts/seal_share_url.v1.json`). Fragment built by
1187
+ * `@seal/protocol::buildTargetedFragment` using the same WASM
1188
+ * `wasm_sealbox_wrap` as the Rust CLI's `seal send --to`.
1189
+ *
1190
+ * # Pipeline
1191
+ *
1192
+ * 1. **forward only**: validate `to` + `expire ≤ 30m` + `max_reads = 1`.
1193
+ * Validation happens BEFORE upload so a bad request doesn't
1194
+ * leak an orphaned ciphertext on the backend.
1195
+ * 2. Open the path lazily as a `globalThis.File` (`openSource`): a
1196
+ * file as it is, a folder as one ZIP streamed into a private 0600
1197
+ * temp file (`zip.ts`, SPEC-AGENTS §5.1) and removed when the call
1198
+ * ends. Above the free limit the seal is created with a tier and
1199
+ * paid for (`awaitPayment`, §5.2); more than the tier holds is
1200
+ * refused before upload. Without `expire`, a handoff over 100 MiB
1201
+ * lives 1d instead of 5m (contract `expire_default`).
1202
+ * 3. Generate ownerToken (32 B) + ownerTokenHash via the bundled e2ee primitives.
1203
+ * 4. **Journal (ISSUE-0623)**: persist `state.pending[handle] =
1204
+ * { owner_token, mode, step: 'preparing', … }` BEFORE the first
1205
+ * remote effect. Every step below updates the journal; every
1206
+ * post-upload failure runs an AWAITED revoke compensation whose
1207
+ * outcome is recorded (and retried at startup by `recovery.ts`
1208
+ * if it fails). Completion removes the journal entry in the same
1209
+ * envelope write that inserts the durable handle.
1210
+ * 5. `uploadSingleFile(...)` — full AEON streaming upload via the
1211
+ * multipart protocol; identical wire format to the browser
1212
+ * (Phase 2.4 unified WASM init across runtimes). Journal step →
1213
+ * `uploaded` (seal_id recorded — full revoke capability durable).
1214
+ * 6. Generate a separate `shareLinkKey` and encrypt a share-blob
1215
+ * containing `{ file_keys: { sha256_hex: file_key_b64 } }`.
1216
+ * 7. `POST /seal/{id}/share` with the encrypted blob → server
1217
+ * allocates `share_code`.
1218
+ * 8. Build URL fragment:
1219
+ * - **handoff**: `base64url(shareLinkKey)` (anonymous_v1)
1220
+ * - **forward**: `x25519:` || `base64url(packed)` (targeted_x25519_v1)
1221
+ * 9. Deliver:
1222
+ * - **handoff**: journal step → `delivering` (+ attempted
1223
+ * channels), then `performHandoff` (clipboard / 0o600 file)
1224
+ * - **forward**: return `share_url` in the tool response (model gets it)
1225
+ * 10. Persist `state.handles[handle] = { seal_id, owner_token, mode,
1226
+ * expires_at, created_at, handoff_channel? }` + drop the journal
1227
+ * entry (single atomic write).
1228
+ *
1229
+ * # Zero-knowledge invariants enforced
1230
+ * - `handle` returned to the model contains no capability material.
1231
+ * - The share blob is opaque to the backend (zero-knowledge).
1232
+ * - **handoff mode**: URL never appears in the tool result.
1233
+ * - **forward mode**: URL IS in the tool result (this is the point);
1234
+ * for `targeted_x25519_v1` URLs the URL alone is NOT a usable
1235
+ * capability without the recipient's identity secret, so leaking
1236
+ * the URL (e.g. via chat history) does not compromise content.
1237
+ */
1238
+
1239
+ type SealShareMode = 'handoff' | 'forward';
1240
+ interface SealShareInput {
1241
+ /** Required unless `payment` is given. */
1242
+ readonly path?: string;
1243
+ /** Over 1 GB: the tier to pay for; default the smallest that fits. */
1244
+ readonly tier?: string;
1245
+ /** Handle of a share waiting for its payment: wait for it and finish. */
1246
+ readonly payment?: string;
1247
+ readonly mode?: SealShareMode;
1248
+ readonly expire?: string;
1249
+ readonly maxReads?: number;
1250
+ readonly burn?: boolean;
1251
+ readonly to?: string;
1252
+ /**
1253
+ * Explicit MIME override. Defaults to extension-based detection via
1254
+ * `guessMimeFromName`; unknown extensions get
1255
+ * `application/octet-stream`. Encrypted into the per-file metadata
1256
+ * bundle (zero-knowledge to backend).
1257
+ */
1258
+ readonly mime?: string;
1259
+ /** Ciphertext bytes storage has confirmed, out of the upload's total. */
1260
+ readonly onProgress?: (done: number, total: number) => void;
1261
+ }
1262
+ interface SealShareHandoffOutput {
1263
+ readonly handle: string;
1264
+ readonly receipt: string;
1265
+ readonly handoffChannel: 'clipboard' | 'file';
1266
+ readonly handoffFile?: string;
1267
+ readonly expiresAt: string;
1268
+ }
1269
+ /**
1270
+ * Forward-mode output (contract: `seal_mcp.v1.json::seal_share.output_forward`).
1271
+ * Includes `shareUrl` — model receives the targeted-X25519 URL. The
1272
+ * URL alone is not a usable capability without the recipient's
1273
+ * identity secret. `password` is omitted because the targeted_x25519_v1
1274
+ * fragment carries the wrapped key inline (zero-knowledge to backend).
1275
+ */
1276
+ interface SealShareForwardOutput {
1277
+ readonly handle: string;
1278
+ readonly shareUrl: string;
1279
+ readonly expiresAt: string;
1280
+ readonly maxReads: 1;
1281
+ readonly mode: 'forward';
1282
+ }
1283
+ /**
1284
+ * Over 1 GB without URL-mode elicitation (contract `seal_share.output_payment`):
1285
+ * the model shows `paymentUrl` and calls again with `payment: handle`.
1286
+ */
1287
+ interface SealSharePaymentOutput {
1288
+ readonly handle: string;
1289
+ readonly paymentUrl: string;
1290
+ readonly tier: string;
1291
+ /** Payment deadline; the seal is revoked after it. */
1292
+ readonly expiresAt: string;
1293
+ readonly receipt: string;
1294
+ }
1295
+ type SealShareOutput = SealShareHandoffOutput | SealShareForwardOutput | SealSharePaymentOutput;
1296
+ interface SealShareDeps {
1297
+ readonly handoff?: HandoffDeps;
1298
+ /**
1299
+ * Override the public origin share URLs are built on. Default is the
1300
+ * `config.ts` resolver chain (`SEAL_MCP_PUBLIC_HOST` env →
1301
+ * `https://seal.net`) — NOT derived from the backend URL, which in
1302
+ * production is the separate `https://api.seal.net` origin serving
1303
+ * no frontend (ISSUE-0622).
1304
+ */
1305
+ readonly publicHost?: string;
1306
+ /**
1307
+ * Inject the upload pipeline. Default: the bundled `uploadSingleFile`
1308
+ * streaming uploader. Tests substitute a fixture that returns a
1309
+ * canned `UploadResult` so they don't need a live backend.
1310
+ * Production code must NOT pass this — the default is the
1311
+ * cross-stack-verified streaming uploader.
1312
+ */
1313
+ readonly uploadSingleFile?: typeof uploadSingleFile;
1314
+ /** URL-mode elicitation for the payment page, when the client supports it. */
1315
+ readonly elicitUrl?: (params: {
1316
+ url: string;
1317
+ message: string;
1318
+ elicitationId: string;
1319
+ }) => Promise<string>;
1320
+ /** Tell the client the out-of-band part is done (`notifications/elicitation/complete`). */
1321
+ readonly elicitationDone?: (elicitationId: string) => Promise<void>;
1322
+ }
1323
+ declare function sealShare(ctx: ToolContext, input: SealShareInput, deps?: SealShareDeps): Promise<SealShareOutput>;
1324
+
1325
+ export { APP_NAME, type AcquireLockDeps, type AuditEntry, BACKEND_URL_ENV_VAR, DEFAULT_BACKEND_URL, DEFAULT_PUBLIC_HOST, type HandleEntry, HandoffBothChannelsFailedError, type HandoffPayload, type HandoffResult, type Identity, type IdentityFields, KEYCHAIN_ACCOUNT, KEYCHAIN_SERVICE, type KeytarLike, KeytarUnavailableError, LOCK_FILE_NAME, type LockHolderInfo, type McpServerHandle, type McpServerOptions, PASSPHRASE_ENV_VAR, PUBLIC_HOST_ENV_VAR, PassphraseNotFoundError, type PassphraseSource, type PathResolution, type PendingCompensation, type PendingOperation, type PendingStep, type RecoveryReport, type ResolveOptions, type ResolvedPassphrase, type RevokeClient, STATE_FILE_NAME, type SealListInput, type SealListOutput, type SealOpenInput, type SealOpenOutput, type SealRevokeInput, type SealRevokeOutput, type SealShareInput, type SealShareMode, type SealShareOutput, StateCorrupt, type StateDirSource, type StateLock, StateLockedError, StatePassphraseInvalid, type StateView, ToolError, acquireStateLock, createEmptyState, createServer, deleteKeychainPassphrase, generateHandle, generateIdentity, identityToFields, isValidHandle, loadIdentity, loadState, performHandoff, probeKeychain, probeStateLock, promptHiddenPassphrase, recoverPendingOperations, resolveBackendUrl, resolvePassphraseFromStores, resolvePublicHost, resolveStateDir, saveState, sealList, sealOpen, sealRevoke, sealShare, setKeychainPassphrase };