ssh-mcp 2.8.0 → 2.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ import { isAbsolute, relative, sep } from 'node:path';
2
+ /**
3
+ * Path-segment-aware containment; unlike a prefix check, siblings never match.
4
+ *
5
+ * The `..` cases are spelled out rather than using `rel.startsWith('..')`,
6
+ * which also rejects a legitimately named sibling such as `..cache`.
7
+ */
8
+ export function isWithinRoot(root, candidate) {
9
+ const rel = relative(root, candidate);
10
+ return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
11
+ }
12
+ /** True when either path contains the other, including equality. */
13
+ export function pathsOverlap(left, right) {
14
+ return isWithinRoot(left, right) || isWithinRoot(right, left);
15
+ }
@@ -3,8 +3,9 @@ import { promisify } from 'util';
3
3
  import { existsSync, realpathSync } from 'fs';
4
4
  import { mkdtemp, readFile, rm } from 'fs/promises';
5
5
  import { tmpdir, userInfo } from 'os';
6
- import { join, resolve, relative, isAbsolute, parse as parsePath } from 'path';
6
+ import { join, resolve, parse as parsePath } from 'path';
7
7
  import { OperatorError } from '../errors.js';
8
+ import { isWithinRoot } from './path-containment.js';
8
9
  import { parseDacl, aclIdentity, } from './sddl.js';
9
10
  const run = promisify(execFile);
10
11
  const WINDOWS_ROOT = /^[A-Za-z]:\\Windows$/;
@@ -287,8 +288,11 @@ function isTightenable(path, kind) {
287
288
  // path.relative rather than a prefix match: a hardcoded separator was wrong on
288
289
  // the platform the tests run on, and a bare startsWith would also accept a
289
290
  // sibling whose name merely begins with the profile's (`C:\\Users\\meloud`).
290
- const rel = relative(canonical(home), canonical(path));
291
- return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
291
+ //
292
+ // Through isWithinRoot rather than a local `!rel.startsWith('..')`, which
293
+ // rejected a legitimately named child such as `..cache` as an escape. One
294
+ // containment implementation, shared with the transfer-root gate.
295
+ return isWithinRoot(canonical(home), canonical(path));
292
296
  }
293
297
  /**
294
298
  * A path in the form the filesystem itself uses, lowercased.
@@ -1000,13 +1000,24 @@ function hasUnnameableCommand(command) {
1000
1000
  * `~/.ssh/authorized_keys` without touching a shell. Opening a session is the same
1001
1001
  * argument, since it hands over an interactive shell.
1002
1002
  *
1003
- * `sftp:download` and `session:close` are deliberately absent. Both would move *down*
1004
- * from `safe` — download to `read-only`, matching its `readOnlyHint`, and close being a
1005
- * release rather than an acquisition. Lowering a class is a widening, and a security
1006
- * release is the wrong place for one; they keep the class they have today.
1003
+ * `sftp:download`, `sftp:list` and `session:close` are deliberately absent. Each would
1004
+ * move *down* from `safe` — download and list to `read-only`, matching their
1005
+ * `readOnlyHint`, and close being a release rather than an acquisition. Lowering a class
1006
+ * is a widening, and a security release is the wrong place for one; they keep the class
1007
+ * they have today. `sftp:list` is worth spelling out because it is new: a `read-only`
1008
+ * entry for it would also be inert, since the floor below can only raise, and the effect
1009
+ * anyone reaching for it actually wants — letting a `viewer` list a remote directory — is
1010
+ * a binding change, not a classification one.
1011
+ *
1012
+ * `sftp:upload-file` and `sftp:download-file` are `destructive` because each writes a
1013
+ * file: the first on the remote host, the second inside the operator's transfer root.
1014
+ * Without a floor both classify `safe` from the verb alone, which would let an `operator`
1015
+ * write to disk through a tool nobody had approved for it.
1007
1016
  */
1008
1017
  const SYNTHETIC_CLASSES = Object.assign(Object.create(null), {
1009
1018
  'sftp:upload': 'destructive',
1019
+ 'sftp:upload-file': 'destructive',
1020
+ 'sftp:download-file': 'destructive',
1010
1021
  'session:open': 'destructive',
1011
1022
  });
1012
1023
  /** The first word, tokenised — the synthetic verb when there is one. */
package/build/ssh/sftp.js CHANGED
@@ -1,4 +1,332 @@
1
+ import ssh2 from 'ssh2';
2
+ import { once } from 'node:events';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { dirname, join } from 'node:path/posix';
1
5
  import { openWithRetry } from './channel-retry.js';
6
+ /*
7
+ * Through the default import, not `import { utils } from 'ssh2'`.
8
+ *
9
+ * ssh2 is CommonJS. Node's ESM loader statically detects some of its named
10
+ * exports (`Client`, which connection.ts imports that way) but not `utils`, so
11
+ * the named form type-checks, passes every vitest run — vitest transforms the
12
+ * module itself — and then throws `SyntaxError: Named export 'utils' not found`
13
+ * the first time the *built* artifact is loaded. Only the e2e suite, which
14
+ * spawns build/index.js, would have caught it.
15
+ */
16
+ const { STATUS_CODE } = ssh2.utils.sftp;
17
+ /**
18
+ * The numeric SFTP status a failed request carries, if any.
19
+ *
20
+ * ssh2 sets `code` only from the SSH_FXP_STATUS word (a number), never a
21
+ * libuv-style string — an earlier version of this file also tested for
22
+ * `'ENOENT'` and `'OP_UNSUPPORTED'`, which can never match. Those dead
23
+ * comparisons were what forced `(err as any).code`, and the cast in turn hid
24
+ * that they were dead. Errors ssh2 raises itself (`'No response from server'`,
25
+ * and the unadvertised-extension throw below) carry no `code` at all, which is
26
+ * why this returns `undefined` rather than a sentinel.
27
+ */
28
+ function statusCode(err) {
29
+ const code = err?.code;
30
+ return typeof code === 'number' ? code : undefined;
31
+ }
32
+ /**
33
+ * Thrown when a step's own bound expired, as distinct from the server refusing.
34
+ *
35
+ * The difference matters at exactly one place: a publish whose *wait* expired
36
+ * may still have landed, because the bound covers the wait and not the request.
37
+ * A publish the server actively refused has definitively not landed. Treating
38
+ * both as ambiguous told a caller its file "may already hold this upload" when
39
+ * the real answer was a permission error.
40
+ */
41
+ export class DeadlineExceededError extends Error {
42
+ }
43
+ const MAX_TIMER_MS = 2 ** 31 - 1;
44
+ /**
45
+ * Refuse a bound that cannot do its job, rather than letting it fail oddly.
46
+ *
47
+ * `idleTimeoutMs` above 2^31-1 (or `Infinity`, or `NaN`) is clamped by Node to
48
+ * 1ms, so every step then fails immediately with a message quoting the value
49
+ * the caller asked for — self-refuting output from a plausible input. `0` is
50
+ * worse than useless here: elsewhere in this codebase 0 means "no limit"
51
+ * (`commandQuotaPerDay`, `approvalGrantTtlMs`), so a caller following the house
52
+ * convention would get the exact opposite of what it asked for.
53
+ */
54
+ function assertBounds(idleTimeoutMs, byteBudget, label) {
55
+ if (!Number.isInteger(idleTimeoutMs) || idleTimeoutMs < 1 || idleTimeoutMs > MAX_TIMER_MS) {
56
+ throw new Error(`${label}: idleTimeoutMs must be an integer between 1 and ${MAX_TIMER_MS}; ` +
57
+ `0 does not mean "unlimited" here`);
58
+ }
59
+ if (!Number.isSafeInteger(byteBudget) || byteBudget < 1) {
60
+ throw new Error(`${label}: the byte budget must be a positive safe integer`);
61
+ }
62
+ }
63
+ /** One place the wire shape becomes this module's own, so the masks exist once. */
64
+ function toSftpStat(path, attrs) {
65
+ return {
66
+ path,
67
+ size: attrs.size,
68
+ mode: attrs.mode,
69
+ isDirectory: (attrs.mode & 0o170000) === 0o040000,
70
+ isFile: (attrs.mode & 0o170000) === 0o100000,
71
+ mtime: new Date(attrs.mtime * 1000),
72
+ atime: new Date(attrs.atime * 1000),
73
+ };
74
+ }
75
+ /**
76
+ * Copy `source` into `destination`, failing if either stalls or the byte cap is
77
+ * exceeded. Returns the number of bytes the far side acknowledged.
78
+ *
79
+ * The cap is enforced on the stream as it flows, not only from a prior stat: a
80
+ * remote size can be stale, unavailable, or a lie, and a local file can grow
81
+ * while it is read.
82
+ *
83
+ * **Ownership:** this ends `destination` — that is how the bytes are flushed and
84
+ * how the count becomes trustworthy — but never closes what the destination
85
+ * wraps. A caller whose `Writable` sits on a resource that must outlive the
86
+ * stream (a `FileHandle` it still has to `sync()` and publish) must construct it
87
+ * with `autoClose: false`; otherwise `end()` closes the handle here and the
88
+ * caller's later `sync()` fails with EBADF.
89
+ */
90
+ async function transfer(source, destination, opts, direction) {
91
+ const controller = new AbortController();
92
+ let rejectFailure;
93
+ const failure = new Promise((_resolve, reject) => { rejectFailure = reject; });
94
+ const abortStreams = () => {
95
+ const reason = controller.signal.reason instanceof Error
96
+ ? controller.signal.reason
97
+ : new Error(`SFTP file ${direction} aborted`);
98
+ rejectFailure(reason);
99
+ source.destroy();
100
+ destination.destroy();
101
+ };
102
+ const onSourceError = (err) => rejectFailure(err);
103
+ const onDestinationError = (err) => rejectFailure(err);
104
+ // Derived once. These three facts are the same question — which end is the
105
+ // SFTP-backed stream, and therefore which completion event is authoritative —
106
+ // and re-deriving them at each use made the body read as five decisions.
107
+ const remoteStream = direction === 'upload' ? destination : source;
108
+ const settledEvent = direction === 'upload' ? 'close' : 'finish';
109
+ const destinationSettled = () => (direction === 'upload' ? destination.closed : destination.writableFinished);
110
+ // Re-armed after every chunk, and once more for the finalize. A slow-but-live
111
+ // transfer survives; a channel that stops delivering fails within one window.
112
+ let idle;
113
+ const arm = () => {
114
+ if (idle)
115
+ clearTimeout(idle);
116
+ idle = setTimeout(() => controller.abort(new Error(`SFTP file ${direction} stalled: no progress for ${opts.idleTimeoutMs}ms`)), opts.idleTimeoutMs);
117
+ };
118
+ const disarm = () => { if (idle)
119
+ clearTimeout(idle); idle = undefined; };
120
+ const onAbort = () => controller.abort(opts.abortSignal?.reason ?? new Error(`SFTP file ${direction} aborted`));
121
+ controller.signal.addEventListener('abort', abortStreams, { once: true });
122
+ source.on('error', onSourceError);
123
+ destination.on('error', onDestinationError);
124
+ opts.abortSignal?.addEventListener('abort', onAbort, { once: true });
125
+ if (opts.abortSignal?.aborted)
126
+ onAbort();
127
+ let bytes = 0;
128
+ let resolveRemoteClosed;
129
+ const remoteClosed = new Promise((resolve) => { resolveRemoteClosed = resolve; });
130
+ const onRemoteClose = () => resolveRemoteClosed();
131
+ remoteStream.once('close', onRemoteClose);
132
+ // ssh2 constructs its streams with emitClose: false, so a close that has
133
+ // already happened never replays. Bounded, because this is a courtesy wait
134
+ // after the work is done, not a step that may hang the caller.
135
+ const waitForRemoteClose = async () => {
136
+ if (remoteStream.closed)
137
+ return;
138
+ let timer;
139
+ try {
140
+ await Promise.race([
141
+ remoteClosed,
142
+ new Promise((resolve) => {
143
+ timer = setTimeout(resolve, Math.min(opts.idleTimeoutMs, 1_000));
144
+ }),
145
+ ]);
146
+ }
147
+ finally {
148
+ if (timer)
149
+ clearTimeout(timer);
150
+ }
151
+ };
152
+ arm();
153
+ try {
154
+ const copy = async () => {
155
+ for await (const value of source) {
156
+ if (controller.signal.aborted)
157
+ throw controller.signal.reason;
158
+ const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value);
159
+ bytes += chunk.length;
160
+ if (bytes > opts.maxBytes) {
161
+ throw new Error(`SFTP file ${direction} exceeds the ${opts.maxBytes} byte transfer limit`);
162
+ }
163
+ arm();
164
+ // With the signal: `once` settles on 'drain' or 'error' only, and ssh2's
165
+ // destroy path emits 'close' without 'error'. A destination that went
166
+ // away cleanly while we were backpressured therefore left this waiting
167
+ // for the whole idle window, and surfaced as "no progress" rather than
168
+ // as the close it was.
169
+ if (!destination.write(chunk)) {
170
+ await once(destination, 'drain', { signal: controller.signal });
171
+ }
172
+ }
173
+ // The finalize costs an SSH_FXP_CLOSE round-trip whose latency has nothing
174
+ // to do with chunk cadence. Without re-arming, it ran on whatever was left
175
+ // of the window the last chunk set — sometimes almost nothing — and a
176
+ // transfer in which every byte was acknowledged failed as "stalled".
177
+ arm();
178
+ const completed = new Promise((resolve, reject) => {
179
+ const cleanup = () => {
180
+ destination.removeListener(settledEvent, done);
181
+ destination.removeListener('error', failed);
182
+ };
183
+ const done = () => { cleanup(); resolve(); };
184
+ const failed = (err) => { cleanup(); reject(err); };
185
+ destination.once(settledEvent, done);
186
+ destination.once('error', failed);
187
+ });
188
+ destination.end();
189
+ if (!destinationSettled())
190
+ await completed;
191
+ // Not dead code on the download side, though it looks it: `source` there
192
+ // is ssh2's SFTP read stream, built with emitClose: false, so `.closed`
193
+ // never becomes true and finishing the `for await` does not get
194
+ // SSH_FXP_CLOSE onto the wire. Without this the handle is still open when
195
+ // withSftp ends the subsystem channel. A review round called it dead after
196
+ // checking a plain Readable, where it genuinely is.
197
+ if (direction === 'download' && !source.destroyed)
198
+ source.destroy();
199
+ await waitForRemoteClose();
200
+ return bytes;
201
+ };
202
+ return await Promise.race([copy(), failure]);
203
+ }
204
+ catch (err) {
205
+ source.destroy();
206
+ destination.destroy();
207
+ await waitForRemoteClose();
208
+ if (controller.signal.aborted && controller.signal.reason instanceof Error) {
209
+ throw controller.signal.reason;
210
+ }
211
+ throw err;
212
+ }
213
+ finally {
214
+ disarm();
215
+ controller.signal.removeEventListener('abort', abortStreams);
216
+ source.removeListener('error', onSourceError);
217
+ destination.removeListener('error', onDestinationError);
218
+ remoteStream.removeListener('close', onRemoteClose);
219
+ opts.abortSignal?.removeEventListener('abort', onAbort);
220
+ }
221
+ }
222
+ /**
223
+ * Run one callback-style SFTP operation under its own bound.
224
+ *
225
+ * Every metadata round-trip gets `idleTimeoutMs` measured from its own start, so
226
+ * no single step can hang and none of them consume a budget the next step needs.
227
+ *
228
+ * It bounds the *wait*, not the request: a reply that arrives after the bound
229
+ * finds `settled` already true and is dropped. Callers for whom a late success
230
+ * matters have to re-establish the state rather than assume failure.
231
+ *
232
+ * Exported for its own tests. Driving the timeout through a real SFTP call
233
+ * cannot pin it: against a container on loopback a metadata round-trip finishes
234
+ * inside a 1ms bound, so the copy's stall wins the race and the branch this
235
+ * helper exists for stays unexercised.
236
+ */
237
+ export function callbackBeforeDeadline(opts, label, start) {
238
+ return new Promise((resolve, reject) => {
239
+ let settled = false;
240
+ const finish = (err, value) => {
241
+ if (settled)
242
+ return;
243
+ settled = true;
244
+ clearTimeout(timer);
245
+ opts.abortSignal?.removeEventListener('abort', abort);
246
+ if (err)
247
+ reject(err);
248
+ else
249
+ resolve(value);
250
+ };
251
+ const abort = () => finish(opts.abortSignal?.reason instanceof Error
252
+ ? opts.abortSignal.reason
253
+ : new Error(`${label} aborted`));
254
+ const timer = setTimeout(() => finish(new DeadlineExceededError(`${label} timed out after ${opts.idleTimeoutMs}ms`)), opts.idleTimeoutMs);
255
+ opts.abortSignal?.addEventListener('abort', abort, { once: true });
256
+ if (opts.abortSignal?.aborted)
257
+ abort();
258
+ else {
259
+ try {
260
+ start(finish);
261
+ }
262
+ catch (err) {
263
+ finish(err);
264
+ }
265
+ }
266
+ });
267
+ }
268
+ function remoteTemporaryPath(remotePath) {
269
+ return join(dirname(remotePath), `.ssh-mcp-upload-${randomUUID()}.part`);
270
+ }
271
+ /**
272
+ * Publish a staged upload, atomically where the server allows it.
273
+ *
274
+ * ssh2 does not report an unadvertised extension through the callback — it
275
+ * `throw`s before sending anything, with no `code` on the Error. Both extension
276
+ * calls are therefore wrapped: an earlier version tested the callback for
277
+ * `code === 8`, so the plain-`rename` fallback written for exactly these servers
278
+ * was unreachable and `overwrite: true` failed outright against anything that
279
+ * does not advertise posix-rename@openssh.com.
280
+ *
281
+ * Exported for its own tests. No server in this repo can exercise the throw:
282
+ * docker-compose pins OpenSSH's sftp-server, and even the Dropbear image
283
+ * installs openssh-sftp-server because Dropbear ships no SFTP subsystem — so
284
+ * both extensions are always advertised and the fallback would stay unverified
285
+ * without a stub.
286
+ */
287
+ export function publishRemote(sftp, temporary, remotePath, opts) {
288
+ return callbackBeforeDeadline(opts, 'SFTP upload publish', (callback) => {
289
+ const standardRename = () => sftp.rename(temporary, remotePath, (err) => callback(err ?? undefined));
290
+ const unsupported = () => callback(new Error('The SFTP server cannot atomically publish a file without overwrite; retry with overwrite=true'));
291
+ if (!opts.overwrite) {
292
+ // SFTP v3 rename has no portable no-clobber semantics. The OpenSSH
293
+ // hardlink extension maps to link(2), which fails atomically if the
294
+ // destination appeared after the stat above.
295
+ try {
296
+ sftp.ext_openssh_hardlink(temporary, remotePath, (err) => {
297
+ if (!err)
298
+ return callback();
299
+ const code = statusCode(err);
300
+ if (code === STATUS_CODE.OP_UNSUPPORTED)
301
+ return unsupported();
302
+ // v3 has no dedicated "already exists" status, so OpenSSH reports
303
+ // EEXIST as FAILURE. Anything else — permission, a filesystem that
304
+ // cannot hardlink, quota — used to be reported as a lost race too,
305
+ // sending the reader to look for a concurrent writer that never was.
306
+ if (code === STATUS_CODE.FAILURE) {
307
+ return callback(new Error('Refusing to overwrite a remote file created during transfer'));
308
+ }
309
+ return callback(new Error(`SFTP upload publish failed: ${err.message}`));
310
+ });
311
+ }
312
+ catch {
313
+ unsupported();
314
+ }
315
+ return;
316
+ }
317
+ try {
318
+ sftp.ext_openssh_rename(temporary, remotePath, (err) => {
319
+ if (statusCode(err) === STATUS_CODE.OP_UNSUPPORTED)
320
+ standardRename();
321
+ else
322
+ callback(err ?? undefined);
323
+ });
324
+ }
325
+ catch {
326
+ standardRename();
327
+ }
328
+ });
329
+ }
2
330
  export class SftpClient {
3
331
  conn;
4
332
  constructor(conn) {
@@ -36,6 +364,19 @@ export class SftpClient {
36
364
  catch { /* already torn down */ }
37
365
  }
38
366
  }
367
+ /**
368
+ * Write string or Buffer content to a remote path.
369
+ *
370
+ * Deliberately a direct write at 0o644, not a staged `.part` published with
371
+ * rename, and `||` rather than `??` so `mode: 0` falls back instead of
372
+ * producing a file nobody can read. This is a pre-existing tool: staging
373
+ * needs write permission on the *directory* where a direct write needs it
374
+ * only on the file, and republishing discards the target's mode, owner and
375
+ * ACL. #185 asked for the text-based tools to stay unchanged, and
376
+ * test/integration/ssh-sftp.test.ts pins all three properties.
377
+ *
378
+ * The streaming path with the opposite trade-offs is uploadFile().
379
+ */
39
380
  async upload(opts) {
40
381
  return this.withSftp(async (sftp) => {
41
382
  return new Promise((resolve, reject) => {
@@ -47,20 +388,145 @@ export class SftpClient {
47
388
  });
48
389
  }
49
390
  /**
50
- * Download a remote file, refusing anything over the cap.
391
+ * Upload from an already-open source without buffering it in the MCP process.
392
+ *
393
+ * Publishes through a sibling `.part` so a failed transfer never leaves a
394
+ * half-written file at the destination — the right trade for a new tool whose
395
+ * destination is expected to be new, and the wrong one for upload() above.
396
+ *
397
+ * Note what `overwrite: true` costs, since it is the case where the
398
+ * destination is *not* new: publishing by rename replaces the inode, so the
399
+ * target's owner and ACL do not survive. The mode does, unless the caller
400
+ * passes one — see the chmod below.
401
+ */
402
+ async uploadFile(source, remotePath, opts) {
403
+ assertBounds(opts.idleTimeoutMs, opts.maxBytes, 'SFTP upload');
404
+ return this.withSftp(async (sftp) => {
405
+ const exists = async () => callbackBeforeDeadline(opts, 'SFTP upload stat', (callback) => {
406
+ sftp.stat(remotePath, (err) => {
407
+ if (!err)
408
+ callback(undefined, true);
409
+ else if (statusCode(err) === STATUS_CODE.NO_SUCH_FILE)
410
+ callback(undefined, false);
411
+ else
412
+ callback(new Error(`SFTP upload stat failed: ${err.message}`));
413
+ });
414
+ });
415
+ if (!opts.overwrite && await exists()) {
416
+ throw new Error('Refusing to overwrite an existing remote file');
417
+ }
418
+ // Read before the transfer, because after it the destination is about to
419
+ // be replaced and its mode is the only part we can carry across.
420
+ const inheritedMode = opts.overwrite && opts.mode === undefined
421
+ ? await callbackBeforeDeadline(opts, 'SFTP upload mode stat', (callback) => {
422
+ sftp.stat(remotePath, (err, stats) => callback(undefined, err ? undefined : stats?.mode));
423
+ })
424
+ : undefined;
425
+ const temporary = remoteTemporaryPath(remotePath);
426
+ let published = false;
427
+ try {
428
+ const destination = sftp.createWriteStream(temporary, {
429
+ flags: 'wx',
430
+ mode: opts.mode || 0o600,
431
+ });
432
+ const bytes = await transfer(source, destination, opts, 'upload');
433
+ if (inheritedMode !== undefined) {
434
+ // Without this, replacing a 0644 service config with a 0600 one is a
435
+ // successful-looking upload that silently breaks every other reader.
436
+ await callbackBeforeDeadline(opts, 'SFTP upload chmod', (callback) => {
437
+ sftp.chmod(temporary, inheritedMode & 0o7777, (err) => callback(err ?? undefined));
438
+ });
439
+ }
440
+ if (!opts.overwrite && await exists()) {
441
+ throw new Error('Refusing to overwrite a remote file created during transfer');
442
+ }
443
+ try {
444
+ await publishRemote(sftp, temporary, remotePath, opts);
445
+ published = true;
446
+ }
447
+ catch (err) {
448
+ // Only a bound that expired is ambiguous: the request may still be
449
+ // in flight, so the destination existing afterwards could be this
450
+ // upload. Reporting that as a plain failure sent a retrying caller
451
+ // into "Refusing to overwrite an existing remote file" —
452
+ // indistinguishable from a squatter, and an argument for
453
+ // `overwrite: true` built on a false premise. A publish the server
454
+ // actively refused is not ambiguous and must keep its own error.
455
+ if (err instanceof DeadlineExceededError && await exists().catch(() => false)) {
456
+ published = true;
457
+ throw new Error('SFTP upload publish did not confirm within its bound, but the destination now exists. ' +
458
+ 'Verify the remote file before retrying; it may already hold this upload.');
459
+ }
460
+ throw err;
461
+ }
462
+ if (!opts.overwrite) {
463
+ // The hardlink above left two names for one inode; drop ours.
464
+ await this.removeTemporary(sftp, temporary, opts.idleTimeoutMs).catch(() => {
465
+ console.error('Warning: an SFTP upload was published but its stale .part hard link could not be removed');
466
+ });
467
+ }
468
+ return bytes;
469
+ }
470
+ finally {
471
+ if (!published) {
472
+ await this.removeTemporary(sftp, temporary, opts.idleTimeoutMs).catch(() => {
473
+ // Logged, not swallowed: this is the case that leaves remote state
474
+ // nobody knows about. local-path.ts says the same thing for the
475
+ // local half, and saying nothing here was the asymmetry.
476
+ console.error(`Warning: an unpublished SFTP upload left a staging file in ${dirname(remotePath)}; ` +
477
+ 'look for .ssh-mcp-upload-*.part and remove it');
478
+ });
479
+ }
480
+ }
481
+ });
482
+ }
483
+ /**
484
+ * Remove a staging file.
485
+ *
486
+ * Takes the timeout as a number rather than the caller's options, because it
487
+ * deliberately ignores `abortSignal`: cleanup runs on the aborted path, and a
488
+ * signal threaded through here would make it unrunnable exactly when needed.
489
+ * Its own short bound keeps it from extending a transfer that already failed.
490
+ */
491
+ async removeTemporary(sftp, temporary, idleTimeoutMs) {
492
+ return callbackBeforeDeadline({ idleTimeoutMs: Math.min(idleTimeoutMs, 1_000) }, 'SFTP upload cleanup', (callback) => sftp.unlink(temporary, (err) => callback(err ?? undefined)));
493
+ }
494
+ /**
495
+ * Download into an already-open destination without buffering it in memory.
51
496
  *
52
- * exec output has always been capped, but SFTP download was not: the whole
53
- * file was buffered, concatenated, decoded to a UTF-16 string and entropy
54
- * scanned. A large remote file therefore turned one tool call into multi-GB
55
- * RSS and a multi-second event-loop stall for the entire server.
497
+ * Ends the destination when the transfer completes — see transfer()'s
498
+ * ownership note. A caller holding a `FileHandle` it still needs (to `sync()`
499
+ * and publish) must create the stream with `autoClose: false`.
56
500
  */
501
+ async downloadFile(remotePath, destination, opts) {
502
+ assertBounds(opts.idleTimeoutMs, opts.maxBytes, 'SFTP download');
503
+ return this.withSftp(async (sftp) => {
504
+ const size = await callbackBeforeDeadline(opts, 'SFTP download stat', (callback) => {
505
+ sftp.stat(remotePath, (err, stats) => callback(undefined, err ? undefined : stats?.size));
506
+ });
507
+ if (size !== undefined && size > opts.maxBytes) {
508
+ throw new Error(`SFTP file download exceeds the ${opts.maxBytes} byte transfer limit (${size} bytes)`);
509
+ }
510
+ const source = sftp.createReadStream(remotePath);
511
+ return transfer(source, destination, opts, 'download');
512
+ });
513
+ }
57
514
  async download(opts) {
58
515
  const maxBytes = opts.maxBytes ?? this.conn.profile.maxOutputBytes;
59
516
  return this.withSftp(async (sftp) => {
60
517
  // Refuse before transferring anything when the size is known up front.
61
- const size = await new Promise((resolve) => {
62
- sftp.stat(opts.remotePath, (err, stats) => resolve(err ? undefined : stats?.size));
63
- });
518
+ //
519
+ // Bounded, because this promise had no reject path and no timeout: a
520
+ // server that accepted SSH_FXP_STAT and never answered left this tool
521
+ // call suspended for the life of the connection, with no error and
522
+ // nothing for a caller to act on. That is the same shape as #197 on the
523
+ // exec side. The bound is the profile's command timeout, which is what
524
+ // every other step of a tool call already answers to; an unanswerable
525
+ // stat now falls through to the streaming cap below, exactly as an
526
+ // unavailable one always did.
527
+ const size = await callbackBeforeDeadline({ idleTimeoutMs: this.conn.profile.timeout }, 'SFTP download stat', (callback) => {
528
+ sftp.stat(opts.remotePath, (err, stats) => callback(undefined, err ? undefined : stats?.size));
529
+ }).catch(() => undefined);
64
530
  if (size !== undefined && size > maxBytes) {
65
531
  throw new Error(`Refusing to download ${opts.remotePath}: ${size} bytes exceeds the ${maxBytes} byte limit ` +
66
532
  `(commandMaxOutputBytes). Narrow the file or raise the limit for this profile.`);
@@ -85,25 +551,77 @@ export class SftpClient {
85
551
  });
86
552
  });
87
553
  }
88
- async list(remotePath) {
554
+ /**
555
+ * List a remote directory, retaining at most `maxEntries + 1` entries.
556
+ *
557
+ * Reads happen in server-sized batches; the bound is on what is *retained*,
558
+ * and the one entry past the limit is what makes `truncated` honest without a
559
+ * second round-trip. Unbounded readdir was the previous shape, and a directory
560
+ * with a million entries turned one call into a million-object array.
561
+ */
562
+ async list(remotePath, opts) {
563
+ assertBounds(opts.idleTimeoutMs, opts.maxResponseBytes, 'SFTP list');
564
+ if (!Number.isInteger(opts.maxEntries) || opts.maxEntries < 1) {
565
+ throw new Error('SFTP list: maxEntries must be a positive integer');
566
+ }
89
567
  return this.withSftp(async (sftp) => {
90
- return new Promise((resolve, reject) => {
91
- sftp.readdir(remotePath, (err, list) => {
92
- if (err) {
93
- reject(new Error(`SFTP list error: ${err.message}`));
94
- return;
95
- }
96
- resolve(list.map((entry) => ({
97
- path: `${remotePath}/${entry.filename}`,
98
- size: entry.attrs.size,
99
- mode: entry.attrs.mode,
100
- isDirectory: (entry.attrs.mode & 0o170000) === 0o040000,
101
- isFile: (entry.attrs.mode & 0o170000) === 0o100000,
102
- mtime: new Date(entry.attrs.mtime * 1000),
103
- atime: new Date(entry.attrs.atime * 1000),
104
- })));
568
+ const handle = await callbackBeforeDeadline(opts, 'SFTP list open', (callback) => {
569
+ sftp.opendir(remotePath, (err, opened) => {
570
+ callback(err ? new Error(`SFTP list error: ${err.message}`) : undefined, opened);
105
571
  });
106
572
  });
573
+ const entries = [];
574
+ let retained = 0;
575
+ let responseBytes = 0;
576
+ let budgetTruncated = false;
577
+ try {
578
+ while (retained <= opts.maxEntries && !budgetTruncated) {
579
+ const batch = await callbackBeforeDeadline(opts, 'SFTP list read', (callback) => {
580
+ // `{ full: true }` so `.` and `..` reach us. Without it ssh2 splices
581
+ // them out before the callback, and a server whose READDIR batch
582
+ // holds only those two names arrives here as a zero-length batch —
583
+ // which the loop would read as EOF and report a listing it never
584
+ // read as complete. Genuine EOF is the EOF status, nothing else.
585
+ sftp.readdir(handle, { full: true }, (err, list) => {
586
+ if (statusCode(err) === STATUS_CODE.EOF)
587
+ callback(undefined, null);
588
+ else if (err)
589
+ callback(new Error(`SFTP list error: ${err.message}`));
590
+ else
591
+ callback(undefined, list);
592
+ });
593
+ });
594
+ if (batch === null)
595
+ break;
596
+ for (const entry of batch) {
597
+ if (entry.filename === '.' || entry.filename === '..')
598
+ continue;
599
+ // Bill what is actually retained. `longname` is the server's `ls -l`
600
+ // line — attacker-controlled, unbounded up to the packet limit, and
601
+ // repeating the filename — so charging only the filename let a
602
+ // hostile server retain three orders of magnitude more than the
603
+ // budget said. Projecting to SftpStat here rather than pushing
604
+ // ssh2's object drops `longname` instead of holding it.
605
+ responseBytes += Buffer.byteLength(entry.filename, 'utf8')
606
+ + Buffer.byteLength(entry.longname ?? '', 'utf8')
607
+ + 256;
608
+ if (responseBytes > opts.maxResponseBytes) {
609
+ budgetTruncated = true;
610
+ break;
611
+ }
612
+ retained++;
613
+ if (retained > opts.maxEntries)
614
+ break;
615
+ entries.push(toSftpStat(join(remotePath, entry.filename), entry.attrs));
616
+ }
617
+ }
618
+ }
619
+ finally {
620
+ await callbackBeforeDeadline(opts, 'SFTP list close', (callback) => {
621
+ sftp.close(handle, () => callback());
622
+ }).catch(() => { });
623
+ }
624
+ return { truncated: budgetTruncated || retained > opts.maxEntries, entries };
107
625
  });
108
626
  }
109
627
  async stat(remotePath) {
@@ -114,15 +632,7 @@ export class SftpClient {
114
632
  reject(new Error(`SFTP stat error: ${err.message}`));
115
633
  return;
116
634
  }
117
- resolve({
118
- path: remotePath,
119
- size: stats.size,
120
- mode: stats.mode,
121
- isDirectory: (stats.mode & 0o170000) === 0o040000,
122
- isFile: (stats.mode & 0o170000) === 0o100000,
123
- mtime: new Date(stats.mtime * 1000),
124
- atime: new Date(stats.atime * 1000),
125
- });
635
+ resolve(toSftpStat(remotePath, stats));
126
636
  });
127
637
  });
128
638
  });
@@ -16,7 +16,11 @@ export function registerFileTools({ server }, pipeline) {
16
16
  await new SftpClient(rt.conn).upload({ remotePath, content });
17
17
  return {
18
18
  audited: syntheticSuccess(rt.profileName),
19
- output: textResult(`Uploaded ${content.length} bytes to ${remotePath}`),
19
+ // Byte count, not string length: `content` is a JS string, so
20
+ // `.length` counts UTF-16 code units and under-reports every
21
+ // multi-byte character. SftpClient.upload writes Buffer.from(content),
22
+ // which is utf8, so this is exactly what lands on the remote side.
23
+ output: textResult(`Uploaded ${Buffer.byteLength(content, 'utf8')} bytes to ${remotePath}`),
20
24
  };
21
25
  });
22
26
  });
@@ -0,0 +1,313 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { constants, realpath as realpathCb } from 'node:fs';
3
+ import { open, lstat, rename, link, unlink, stat } from 'node:fs/promises';
4
+ import { platform } from 'node:os';
5
+ import { basename, dirname, isAbsolute, relative, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { promisify } from 'node:util';
8
+ import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
9
+ import { isWithinRoot, pathsOverlap } from '../config/path-containment.js';
10
+ const INSTALL_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
11
+ const NO_FOLLOW = constants.O_NOFOLLOW ?? 0;
12
+ const PATH_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028-\u202e\u2066-\u2069]/;
13
+ /**
14
+ * `fs.promises.realpath` does not expand Windows 8.3 short names, so two
15
+ * spellings of one path can compare unequal. The native binding does. On POSIX
16
+ * the two behave alike, so this is the safe default everywhere rather than a
17
+ * platform branch that only ever gets exercised on one OS.
18
+ */
19
+ const realpath = promisify(realpathCb.native);
20
+ function invalid(message) {
21
+ throw new McpError(ErrorCode.InvalidParams, message);
22
+ }
23
+ function validateInput(input) {
24
+ if (!input.trim())
25
+ invalid('Local path cannot be empty');
26
+ if (PATH_CONTROL_CHARS.test(input)) {
27
+ invalid('Local path cannot contain control or bidi formatting characters');
28
+ }
29
+ }
30
+ function display(root, candidate) {
31
+ const value = relative(root, candidate);
32
+ return value || '.';
33
+ }
34
+ function confined(root, candidate) {
35
+ if (!isWithinRoot(root, candidate))
36
+ invalid('Local path must stay within defaults.transferRoot');
37
+ }
38
+ async function assertPrivateTransferRoot(root) {
39
+ const info = await stat(root);
40
+ const uid = process.getuid?.();
41
+ if (uid !== undefined && info.uid !== uid) {
42
+ invalid('defaults.transferRoot must be owned by the SSH MCP account');
43
+ }
44
+ if ((info.mode & 0o077) !== 0) {
45
+ invalid('defaults.transferRoot permissions must be 0700');
46
+ }
47
+ // A writable ancestor could replace the root between realpath() and open(). Sticky
48
+ // directories such as /tmp are safe for an owner-controlled child and are allowed.
49
+ let child = root;
50
+ for (let parent = dirname(root); parent !== child; child = parent, parent = dirname(parent)) {
51
+ const parentInfo = await stat(parent);
52
+ if (uid !== undefined && parentInfo.uid !== uid && parentInfo.uid !== 0) {
53
+ invalid('defaults.transferRoot has a parent owned by an untrusted account');
54
+ }
55
+ const writableByOthers = (parentInfo.mode & 0o022) !== 0;
56
+ const sticky = (parentInfo.mode & 0o1000) !== 0;
57
+ if (writableByOthers && !sticky) {
58
+ invalid('defaults.transferRoot has an unsafe writable parent directory');
59
+ }
60
+ }
61
+ }
62
+ async function configuredRoot(ctx) {
63
+ // Windows is refused outright rather than verified with the config-file ACL
64
+ // posture, which waives a read-exposed directory, never reads the `O:` owner,
65
+ // and treats a missing or slow `icacls.exe` as a pass. A transfer root that
66
+ // cannot be verified must disable the tools, not enable them.
67
+ if (platform() === 'win32') {
68
+ invalid('Streaming SFTP file tools are not available on Windows: the transfer root cannot yet ' +
69
+ 'be verified private there, and an unverifiable root would leave every downloaded file ' +
70
+ 'readable by other accounts on the machine.');
71
+ }
72
+ const input = ctx.transferRoot;
73
+ if (!input)
74
+ invalid('Streaming SFTP file tools require defaults.transferRoot in config.toml');
75
+ if (!isAbsolute(input))
76
+ invalid('defaults.transferRoot must be an absolute path');
77
+ let canonical;
78
+ try {
79
+ canonical = await realpath(input);
80
+ }
81
+ catch {
82
+ invalid('defaults.transferRoot is not an accessible directory');
83
+ }
84
+ const info = await stat(canonical);
85
+ if (!info.isDirectory())
86
+ invalid('defaults.transferRoot must name a directory');
87
+ const install = await realpath(INSTALL_DIR);
88
+ if (pathsOverlap(canonical, install)) {
89
+ invalid('defaults.transferRoot must be separate from the SSH MCP installation');
90
+ }
91
+ if (ctx.configPath) {
92
+ let configDir;
93
+ try {
94
+ configDir = dirname(await realpath(resolve(ctx.configPath)));
95
+ }
96
+ catch {
97
+ invalid('The SSH MCP config directory cannot be resolved safely');
98
+ }
99
+ if (pathsOverlap(canonical, configDir)) {
100
+ invalid('defaults.transferRoot must be separate from the SSH MCP config directory');
101
+ }
102
+ }
103
+ // The install and config directories are not the only sensitive ones. A root
104
+ // containing the audit log lets a download replace the tamper-evident record
105
+ // of the transfer that wrote it, and a root shaped like ~/.ssh turns the same
106
+ // tools into "read the private key" and "append to authorized_keys". Both are
107
+ // conventionally 0700 and owner-owned, so the privacy checks above accept
108
+ // them; they have to be excluded by name.
109
+ for (const entry of ctx.forbidden ?? []) {
110
+ let forbidden;
111
+ try {
112
+ forbidden = await realpath(entry.path);
113
+ }
114
+ catch {
115
+ // A directory that does not exist yet cannot contain the root, but its
116
+ // lexical form still can once it is created.
117
+ forbidden = resolve(entry.path);
118
+ }
119
+ if (pathsOverlap(canonical, forbidden)) {
120
+ invalid(`defaults.transferRoot must be separate from ${entry.reason}`);
121
+ }
122
+ }
123
+ await assertPrivateTransferRoot(canonical);
124
+ return { configured: resolve(input), canonical };
125
+ }
126
+ /**
127
+ * Make a caller's path comparable to the canonical root without touching the
128
+ * filesystem outside it.
129
+ *
130
+ * `resolve(root, input)` ignores `root` entirely when `input` is absolute, so a
131
+ * legitimate absolute path spelled the way a shell shows it — macOS `/var/...`
132
+ * for a root canonicalized to `/private/var/...` — is not yet comparable. The
133
+ * previous shape confined this lexical form immediately and therefore refused
134
+ * those outright, on every host whose transfer root sits behind a symlink.
135
+ *
136
+ * Accepting either spelling fixes that. The check stays lexical so that a path
137
+ * outside the root is refused before anything stats it, which is what keeps
138
+ * these tools from answering "does this file exist?" for arbitrary paths.
139
+ */
140
+ function lexicalCandidate(root, input) {
141
+ const candidate = resolve(root.canonical, input);
142
+ if (isWithinRoot(root.canonical, candidate))
143
+ return candidate;
144
+ if (isWithinRoot(root.configured, candidate))
145
+ return candidate;
146
+ invalid('Local path must stay within defaults.transferRoot');
147
+ }
148
+ /** Open an existing regular file once, so the transfer never re-resolves its path. */
149
+ export async function localFileForRead(ctx, input, onResolved) {
150
+ try {
151
+ validateInput(input);
152
+ const root = await configuredRoot(ctx);
153
+ const lexical = lexicalCandidate(root, input);
154
+ // Canonicalize the parent before the leaf: the leaf may be a symlink that
155
+ // has to be refused rather than followed, and its own realpath would hide
156
+ // that. Confining the parent first also means the leaf is only ever lstat'd
157
+ // once it is known to be inside the root.
158
+ const parent = await realpath(dirname(lexical));
159
+ confined(root.canonical, parent);
160
+ const candidate = resolve(parent, basename(lexical));
161
+ confined(root.canonical, candidate);
162
+ onResolved?.(display(root.canonical, candidate));
163
+ if ((await lstat(candidate)).isSymbolicLink()) {
164
+ invalid(`Local upload source cannot be a symlink: ${input}`);
165
+ }
166
+ const real = await realpath(candidate);
167
+ confined(root.canonical, real);
168
+ const handle = await open(real, constants.O_RDONLY | NO_FOLLOW);
169
+ try {
170
+ const info = await handle.stat();
171
+ if (!info.isFile())
172
+ invalid(`Local path is not a regular file: ${input}`);
173
+ return { handle, size: info.size, displayPath: display(root.canonical, real) };
174
+ }
175
+ catch (err) {
176
+ await handle.close().catch(() => { });
177
+ throw err;
178
+ }
179
+ }
180
+ catch (err) {
181
+ if (err instanceof McpError)
182
+ throw err;
183
+ invalid(`Local upload source is not an accessible regular file: ${input}`);
184
+ }
185
+ }
186
+ /** Resolve a destination through its real parent and enforce overwrite policy. */
187
+ export async function localFileForWrite(ctx, input, overwrite, onResolved) {
188
+ try {
189
+ validateInput(input);
190
+ const root = await configuredRoot(ctx);
191
+ const lexical = lexicalCandidate(root, input);
192
+ const parent = await realpath(dirname(lexical));
193
+ confined(root.canonical, parent);
194
+ const destination = resolve(parent, basename(lexical));
195
+ confined(root.canonical, destination);
196
+ const target = { destination, displayPath: display(root.canonical, destination), parent };
197
+ onResolved?.(target);
198
+ try {
199
+ const info = await lstat(destination);
200
+ if (!overwrite)
201
+ invalid(`Refusing to overwrite existing local file: ${input}`);
202
+ if (info.isSymbolicLink())
203
+ invalid(`Refusing to overwrite a local symlink: ${input}`);
204
+ if (!info.isFile())
205
+ invalid(`Local destination is not a regular file: ${input}`);
206
+ }
207
+ catch (err) {
208
+ if (err?.code !== 'ENOENT')
209
+ throw err;
210
+ }
211
+ return target;
212
+ }
213
+ catch (err) {
214
+ if (err instanceof McpError)
215
+ throw err;
216
+ invalid(`Local download destination is not accessible: ${input}`);
217
+ }
218
+ }
219
+ async function assertParentUnchanged(target) {
220
+ let currentParent;
221
+ try {
222
+ currentParent = await realpath(dirname(target.destination));
223
+ }
224
+ catch {
225
+ invalid('Local destination parent changed during transfer');
226
+ }
227
+ if (currentParent !== target.parent)
228
+ invalid('Local destination parent changed during transfer');
229
+ }
230
+ /** Create a same-directory temporary file and publish it atomically. */
231
+ export async function createLocalDownload(ctx, input, overwrite, onResolved) {
232
+ const target = await localFileForWrite(ctx, input, overwrite, onResolved);
233
+ await assertParentUnchanged(target);
234
+ const temporary = resolve(target.parent, `.ssh-mcp-download-${randomUUID()}.part`);
235
+ let handle;
236
+ try {
237
+ handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | NO_FOLLOW, 0o600);
238
+ }
239
+ catch {
240
+ invalid('Local download temporary file could not be created safely');
241
+ }
242
+ let published = false;
243
+ // Handed out by createStream() and torn down here, because the two belong
244
+ // together: an `autoClose: false` stream that has finished but not been
245
+ // destroyed makes `handle.close()` hang indefinitely — measured, not
246
+ // theorised. Giving the caller the stream without owning its teardown just
247
+ // moved the hazard one level out.
248
+ let stream;
249
+ const releaseStream = () => {
250
+ if (stream && !stream.destroyed)
251
+ stream.destroy();
252
+ stream = undefined;
253
+ };
254
+ return {
255
+ handle,
256
+ temporary,
257
+ target,
258
+ createStream() {
259
+ stream ??= handle.createWriteStream({ autoClose: false });
260
+ return stream;
261
+ },
262
+ async publish() {
263
+ // No releaseStream() here: destroying the stream closes the handle even
264
+ // with autoClose: false, and sync() below needs it open. Teardown belongs
265
+ // to cleanup(), which the caller always runs.
266
+ await assertParentUnchanged(target);
267
+ try {
268
+ await handle.sync();
269
+ if (overwrite) {
270
+ await rename(temporary, target.destination);
271
+ published = true;
272
+ }
273
+ else {
274
+ // link() fails with EEXIST rather than clobbering, which is the
275
+ // no-overwrite guarantee; the temporary is unlinked afterwards.
276
+ await link(temporary, target.destination);
277
+ published = true;
278
+ await unlink(temporary).catch(() => {
279
+ console.error('Warning: an SFTP download was published but its stale .part hard link could not be removed');
280
+ });
281
+ }
282
+ }
283
+ catch (err) {
284
+ if (err?.code === 'EEXIST')
285
+ invalid('Refusing to overwrite a local file created during transfer');
286
+ throw new McpError(ErrorCode.InternalError, 'Local download could not be published safely');
287
+ }
288
+ try {
289
+ const parentHandle = await open(target.parent, constants.O_RDONLY);
290
+ try {
291
+ await parentHandle.sync();
292
+ }
293
+ finally {
294
+ await parentHandle.close();
295
+ }
296
+ }
297
+ catch {
298
+ console.error('Warning: SFTP download was published, but its directory metadata could not be synced');
299
+ }
300
+ },
301
+ async cleanup() {
302
+ releaseStream();
303
+ await handle.close().catch(() => { });
304
+ if (!published) {
305
+ await unlink(temporary).catch((err) => {
306
+ console.error(err?.code === 'ENOENT'
307
+ ? 'Warning: an unpublished SFTP temporary file is no longer reachable; check transferRoot for stale .part files'
308
+ : 'Warning: an unpublished SFTP temporary file could not be removed; check transferRoot for stale .part files');
309
+ });
310
+ }
311
+ },
312
+ };
313
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ssh-mcp",
3
3
  "license": "MIT",
4
- "version": "2.8.0",
4
+ "version": "2.8.2",
5
5
  "description": "MCP server exposing policy-gated, audited SSH access for Linux and Windows hosts via the Model Context Protocol.",
6
6
  "mcpName": "io.github.tufantunc/ssh-mcp",
7
7
  "type": "module",
@@ -33,7 +33,7 @@
33
33
  "@opentelemetry/resources": "^2.9.0",
34
34
  "@opentelemetry/sdk-node": "^0.221.0",
35
35
  "@opentelemetry/semantic-conventions": "^1.43.0",
36
- "smol-toml": "^1.7.0",
36
+ "smol-toml": "^1.7.1",
37
37
  "ssh2": "^1.17.0",
38
38
  "zod": "^4.4.3"
39
39
  },