@akms/mcp-wsl 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.d.ts ADDED
@@ -0,0 +1,456 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { SshPolicy, GuardVerdict, RemotePathAccess } from '@akms/mcp-ssh';
3
+ export { GuardRejectionError, GuardVerdict } from '@akms/mcp-ssh';
4
+ import { Readable, Writable } from 'node:stream';
5
+
6
+ /**
7
+ * Guard rules applied to every command / file operation in the distro.
8
+ *
9
+ * The `@akms/mcp-ssh` policy verbatim, so its guards accept it unchanged. `connectTimeoutMs`
10
+ * has no meaning here (nothing connects) and is carried as 0.
11
+ */
12
+ type WslPolicy = SshPolicy;
13
+ /** The registered distro: which one, as whom, what is permitted. */
14
+ interface WslHostProfile {
15
+ /** Alias shown to the model, from `WSL_NAME`; defaults to the distro name. */
16
+ name: string;
17
+ /** Distro name as `wsl -l -q` prints it. */
18
+ distro: string;
19
+ /** Linux user passed as `wsl -u`; the distro's default user when absent. */
20
+ username?: string;
21
+ description?: string;
22
+ /** Directory every session starts in; falls back to the login shell's default. */
23
+ defaultCwd?: string;
24
+ /**
25
+ * Whether the agent may leave the distro for Windows — run interop executables, or
26
+ * write under `/mnt/<drive>/`. Off by default: those paths reach the machine that runs
27
+ * this server and holds its configuration.
28
+ */
29
+ allowWindows: boolean;
30
+ policy: WslPolicy;
31
+ }
32
+ /** View of the profile handed back to the model. */
33
+ interface WslHostSummary {
34
+ name: string;
35
+ distro: string;
36
+ /** The configured user, or the distro default when none was set. */
37
+ username: string;
38
+ description?: string;
39
+ readonly: boolean;
40
+ allowSudo: boolean;
41
+ allowWindows: boolean;
42
+ allowedPaths: string[];
43
+ allowCommands: string[];
44
+ }
45
+
46
+ /** Outcome of one command in the distro. */
47
+ interface WslExecResult {
48
+ stdout: string;
49
+ stderr: string;
50
+ /** Exit status of the Linux command; null when `wsl.exe` was killed before it reported one. */
51
+ exitCode: number | null;
52
+ /** Signal that killed `wsl.exe`, e.g. "SIGTERM". */
53
+ signal?: string;
54
+ /** True when the timeout fired before the command finished. */
55
+ timedOut: boolean;
56
+ /** True when stdout or stderr hit `maxOutputCharacters` and was cut. */
57
+ truncated: boolean;
58
+ /** Working directory after the command ran; only tracked for session-bound execs. */
59
+ cwd?: string;
60
+ durationMs: number;
61
+ }
62
+ /** Live session as reported by `wsl_list_sessions`. */
63
+ interface WslSessionInfo {
64
+ sessionId: string;
65
+ profileName: string;
66
+ distro: string;
67
+ username: string;
68
+ cwd: string;
69
+ /** ISO-8601 timestamps. */
70
+ openedAt: string;
71
+ lastUsedAt: string;
72
+ execCount: number;
73
+ }
74
+ /** One entry of a directory listing. */
75
+ interface WslDirectoryEntry {
76
+ name: string;
77
+ type: "file" | "directory" | "symlink" | "other";
78
+ sizeBytes: number;
79
+ /** Octal permission string, e.g. "0644". */
80
+ mode: string;
81
+ modifiedAt: string;
82
+ }
83
+ /** What `wsl.exe -l -v` and a probe inside the distro report — the facts infra-wsl reasoning needs. */
84
+ interface WslDistroStatus {
85
+ /** False when `wsl -l -v` does not list the distro at all. */
86
+ installed: boolean;
87
+ state: "Running" | "Stopped" | "Installing" | "unknown";
88
+ /** WSL 1 or 2, from the listing. */
89
+ wslVersion?: number;
90
+ /** `wslinfo --networking-mode`: nat / mirrored / …; only probed while the distro runs. */
91
+ networkingMode?: string;
92
+ /** True when PID 1 is systemd. */
93
+ systemd?: boolean;
94
+ kernel?: string;
95
+ }
96
+
97
+ interface WslExecOptions {
98
+ command: string;
99
+ /** Directory to `cd` into first; the login shell's default is used when absent. */
100
+ cwd?: string;
101
+ timeoutMs: number;
102
+ maxOutputCharacters: number;
103
+ /** Appends a `$PWD` marker so a session can follow `cd` between commands. */
104
+ trackCwd: boolean;
105
+ }
106
+ /** A fixed script run without the command guard — the file operations, whose only variable part is a quoted path. */
107
+ interface WslProcessOptions {
108
+ script: string;
109
+ /** Fed to the process and then closed; absent means stdin is closed from the start. */
110
+ stdin?: Buffer | Readable;
111
+ /** When set, stdout streams here instead of being buffered, and `stdout` comes back empty. */
112
+ stdoutSink?: Writable;
113
+ timeoutMs: number;
114
+ /** Bytes buffered per stream before the process is killed. */
115
+ maxCapturedBytes: number;
116
+ }
117
+ interface WslProcessOutput {
118
+ stdout: Buffer;
119
+ stderr: Buffer;
120
+ /** Bytes that went to `stdoutSink`, when one was given. */
121
+ sinkBytes: number;
122
+ exitCode: number | null;
123
+ signal?: string;
124
+ timedOut: boolean;
125
+ capExceeded: boolean;
126
+ durationMs: number;
127
+ }
128
+ /**
129
+ * One distro + user, plus the operations run in it. Every call is its own `wsl.exe`
130
+ * process; nothing is held between calls.
131
+ *
132
+ * This class is the chokepoint: it holds the profile, so it applies the command guard
133
+ * itself rather than trusting each caller to remember — the same reasoning as
134
+ * `SshConnection` in `@akms/mcp-ssh`.
135
+ */
136
+ declare class WslHost {
137
+ readonly profile: WslHostProfile;
138
+ constructor(profile: WslHostProfile);
139
+ /**
140
+ * Screens a command against this profile's policy, then runs it.
141
+ *
142
+ * The canonical form is what gets screened *and* what gets sent — validating one
143
+ * string while executing another is a bypass by construction.
144
+ *
145
+ * @throws {GuardRejectionError} If the policy refuses the command or the cwd.
146
+ * @throws {Error} If `wsl.exe` itself fails (unknown distro, service down) or cannot be spawned.
147
+ */
148
+ exec(options: WslExecOptions): Promise<WslExecResult>;
149
+ /**
150
+ * Spawns `wsl.exe` for one script and collects what it produced.
151
+ *
152
+ * Not guarded — callers own that: `exec` screens the command, the file operations
153
+ * screen the path and build the script themselves. stdin is closed (or fed and then
154
+ * closed) so anything that waits for input fails at once instead of hanging until the
155
+ * timeout.
156
+ *
157
+ * @throws {Error} If the process ceiling is reached, `wsl.exe` cannot be started, or
158
+ * `wsl.exe` reports a failure of its own rather than the command's exit
159
+ * status — that message (unknown distro, service down) is the whole diagnosis.
160
+ */
161
+ runProcess(options: WslProcessOptions): Promise<WslProcessOutput>;
162
+ }
163
+
164
+ /**
165
+ * The `wsl.exe` argument vector for running `script` in the profile's distro as its user.
166
+ *
167
+ * `--exec`, never `--`. With `--`, wsl.exe hands the arguments to the distro's *default
168
+ * shell* for a second parse: backslashes vanish (`printf '\n'` breaks, `find … \;`
169
+ * breaks) and `$HOME` / `$PATH` are expanded before bash ever sees the script — that is,
170
+ * before the text the guard screened is the text that runs. `--exec` is a straight
171
+ * execve, so the argument arrives byte for byte; `bash -lc` then gives the login
172
+ * environment (PATH from `.profile`, nvm) that `--exec` alone would skip.
173
+ */
174
+ declare function buildWslArguments(profile: WslHostProfile, script: string): string[];
175
+ /**
176
+ * Wraps the user command so bash reports where it ended up.
177
+ *
178
+ * The `cd` is done inside the script rather than with `wsl --cd`: a missing directory
179
+ * makes `--cd` fall back to `/` with exit 0 and a warning on stderr, so the command
180
+ * would run in the wrong place and look successful.
181
+ *
182
+ * `$?` is captured before the marker is printed and replayed via `exit`, so the exit
183
+ * status the caller sees is the command's, not `printf`'s.
184
+ *
185
+ * `cwd` appears exactly once, inside `quoteShellArgument`. It must never be interpolated
186
+ * anywhere else — echoing it into a double-quoted diagnostic would let
187
+ * `cwd: '/x"; rm -rf /; #'` run arbitrary commands with every guard bypassed. The
188
+ * failure message is therefore a fixed string.
189
+ */
190
+ declare function buildDistroScript(command: string, cwd: string | undefined, trackCwd: boolean): string;
191
+ /** Splits the trailing `$PWD` marker off stdout. */
192
+ declare function extractCwdMarker(stdout: string): {
193
+ output: string;
194
+ cwd?: string;
195
+ };
196
+
197
+ /**
198
+ * Shell state carried across commands — the working directory. Nothing else persists:
199
+ * each command is its own `wsl.exe` process, so the session is the memory, not a channel.
200
+ */
201
+ declare class WslSession {
202
+ readonly sessionId: string;
203
+ readonly host: WslHost;
204
+ /** Empty until the first command reports `$PWD`; then the real distro path. */
205
+ cwd: string;
206
+ lastUsedAt: Date;
207
+ execCount: number;
208
+ readonly openedAt: Date;
209
+ /** Tail of the serialized operation chain; see `runExclusive`. */
210
+ private operationChain;
211
+ private activeOperationCount;
212
+ constructor(sessionId: string, host: WslHost);
213
+ get profile(): WslHostProfile;
214
+ /** True while an operation is running, so the idle sweeper leaves it alone. */
215
+ get isBusy(): boolean;
216
+ touch(): void;
217
+ /**
218
+ * Runs `operation` with exclusive use of this session.
219
+ *
220
+ * Serialized because `cwd` is read-modify-write across a process run: two concurrent
221
+ * calls both read the old directory, and the one that finishes second writes it back —
222
+ * silently undoing the other's `cd`.
223
+ */
224
+ runExclusive<T>(operation: (host: WslHost) => Promise<T>): Promise<T>;
225
+ /**
226
+ * Runs a command in this session's working directory and adopts wherever it ended up,
227
+ * so a `cd` in one call is still in effect on the next.
228
+ *
229
+ * Passing `cwd` runs this command elsewhere *and* moves the session there — same
230
+ * result as prefixing `cd <cwd> &&`, which is what a shell user would expect.
231
+ */
232
+ exec(command: string, options?: {
233
+ cwd?: string;
234
+ timeoutMs?: number;
235
+ }): Promise<WslExecResult>;
236
+ toInfo(): WslSessionInfo;
237
+ }
238
+ /** Owns every open session: creation, lookup, idle expiry and shutdown. */
239
+ declare class WslSessionManager {
240
+ private readonly sessions;
241
+ private sequence;
242
+ private sweepTimer;
243
+ /**
244
+ * Registers a session. Nothing is opened — the caller probes with `pwd` to prove
245
+ * the distro answers and to settle the starting directory.
246
+ *
247
+ * @throws {Error} If the session ceiling is reached.
248
+ */
249
+ open(host: WslHost): WslSession;
250
+ /**
251
+ * Looks up a session, failing with the list of live ids so the caller can self-correct.
252
+ *
253
+ * @throws {Error} If no session matches `sessionId`.
254
+ */
255
+ require(sessionId: string): WslSession;
256
+ close(sessionId: string): boolean;
257
+ closeAll(): void;
258
+ list(): WslSessionInfo[];
259
+ /** Starts the idle sweeper; the timer is unref'd so it never holds the process open. */
260
+ startIdleSweeper(): void;
261
+ stopIdleSweeper(): void;
262
+ }
263
+
264
+ interface DirectoryListing {
265
+ entries: WslDirectoryEntry[];
266
+ /** Entries the distro reported, before the cap was applied. */
267
+ totalCount: number;
268
+ truncated: boolean;
269
+ }
270
+ interface FileContent {
271
+ content: string;
272
+ /** Size `stat` reported; 0 for virtual files that do not declare one. */
273
+ sizeBytes: number;
274
+ /** Bytes actually read. */
275
+ readBytes: number;
276
+ truncated: boolean;
277
+ }
278
+ /**
279
+ * Lists a directory, directories first then files, each group alphabetical, capped at
280
+ * `MAX_DIRECTORY_ENTRIES`. One `find -printf` call; `%y` is the type letter, `%m` the
281
+ * octal mode, `%s` bytes, `%T+` the mtime.
282
+ */
283
+ declare function listDirectory(host: WslHost, distroPath: string): Promise<DirectoryListing>;
284
+ /**
285
+ * Reads the leading `maxBytes` of a file. Two processes — `stat` for the declared size,
286
+ * then `head -c` — because mixing a size line into the content stream would need
287
+ * escaping that a fixed script should not have.
288
+ */
289
+ declare function readFile(host: WslHost, distroPath: string, maxBytes: number): Promise<FileContent>;
290
+ /**
291
+ * Writes or appends UTF-8 text. Content goes through stdin, so nothing in it is ever
292
+ * shell-parsed; only the path is interpolated, quoted.
293
+ *
294
+ * @returns Bytes written.
295
+ */
296
+ declare function writeFile(host: WslHost, args: {
297
+ distroPath: string;
298
+ content: string;
299
+ append: boolean;
300
+ }): Promise<number>;
301
+ /**
302
+ * Local → distro. The local file is streamed into the process's stdin, so size is bounded
303
+ * by the distro's disk, not this server's memory.
304
+ *
305
+ * @returns Bytes sent, from the local file's size.
306
+ */
307
+ declare function uploadFile(host: WslHost, localPath: string, distroPath: string): Promise<number>;
308
+ /**
309
+ * Distro → local. stdout streams straight into the destination file, so a large download
310
+ * never sits in memory. The destination's parent must already exist — the tool creates
311
+ * no directories on the operator's machine.
312
+ *
313
+ * @returns Bytes received.
314
+ */
315
+ declare function downloadFile(host: WslHost, distroPath: string, localPath: string): Promise<number>;
316
+
317
+ /**
318
+ * What `wsl.exe -l -v` says about the distro. Does not start it — that is what makes
319
+ * this safe to call from `wsl_list_hosts` and `--check` without side effects.
320
+ *
321
+ * @throws {Error} If `wsl.exe` cannot be started at all.
322
+ */
323
+ declare function queryDistroListing(distro: string): Promise<WslDistroStatus>;
324
+ /**
325
+ * Facts only visible from inside: networking mode, whether systemd is PID 1, the kernel.
326
+ * Starts the distro when it is stopped, so callers check the listing first and skip this
327
+ * for a stopped distro unless starting it is the intent.
328
+ *
329
+ * Runs as a fixed script through `runProcess`, bypassing the command guard — the script
330
+ * has no variable part and an allow-list profile must still be able to describe itself.
331
+ */
332
+ declare function probeDistroFacts(host: WslHost): Promise<Pick<WslDistroStatus, "networkingMode" | "systemd" | "kernel">>;
333
+
334
+ interface WslMcpServerInstance {
335
+ server: McpServer;
336
+ sessions: WslSessionManager;
337
+ /** The distro read from the `WSL_*` variables at startup, or null when none is set. */
338
+ readonly profile: WslHostProfile | null;
339
+ /** Stops the sweeper and forgets every open session. Call before the process exits. */
340
+ shutdown(): void;
341
+ }
342
+ /**
343
+ * Builds the MCP server: reads the distro from the environment, wires the session
344
+ * manager, registers tools.
345
+ *
346
+ * Transport is deliberately left to the caller — `cli.ts` attaches stdio, tests can attach
347
+ * an in-memory pair.
348
+ *
349
+ * @throws {Error} If the `WSL_*` variables are malformed (a bad policy value must not
350
+ * silently degrade into "no guard").
351
+ */
352
+ declare function createWslMcpServer(): WslMcpServerInstance;
353
+
354
+ /**
355
+ * Builds the profile from the `WSL_*` variables, or null when `WSL_DISTRO` is unset.
356
+ *
357
+ * The distro is required by name rather than falling back to `wsl --set-default`'s pick:
358
+ * that default is a machine-wide setting anyone can change, and a server silently
359
+ * following it would one day run every command in a different distro than the entry
360
+ * says. A malformed value aborts startup rather than being ignored, because silently
361
+ * dropping `WSL_READONLY` would leave a distro the operator believed was read-only
362
+ * fully writable.
363
+ *
364
+ * Flow:
365
+ * 1) Guard: no `WSL_DISTRO` means nothing is registered — the server still starts and says so
366
+ * 2) Resolve the policy over the built-in permissive stance; each variable is opt-in restriction
367
+ * 3) Assemble the profile
368
+ *
369
+ * @throws {Error} If a typed value is malformed.
370
+ */
371
+ declare function loadHostProfile(): WslHostProfile | null;
372
+ /** The profile as described back to the model. Nothing secret lives in a WSL profile, but the shape mirrors mcp-ssh. */
373
+ declare function toHostSummary(profile: WslHostProfile): WslHostSummary;
374
+
375
+ /**
376
+ * Screens a command for a WSL host: the `@akms/mcp-ssh` guard first (catastrophe rules,
377
+ * read-only, sudo, allow-list, deny patterns), then what only matters here.
378
+ *
379
+ * The WSL additions exist because one assumption of the base guard does not hold: that
380
+ * the remote account is the real boundary. In WSL the "remote" is the machine running
381
+ * this server. Through interop (`powershell.exe`) or a write under `/mnt/c/Users/…`, a
382
+ * mistaken agent can reach `~/.claude.json` — the file that defines this very guard — so
383
+ * both routes are closed unless `WSL_ALLOW_WINDOWS` opens them, and the operator's
384
+ * credential and configuration paths stay closed either way.
385
+ *
386
+ * Flow:
387
+ * 1) Base guard on the whole command
388
+ * 2) Per segment (and per `$(…)` substitution, recursively): WSL catastrophe rules
389
+ * 3) Per segment: interop binary
390
+ * 4) Per segment: `/mnt/<drive>/` references — write shape, then protected Windows paths
391
+ */
392
+ declare function inspectWslCommand(canonicalCommand: string, profile: WslHostProfile, depth?: number): GuardVerdict;
393
+ /**
394
+ * Decides whether a file tool may touch a path in the distro: the base path guard
395
+ * (`allowedPaths`, read-only), then the Windows rules for anything under `/mnt/<drive>/`.
396
+ */
397
+ declare function inspectWslPath(distroPath: string, profile: WslHostProfile, access: RemotePathAccess): GuardVerdict;
398
+
399
+ /** Everything the tool handlers share: the configured distro and the live sessions. */
400
+ interface ToolContext {
401
+ /** The distro from the `WSL_*` variables, or null when none is registered. */
402
+ host: WslHost | null;
403
+ sessions: WslSessionManager;
404
+ }
405
+
406
+ /** Registers every tool this server exposes, in the order the model should discover them. */
407
+ declare function registerAllTools(server: McpServer, context: ToolContext): void;
408
+
409
+ type LogLevel = "silent" | "debug" | "info" | "warn" | "error";
410
+ interface Logger {
411
+ /** Returns a logger that appends `meta` to every record it writes. */
412
+ child(meta: Record<string, unknown>): Logger;
413
+ debug(message: string, meta?: Record<string, unknown>): void;
414
+ info(message: string, meta?: Record<string, unknown>): void;
415
+ warn(message: string, meta?: Record<string, unknown>): void;
416
+ error(error: unknown, message: string, meta?: Record<string, unknown>): void;
417
+ }
418
+ declare const $logger: Logger;
419
+
420
+ /** Env var overriding the stderr log level ("debug" | "info" | "warn" | "error" | "silent"). */
421
+ declare const LOG_LEVEL_ENV = "WSL_MCP_LOG_LEVEL";
422
+ declare const SERVER_NAME = "akms-mcp-wsl";
423
+ declare const SERVER_VERSION = "0.0.1";
424
+
425
+ /**
426
+ * Environment variables that define the distro, one MCP server entry per distro + user.
427
+ *
428
+ * The `SSH_*` surface of `@akms/mcp-ssh` with the connection and authentication
429
+ * variables removed — there is no connection, `wsl.exe` is the transport.
430
+ */
431
+ declare const SINGLE_HOST_ENV: {
432
+ /** Presence of this variable is what registers a distro at all. Name as `wsl -l -q` prints it. */
433
+ readonly DISTRO: "WSL_DISTRO";
434
+ /** Linux user to run as (`wsl -u`); the distro's default user when unset. */
435
+ readonly USER: "WSL_USER";
436
+ /** Alias shown to the model; defaults to the distro name. */
437
+ readonly NAME: "WSL_NAME";
438
+ readonly DESCRIPTION: "WSL_DESCRIPTION";
439
+ /** Directory new sessions start in. */
440
+ readonly CWD: "WSL_CWD";
441
+ readonly READONLY: "WSL_READONLY";
442
+ readonly ALLOW_SUDO: "WSL_ALLOW_SUDO";
443
+ /** Interop `.exe` execution and writes under `/mnt/<drive>/`. Default false. */
444
+ readonly ALLOW_WINDOWS: "WSL_ALLOW_WINDOWS";
445
+ /** Comma-separated binary names. */
446
+ readonly ALLOW_COMMANDS: "WSL_ALLOW_COMMANDS";
447
+ /** Comma-separated regex sources. */
448
+ readonly DENY_PATTERNS: "WSL_DENY_PATTERNS";
449
+ /** Comma-separated absolute path prefixes. */
450
+ readonly ALLOWED_PATHS: "WSL_ALLOWED_PATHS";
451
+ readonly EXEC_TIMEOUT_MS: "WSL_EXEC_TIMEOUT_MS";
452
+ readonly MAX_OUTPUT_CHARACTERS: "WSL_MAX_OUTPUT";
453
+ readonly MAX_READ_FILE_BYTES: "WSL_MAX_READ_BYTES";
454
+ };
455
+
456
+ export { $logger, type DirectoryListing, type FileContent, LOG_LEVEL_ENV, type LogLevel, type Logger, SERVER_NAME, SERVER_VERSION, SINGLE_HOST_ENV, type ToolContext, type WslDirectoryEntry, type WslDistroStatus, type WslExecOptions, type WslExecResult, WslHost, type WslHostProfile, type WslHostSummary, type WslMcpServerInstance, type WslPolicy, type WslProcessOptions, type WslProcessOutput, WslSession, type WslSessionInfo, WslSessionManager, buildDistroScript, buildWslArguments, createWslMcpServer, downloadFile, extractCwdMarker, inspectWslCommand, inspectWslPath, listDirectory, loadHostProfile, probeDistroFacts, queryDistroListing, readFile, registerAllTools, toHostSummary, uploadFile, writeFile };