ssh-mcp 2.8.3 → 2.9.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.
package/README.md CHANGED
@@ -142,7 +142,7 @@ claude mcp add --transport stdio ssh-mcp -- ssh-mcp
142
142
 
143
143
  ---
144
144
 
145
- ## Tools (11)
145
+ ## Tools (14)
146
146
 
147
147
  | Tool | Purpose | readOnly | destructive |
148
148
  |------|---------|:--------:|:----------:|
@@ -156,8 +156,63 @@ claude mcp add --transport stdio ssh-mcp -- ssh-mcp
156
156
  | `privileged-command` | Execute with sudo (needs approval, unless `approvalPolicy = "auto"`) | — | ✅ |
157
157
  | `sftp-upload` | Upload a file via SFTP | — | ✅ |
158
158
  | `sftp-download` | Download a file via SFTP | ✅ | — |
159
+ | `sftp-list` | List a remote directory, bounded in entries and bytes | ✅ | — |
160
+ | `sftp-upload-file` | Stream a local file to the remote host, never through model context | — | ✅ |
161
+ | `sftp-download-file` | Stream a remote file to local disk, never through model context | — | ✅ |
159
162
  | `signal-process` | Send INT/TERM/KILL to a remote PID | — | ✅ |
160
163
 
164
+ ### Streaming file transfer
165
+
166
+ `sftp-upload`/`sftp-download` move file *contents* through the model's context:
167
+ the text is an argument on the way out and a response on the way back. That is
168
+ what you want for a config snippet and exactly what you do not want for a 200 MB
169
+ tarball or anything binary.
170
+
171
+ `sftp-upload-file` and `sftp-download-file` stream between the remote host and
172
+ local disk instead. Neither the bytes nor a base64 encoding of them ever reaches
173
+ the model — the response is a byte count and two paths.
174
+
175
+ The two transfer tools are **off until you configure `defaults.transferRoot`**,
176
+ and refuse with an explanation until then. (`sftp-list` has no local side and
177
+ needs none of this.) That directory is the whole of their local reach:
178
+
179
+ ```toml
180
+ [defaults]
181
+ transferRoot = "/srv/ssh-mcp-transfers"
182
+ transferMaxBytes = 268435456 # 256MB per transfer
183
+ transferTimeoutMs = 300000 # 5min with no bytes moving
184
+ ```
185
+
186
+ Checked on every call, and refused rather than degraded if any of it fails: the
187
+ directory must be `0700` and owned by the account running this server, with no
188
+ group- or world-writable parent, and must not overlap the ssh-mcp installation,
189
+ the config directory, the audit log directory, or `~/.ssh`. Caller paths are
190
+ confined to it, symlinks are refused rather than followed, and a download is
191
+ staged as a sibling `.part` file and published atomically, so a failed transfer
192
+ never leaves a half-written file at the destination.
193
+
194
+ `transferTimeoutMs` is an **idle** budget, not a total one: it bounds one
195
+ metadata round-trip, or one stretch of the copy with no bytes moving, and is
196
+ re-armed on progress. A slow but live transfer of a large file survives it; a
197
+ stalled channel still fails within one window. That is why the byte cap above
198
+ does not have to be divided by it — a total budget would have made a 256MB cap
199
+ mean "only if the link sustains 900 KB/s".
200
+
201
+ Not available on Windows, where the transfer root cannot yet be verified
202
+ private; the two transfer tools refuse there rather than writing into a
203
+ directory other accounts may be able to read.
204
+
205
+ `sftp-upload-file` takes an optional `mode` (1–511; setuid, setgid and the
206
+ sticky bit are refused). Omitted, a new remote file is published `0600`; an
207
+ overwritten one inherits the replaced file's permission bits — its permission
208
+ bits only, not its setuid or setgid.
209
+
210
+ What a transfer is authorized for includes these arguments: the string the
211
+ policy engine classifies, the approval prompt a human reads, and the audit
212
+ record all name `--overwrite` and `--mode` when they are given. So approving one
213
+ upload to a path does not approve a different one to the same path, and an
214
+ approval grant (`approvalGrantTtlMs`) cannot be replayed with a different mode.
215
+
161
216
  ### Interactive Sessions
162
217
 
163
218
  Sessions maintain state (CWD, environment variables) between commands:
@@ -189,6 +244,8 @@ OpenSSH on Windows 11.
189
244
  |---|:---:|:---:|
190
245
  | `read-command`, `run-command`, `privileged-command`, `signal-process` | ✅ | ✅ |
191
246
  | `sftp-upload`, `sftp-download` | ✅ | ✅ |
247
+ | `sftp-list` | ✅ | ✅ |
248
+ | `sftp-upload-file`, `sftp-download-file` | ✅ | ❌ (local side unverifiable) |
192
249
  | Background sessions | ✅ | ✅ |
193
250
  | **Interactive sessions** | ✅ | ❌ |
194
251
 
@@ -220,6 +277,9 @@ connectionIdleReapMs = 900000 # 15min
220
277
  commandQuotaPerDay = 0 # 0 = unlimited; circuit breaker for runaway agents
221
278
  approvalGrantTtlMs = 0 # 0 = always prompt; see "Approval Grants"
222
279
  approvalMode = "ask-destructive" # auto | ask-destructive | ask-all | deny
280
+ # transferRoot = "/srv/ssh-mcp-transfers" # enables the streaming file tools; see above
281
+ transferMaxBytes = 268435456 # 256MB per streaming transfer
282
+ transferTimeoutMs = 300000 # 5min with no bytes moving (idle, not total)
223
283
 
224
284
  [[profiles]]
225
285
  name = "prod-web-1"
@@ -242,6 +302,8 @@ sessionMaxPerConnection = 3 # per-profile override
242
302
  sessionIdleTimeoutMs = 300000 # stricter for prod
243
303
  commandQuotaPerDay = 200 # per-profile override
244
304
  maxChars = 2000 # per-profile override; stricter for prod
305
+ transferMaxBytes = 16777216 # per-profile override; transferRoot is not per-profile
306
+ transferTimeoutMs = 60000 # per-profile override
245
307
 
246
308
  # Optional. Merged over the built-in role matrix; see "Policy Engine" below.
247
309
  # roleBindings is keyed by role and then by tier, so the block below changes
package/build/cli.js CHANGED
@@ -229,6 +229,13 @@ export function defaultsFromArgv(argv) {
229
229
  commandQuotaPerDay: parseInt(argv.commandQuota) || 0,
230
230
  approvalGrantTtlMs: parseInt(argv.approvalGrantTtl) || 0,
231
231
  approvalMode: 'ask-destructive',
232
+ // No flag for transferRoot on purpose. It is the consent to touch local
233
+ // disk, and the quick-start path exists for someone who passed a host and a
234
+ // user on a command line — not a place to acquire that consent in passing.
235
+ // The streaming file tools therefore refuse under `--host/--user` until a
236
+ // config file names a root.
237
+ transferMaxBytes: 268_435_456,
238
+ transferTimeoutMs: 300_000,
232
239
  };
233
240
  }
234
241
  export async function buildAppConfig(argv) {
@@ -330,6 +337,8 @@ export async function buildAppConfig(argv) {
330
337
  sessionIdleTimeoutMs: defaults.sessionIdleTimeoutMs,
331
338
  sessionBackgroundMaxMs: defaults.sessionBackgroundMaxMs,
332
339
  commandQuotaPerDay: defaults.commandQuotaPerDay,
340
+ transferMaxBytes: defaults.transferMaxBytes,
341
+ transferTimeoutMs: defaults.transferTimeoutMs,
333
342
  };
334
343
  return { defaults, profiles: [profile] };
335
344
  }
@@ -169,6 +169,8 @@ function normalizeConfig(raw) {
169
169
  sessionIdleTimeoutMs: p.sessionIdleTimeoutMs ?? defaults.sessionIdleTimeoutMs,
170
170
  sessionBackgroundMaxMs: p.sessionBackgroundMaxMs ?? defaults.sessionBackgroundMaxMs,
171
171
  commandQuotaPerDay: p.commandQuotaPerDay ?? defaults.commandQuotaPerDay,
172
+ transferMaxBytes: p.transferMaxBytes ?? defaults.transferMaxBytes,
173
+ transferTimeoutMs: p.transferTimeoutMs ?? defaults.transferTimeoutMs,
172
174
  }));
173
175
  // Passed through untouched. mergePolicyRules() owns the layering over
174
176
  // DEFAULT_RULES, so the loader has no policy semantics of its own to get
@@ -101,6 +101,39 @@ export const defaultsSchema = z.object({
101
101
  // it is opt-in rather than a default convenience.
102
102
  approvalGrantTtlMs: z.number().int().nonnegative().default(0),
103
103
  approvalMode: approvalModeSchema.default('ask-destructive'),
104
+ /**
105
+ * Where the streaming SFTP file tools may read and write on this machine.
106
+ *
107
+ * No default, and deliberately not a per-profile key. It names a directory on
108
+ * the machine running this server rather than anything about a remote host,
109
+ * and the gate in tools/local-path.ts insists the directory be 0700 and owned
110
+ * by this account — so an operator who has not chosen one has not consented to
111
+ * those tools touching local disk at all. Absent, they refuse; there is no
112
+ * spelling of it that means "anywhere".
113
+ */
114
+ transferRoot: z.string().optional(),
115
+ // 256 MiB. Also a disk-usage bound on `transferRoot`, because a download is
116
+ // staged there as a `.part` before it is published, so the cap is how much a
117
+ // single call can put in a directory the operator may not be watching.
118
+ //
119
+ // Bounded above at MAX_SAFE_INTEGER because `assertBounds` in ssh/sftp.ts
120
+ // refuses anything larger. Without that bound here, a config naming a bigger
121
+ // number started cleanly and then failed every transfer at call time — the
122
+ // "accepted by the schema but never applied" shape this file's other comments
123
+ // exist to prevent, moved one layer down.
124
+ transferMaxBytes: z.number().int().positive().max(Number.MAX_SAFE_INTEGER).default(268_435_456),
125
+ // An *idle* budget, not a wall-clock one: it bounds one metadata round-trip,
126
+ // or one stretch of the copy with no bytes moving, and is re-armed on progress
127
+ // (see TransferBounds in ssh/sftp.ts). That is what lets a slow-but-live
128
+ // transfer of a large file survive a generous byte cap, so the two do not have
129
+ // to be divided into each other to check they are coherent (#206). It does not
130
+ // make a stall cheaper to notice: a channel that stops delivering still costs
131
+ // one full window, which at this default is five minutes.
132
+ //
133
+ // Capped at 2^31-1, the largest delay `setTimeout` honours — Node clamps a
134
+ // larger one to 1ms, so every step would fail immediately while quoting the
135
+ // value the operator asked for.
136
+ transferTimeoutMs: z.number().int().positive().max(2_147_483_647).default(300_000),
104
137
  }).strict();
105
138
  export const profileSchema = z.object({
106
139
  name: z.string().min(1),
@@ -140,6 +173,11 @@ export const profileSchema = z.object({
140
173
  sessionIdleTimeoutMs: z.number().int().positive().optional(),
141
174
  sessionBackgroundMaxMs: z.number().int().positive().optional(),
142
175
  commandQuotaPerDay: z.number().int().nonnegative().optional(),
176
+ // Per-profile, unlike transferRoot: how large a transfer may be, and how long
177
+ // a silent wire is tolerated, are properties of the link to that host. Same
178
+ // upper bounds as [defaults], for the same reason.
179
+ transferMaxBytes: z.number().int().positive().max(Number.MAX_SAFE_INTEGER).optional(),
180
+ transferTimeoutMs: z.number().int().positive().max(2_147_483_647).optional(),
143
181
  }).strict();
144
182
  /**
145
183
  * Overrides merged over the compiled-in DEFAULT_RULES at startup.
@@ -41,6 +41,95 @@ export function sanitizeCommand(command, maxChars) {
41
41
  }
42
42
  return cleaned;
43
43
  }
44
+ /**
45
+ * Every character that could make the audited string, the approval prompt and
46
+ * the path actually used disagree with each other.
47
+ *
48
+ * C0 and C1 controls, the Unicode line separators, the whole Bidi_Control set
49
+ * (U+061C, U+200E, U+200F, U+202A-U+202E, U+2066-U+2069) and the zero-width
50
+ * formatters. A remote path is quoted back to a human in the approval prompt
51
+ * and written into a hash-chained audit record, so a name carrying a
52
+ * right-to-left override renders as one path and transfers another, and one
53
+ * carrying a zero-width space renders identically to a different path.
54
+ *
55
+ * An earlier version stopped at the overrides and isolates, which left the
56
+ * marks that reorder *neutral* characters — and a path is mostly neutrals:
57
+ * slashes, dots, hyphens and digits.
58
+ *
59
+ * Exported because `tools/local-path.ts` asks the same question of the local
60
+ * half. The two *functions* are split for a real reason — that one has to stat
61
+ * and this one must not — but the character class is one threat model, and two
62
+ * copies of it drift the first time a codepoint is added.
63
+ */
64
+ export const PATH_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u2028-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/;
65
+ /** Longer than any path a real filesystem accepts, so this bounds nothing legitimate. */
66
+ const MAX_REMOTE_PATH_CHARS = 4096;
67
+ /** What the audit record calls a path that never passed validation. */
68
+ export const REJECTED_REMOTE_PATH = '(rejected: invalid remote path)';
69
+ /**
70
+ * Validate a caller-supplied *remote* path for the streaming SFTP file tools.
71
+ *
72
+ * Pure: it touches no filesystem and reaches no host, which is what lets it run
73
+ * before the policy decision (#207). The local half of the same question is
74
+ * `tools/local-path.ts`, and that one cannot be pure — it has to stat — so it
75
+ * runs after approval instead.
76
+ *
77
+ * Leading and trailing whitespace is **refused**, not trimmed. `sanitizeCommand`
78
+ * trims because a shell ignores whitespace at either end of a command, so
79
+ * trimming cannot change what runs. A path is not a command: only NUL and `/`
80
+ * are excluded from a POSIX filename, so `"report.txt "` and `"report.txt"` are
81
+ * two different files. Trimming here silently retargeted the transfer and
82
+ * audited the name it did not use.
83
+ *
84
+ * Shell metacharacters are deliberately *not* refused. The path is interpolated
85
+ * into a synthetic command (`sftp:upload-file <path>`) that the classifier then
86
+ * reads, and `classifyCommand` treats anything a shell would read as a carrier
87
+ * by taking the *higher* class — so `/tmp/x; sudo id` classifies `privileged`
88
+ * and is refused by policy rather than slipping through at `destructive`. A
89
+ * filename that genuinely contains a `$` is refused for the same reason, which
90
+ * is the direction to fail in.
91
+ */
92
+ export function sanitizeRemotePath(path) {
93
+ if (typeof path !== 'string') {
94
+ throw new McpError(ErrorCode.InvalidParams, 'Remote path must be a string');
95
+ }
96
+ if (!path) {
97
+ throw new McpError(ErrorCode.InvalidParams, 'Remote path cannot be empty');
98
+ }
99
+ if (path !== path.trim()) {
100
+ throw new McpError(ErrorCode.InvalidParams, 'Remote path cannot begin or end with whitespace: a filename may legitimately ' +
101
+ 'contain it, so trimming would transfer a different file than the one named ' +
102
+ 'and audit the name it did not use.');
103
+ }
104
+ if (path.length > MAX_REMOTE_PATH_CHARS) {
105
+ throw new McpError(ErrorCode.InvalidParams, `Remote path is too long (max ${MAX_REMOTE_PATH_CHARS} characters)`);
106
+ }
107
+ if (PATH_CONTROL_CHARS.test(path)) {
108
+ throw new McpError(ErrorCode.InvalidParams, 'Remote path cannot contain control, bidirectional or zero-width formatting ' +
109
+ 'characters: the approval prompt and the audit record quote this path back, ' +
110
+ 'and such a character makes what is shown differ from what is transferred.');
111
+ }
112
+ return path;
113
+ }
114
+ /**
115
+ * The same path, or a fixed placeholder when it is invalid — never throws.
116
+ *
117
+ * So that a refused call still has something to file an audit record under. The
118
+ * audited string has to be built before `runAudited` can evaluate anything, and
119
+ * building it from an unvalidated path would put a control character into a
120
+ * hash-chained log; building it from nothing left a client probing the
121
+ * validation boundary invisible to the operator, which is the failure
122
+ * `pipeline.ts` moved sanitization inside its own try to remove. The throwing
123
+ * check still runs, in `preCheck`, where the refusal is audited.
124
+ */
125
+ export function remotePathForAudit(path) {
126
+ try {
127
+ return sanitizeRemotePath(path);
128
+ }
129
+ catch {
130
+ return REJECTED_REMOTE_PATH;
131
+ }
132
+ }
44
133
  export function sanitizeSessionName(name) {
45
134
  if (!/^[a-zA-Z0-9_-]{1,64}$/.test(name)) {
46
135
  throw new McpError(ErrorCode.InvalidParams, 'Session name must be 1-64 chars, alphanumeric/dash/underscore only');
package/build/index.js CHANGED
@@ -6,7 +6,9 @@ import { ConnectionRegistry } from './ssh/connection-registry.js';
6
6
  import { PolicyEngine, resolvePolicyRules } from './policy/engine.js';
7
7
  import { AuditStore } from './audit/store.js';
8
8
  import { registerTools, registerResources, getToolHashes } from './tools/registry.js';
9
+ import { transferForbiddenDirs } from './tools/forbidden-dirs.js';
9
10
  import { initKeychain } from './config/credential-resolver.js';
11
+ import { getConfigPath } from './config/loader.js';
10
12
  import { SERVER_VERSION } from './version.js';
11
13
  import { parseArgv, buildAppConfig, checkRemovedFlags, flagEnabled, parseFailureLimit, parseOpaUrl, parseOpaTimeout, resolveHostKeyMode, } from './cli.js';
12
14
  async function main() {
@@ -25,6 +27,13 @@ async function main() {
25
27
  const { initTracing } = await import('./observability/tracer.js');
26
28
  await initTracing(argv.otelEndpoint, argv.otelServiceName || 'ssh-mcp');
27
29
  }
30
+ // Everything the transfer-root gate needs that it must not reach for itself:
31
+ // which config file was actually loaded, and which directories are off limits.
32
+ const localPath = {
33
+ transferRoot: config.defaults.transferRoot,
34
+ configPath: getConfigPath(argv.config || undefined),
35
+ forbidden: transferForbiddenDirs(),
36
+ };
28
37
  const registry = new ConnectionRegistry(config, hostKeyMode);
29
38
  const policy = new PolicyEngine(resolvePolicyRules(config.profiles, config.policy));
30
39
  const audit = new AuditStore(undefined, entropyScan, tamperEvident);
@@ -44,6 +53,7 @@ async function main() {
44
53
  const server = new McpServer({ name: 'SSH MCP Server', version: SERVER_VERSION }, { capabilities: { tools: {}, resources: {} } });
45
54
  registerTools(server, registry, policy, audit, {
46
55
  approvalGrantTtlMs: config.defaults.approvalGrantTtlMs,
56
+ localPath,
47
57
  });
48
58
  registerResources(server, registry);
49
59
  return server;
package/build/ssh/sftp.js CHANGED
@@ -396,8 +396,9 @@ export class SftpClient {
396
396
  *
397
397
  * Note what `overwrite: true` costs, since it is the case where the
398
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.
399
+ * target's owner and ACL do not survive. The permission bits do, unless the
400
+ * caller passes a mode — but only the low nine: setuid, setgid and the sticky
401
+ * bit are dropped rather than carried across. See the chmod below.
401
402
  */
402
403
  async uploadFile(source, remotePath, opts) {
403
404
  assertBounds(opts.idleTimeoutMs, opts.maxBytes, 'SFTP upload');
@@ -433,8 +434,16 @@ export class SftpClient {
433
434
  if (inheritedMode !== undefined) {
434
435
  // Without this, replacing a 0644 service config with a 0600 one is a
435
436
  // successful-looking upload that silently breaks every other reader.
437
+ //
438
+ // The low nine bits only. `& 0o7777` also carried setuid, setgid and
439
+ // the sticky bit across, which turns "may replace this file" into "may
440
+ // run code as its owner" — measured against a 04755 destination owned
441
+ // by the SSH user: the content became the caller's and the mode stayed
442
+ // 4755. Nothing else in the upload path could have caught it, because
443
+ // the caller never named a mode; it arrived by inheritance, through the
444
+ // one argument the caller can omit.
436
445
  await callbackBeforeDeadline(opts, 'SFTP upload chmod', (callback) => {
437
- sftp.chmod(temporary, inheritedMode & 0o7777, (err) => callback(err ?? undefined));
446
+ sftp.chmod(temporary, inheritedMode & 0o777, (err) => callback(err ?? undefined));
438
447
  });
439
448
  }
440
449
  if (!opts.overwrite && await exists()) {
@@ -574,6 +583,15 @@ export class SftpClient {
574
583
  let retained = 0;
575
584
  let responseBytes = 0;
576
585
  let budgetTruncated = false;
586
+ // The loop advances on retained entries or on EOF, and neither is
587
+ // guaranteed: a batch holding only `.`/`..` is legitimately empty after
588
+ // filtering and must not be read as EOF, so a server answering every
589
+ // READDIR that way kept this issuing requests forever. Each step has its
590
+ // own idle bound; nothing bounded the number of steps, and a synthetic
591
+ // tool call carries no overall deadline — so the call never settled.
592
+ // Sixteen is far past any real directory layout and still terminates.
593
+ const MAX_BARREN_BATCHES = 16;
594
+ let barren = 0;
577
595
  try {
578
596
  while (retained <= opts.maxEntries && !budgetTruncated) {
579
597
  const batch = await callbackBeforeDeadline(opts, 'SFTP list read', (callback) => {
@@ -593,6 +611,7 @@ export class SftpClient {
593
611
  });
594
612
  if (batch === null)
595
613
  break;
614
+ const before = retained;
596
615
  for (const entry of batch) {
597
616
  if (entry.filename === '.' || entry.filename === '..')
598
617
  continue;
@@ -614,6 +633,14 @@ export class SftpClient {
614
633
  break;
615
634
  entries.push(toSftpStat(join(remotePath, entry.filename), entry.attrs));
616
635
  }
636
+ if (retained === before && !budgetTruncated) {
637
+ if (++barren >= MAX_BARREN_BATCHES) {
638
+ throw new Error(`SFTP list error: server returned ${MAX_BARREN_BATCHES} consecutive batches with no entries and no EOF`);
639
+ }
640
+ }
641
+ else {
642
+ barren = 0;
643
+ }
617
644
  }
618
645
  }
619
646
  finally {
@@ -10,6 +10,9 @@ export const TOOL_DESCRIPTIONS = {
10
10
  'privileged-command': 'Execute a command with sudo elevation. Goes through the approval gate; approvalPolicy on the profile decides whether that is a prompt, an automatic allow, or a refusal. The sudo password is piped via stdin (never visible in process list). Single-line only: a command containing a line break is refused, so upload a multi-line script with sftp-upload and run it by path.',
11
11
  'sftp-upload': 'Upload a file to the remote server via SFTP (secure file transfer, not shell-based).',
12
12
  'sftp-download': 'Download a file from the remote server via SFTP.',
13
+ 'sftp-list': 'List a remote directory over SFTP, with a bounded number of entries and a bounded response size. Read-only.',
14
+ 'sftp-upload-file': 'Upload a local file to the remote host over SFTP, streaming it without passing the contents through model context. The local file must be inside the transferRoot directory the operator configured; without that setting this tool refuses. Use this for binary or large files; use sftp-upload for short text you already have.',
15
+ 'sftp-download-file': 'Download a remote file to local disk over SFTP, streaming it without passing the contents through model context. The destination must be inside the transferRoot directory the operator configured; without that setting this tool refuses. Use this for binary or large files; use sftp-download when you need to read the contents.',
13
16
  'signal-process': 'Send a signal (INT, TERM, KILL) to a remote process by PID.',
14
17
  };
15
18
  export function getToolHashes() {
@@ -0,0 +1,31 @@
1
+ import { homedir } from 'node:os';
2
+ import { dirname, join } from 'node:path';
3
+ import { getAuditLogPath } from '../audit/store.js';
4
+ /**
5
+ * Directories the transfer root must not overlap, beyond the two the gate
6
+ * already derives for itself (the installation and the config directory).
7
+ *
8
+ * Its own module rather than a literal at the call site in index.ts, because
9
+ * there the list sat inside `main()`'s body, which no test can reach — it is not
10
+ * exported, and nothing short of starting the server evaluates it. So the list
11
+ * was the one input to the transfer-root gate that nothing verified, while the
12
+ * gate itself was thoroughly tested against whatever it was handed. (index.ts
13
+ * does not run `main()` under test: the call is gated on SSH_MCP_DISABLE_MAIN,
14
+ * which every test script sets. Unreachable, not unimportable.) The gate takes
15
+ * the list as an argument precisely so that knowing where the audit log went is
16
+ * somebody else's job — this is that somebody.
17
+ *
18
+ * Both entries are conventionally 0700 and owner-owned, so every privacy check
19
+ * the gate applies accepts them. They have to be excluded by name:
20
+ *
21
+ * - a root containing the audit log lets a download replace the tamper-evident
22
+ * record of the transfer that wrote it;
23
+ * - a root shaped like `~/.ssh` turns the same two tools into "read the private
24
+ * key" and "append to authorized_keys".
25
+ */
26
+ export function transferForbiddenDirs() {
27
+ return [
28
+ { path: dirname(getAuditLogPath()), reason: 'the audit log directory' },
29
+ { path: join(homedir(), '.ssh'), reason: 'the SSH client directory (~/.ssh)' },
30
+ ];
31
+ }
@@ -7,9 +7,12 @@ import { fileURLToPath } from 'node:url';
7
7
  import { promisify } from 'node:util';
8
8
  import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
9
9
  import { isWithinRoot, pathsOverlap } from '../config/path-containment.js';
10
+ // One class, one threat model. It lived here as a private copy and in
11
+ // guard/sanitizer.ts as a byte-identical second one, so widening it — a bidi
12
+ // mark, a zero-width formatter — reached whichever file the author had open.
13
+ import { PATH_CONTROL_CHARS } from '../guard/sanitizer.js';
10
14
  const INSTALL_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
11
15
  const NO_FOLLOW = constants.O_NOFOLLOW ?? 0;
12
- const PATH_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028-\u202e\u2066-\u2069]/;
13
16
  /**
14
17
  * `fs.promises.realpath` does not expand Windows 8.3 short names, so two
15
18
  * spellings of one path can compare unequal. The native binding does. On POSIX
@@ -27,6 +30,33 @@ function validateInput(input) {
27
30
  invalid('Local path cannot contain control or bidi formatting characters');
28
31
  }
29
32
  }
33
+ /** What the audit record and the approval prompt call a path that never validated. */
34
+ export const REJECTED_LOCAL_PATH = '(rejected: invalid local path)';
35
+ /**
36
+ * The caller's own spelling of a local path, or a placeholder — never throws,
37
+ * and touches no filesystem.
38
+ *
39
+ * Everything else in this file has to stat, which is why it all runs after the
40
+ * policy decision. This one does not, which is what lets the *approval prompt*
41
+ * name both ends of a transfer. Without it the human approving a download saw
42
+ * the remote path and nothing about where on their own disk the bytes would
43
+ * land — and the local write is the half that makes a download `destructive`.
44
+ *
45
+ * It is the caller's spelling, not the resolved one: resolving needs `realpath`.
46
+ * `onResolved` replaces it with the canonical form once the gate has run, so the
47
+ * audit record ends up with the path actually used.
48
+ */
49
+ export function localPathForAudit(input) {
50
+ if (typeof input !== 'string')
51
+ return REJECTED_LOCAL_PATH;
52
+ try {
53
+ validateInput(input);
54
+ return input;
55
+ }
56
+ catch {
57
+ return REJECTED_LOCAL_PATH;
58
+ }
59
+ }
30
60
  function display(root, candidate) {
31
61
  const value = relative(root, candidate);
32
62
  return value || '.';
@@ -179,10 +179,21 @@ export function createPipeline({ server, registry, policy, audit, approvalGrantT
179
179
  throw new PolicyRefusedError(`QUOTA_EXCEEDED: profile "${profileName}" has used its ${profile.commandQuotaPerDay} commands ` +
180
180
  `for the last 24h. Next slot frees at ${budget.retryAt?.toISOString()}.`, { ...evaluation, decision: 'deny', ruleId: 'command-quota' });
181
181
  }
182
+ const approved = effective;
183
+ const refineCommand = (refined) => {
184
+ if (!refined.startsWith(approved)) {
185
+ throw new Error('Internal: an audited command may only be elaborated, not replaced');
186
+ }
187
+ state.command = refined;
188
+ };
182
189
  const { audited, output } = await run({
183
190
  conn, command: effective, profileName, onProgress, abortSignal, extra: opts.extra,
191
+ refineCommand,
184
192
  });
185
- await auditResult(ctx, profileName, effective, evaluation, audited, approver);
193
+ // `state.command`, not `effective`: identical unless the handler refined
194
+ // it, and the refinement is exactly what the success record should carry.
195
+ // The failure path below already reads `state`, so the two agree.
196
+ await auditResult(ctx, profileName, state.command, evaluation, audited, approver);
186
197
  return output;
187
198
  }
188
199
  catch (err) {
@@ -2,14 +2,16 @@ import { createPipeline } from './pipeline.js';
2
2
  import { registerSessionTools } from './session-tools.js';
3
3
  import { registerCommandTools } from './command-tools.js';
4
4
  import { registerFileTools } from './file-tools.js';
5
+ import { registerTransferTools } from './transfer-tools.js';
5
6
  // Re-exported so existing importers keep a single entry point for the tool layer.
6
7
  export { TOOL_DESCRIPTIONS, getToolHashes } from './descriptions.js';
7
8
  export { registerResources } from './resources.js';
8
9
  /**
9
10
  * Wire every MCP tool onto the server.
10
11
  *
11
- * The handlers live in three groups by subject (sessions, commands, files) and
12
- * share one audited pipeline, built here. Splitting them up is what keeps the
12
+ * The handlers live in four groups by subject (sessions, commands, text-mode
13
+ * file transfer, streaming file transfer) and share one audited pipeline, built
14
+ * here. Splitting them up is what keeps the
13
15
  * pipeline the only route from caller input to a remote command: a handler that
14
16
  * wanted to bypass policy or audit would have to reach past `pipeline` for the
15
17
  * registry, which it is not given.
@@ -20,4 +22,5 @@ export function registerTools(server, registry, policy, audit, opts = {}) {
20
22
  registerSessionTools(deps, pipeline);
21
23
  registerCommandTools(deps, pipeline);
22
24
  registerFileTools(deps, pipeline);
25
+ registerTransferTools(deps, pipeline);
23
26
  }
@@ -44,7 +44,7 @@ export function registerSessionTools({ server, registry }, pipeline) {
44
44
  // first ("discover available hosts before running commands"), and on the one
45
45
  // channel where the startup stderr warning is not visible. The agent saw "no
46
46
  // hosts" rather than "not configured". Refusing here is what makes the claim in
47
- // the README, the changeset and the startup warning true of all eleven tools.
47
+ // the README, the changeset and the startup warning true of every tool.
48
48
  registry.assertConfigured();
49
49
  const connections = registry.listConnections();
50
50
  const profiles = registry.listAllProfiles();
@@ -0,0 +1,341 @@
1
+ import { z } from 'zod';
2
+ import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
3
+ import { redactText } from '../guard/redactor.js';
4
+ import { remotePathForAudit, sanitizeRemotePath } from '../guard/sanitizer.js';
5
+ import { SftpClient } from '../ssh/sftp.js';
6
+ import { TOOL_DESCRIPTIONS as D } from './descriptions.js';
7
+ import { createLocalDownload, localFileForRead, localPathForAudit, } from './local-path.js';
8
+ import { syntheticSuccess, textResult } from './results.js';
9
+ /**
10
+ * Upper bound on `maxEntries`, independent of what the caller asks for.
11
+ *
12
+ * The response is also capped in bytes by the profile's `maxOutputBytes`, so
13
+ * this is not the thing that keeps a huge directory out of the model's context
14
+ * — that bound does. This one keeps the *count* an argument cannot inflate, so
15
+ * a caller cannot turn one tool call into an unbounded walk of a remote tree.
16
+ */
17
+ const MAX_LIST_ENTRIES = 1000;
18
+ const DEFAULT_LIST_ENTRIES = 200;
19
+ /**
20
+ * rwx triples the way `ls -l` prints them, setuid/setgid/sticky included.
21
+ *
22
+ * Reading only the low nine bits renders `-rwsr-xr-x` as `-rwxr-xr-x`: a format
23
+ * that imitates `ls -l` while dropping the only three bits that carry privilege.
24
+ * An operator or a model deciding whether a remote file is safe to overwrite
25
+ * could not see that it was setuid — which is precisely the precondition that
26
+ * makes overwriting one dangerous.
27
+ */
28
+ function renderPermissions(mode) {
29
+ const rwx = 'rwxrwxrwx';
30
+ const out = [];
31
+ for (let i = 0; i < 9; i++)
32
+ out.push((mode & (1 << (8 - i))) ? rwx[i] : '-');
33
+ // Each special bit replaces the x of its own triple, upper-case when that x is
34
+ // not set — setuid without execute is `S`, with it `s`, as `ls` renders it.
35
+ const special = [
36
+ [0o4000, 2, 's'],
37
+ [0o2000, 5, 's'],
38
+ [0o1000, 8, 't'],
39
+ ];
40
+ for (const [bit, at, char] of special) {
41
+ if (mode & bit)
42
+ out[at] = out[at] === 'x' ? char : char.toUpperCase();
43
+ }
44
+ return out.join('');
45
+ }
46
+ /**
47
+ * The `ls -l` type character, from the mode's file-type bits.
48
+ *
49
+ * Read from the mode rather than from `isDirectory`/`isFile`, which between
50
+ * them cover two of the seven types and report everything else as neither.
51
+ * Measured against the test image's `/usr/bin`, where nearly every entry is a
52
+ * symlink: a listing built from those two booleans rendered the whole directory
53
+ * as `?`, which tells a model less than the raw filename did.
54
+ */
55
+ function typeChar(mode) {
56
+ switch (mode & 0o170000) {
57
+ case 0o040000: return 'd';
58
+ case 0o100000: return '-';
59
+ case 0o120000: return 'l';
60
+ case 0o060000: return 'b';
61
+ case 0o020000: return 'c';
62
+ case 0o010000: return 'p';
63
+ case 0o140000: return 's';
64
+ default: return '?';
65
+ }
66
+ }
67
+ /** Width of an ISO timestamp, so the placeholder below keeps the columns aligned. */
68
+ const MTIME_WIDTH = 24;
69
+ /**
70
+ * A remote mtime is optional on the wire, so it may be no date at all.
71
+ *
72
+ * ssh2 fills `attrs.mtime` only when the entry carries SSH_FILEXFER_ACMODTIME,
73
+ * and `toSftpStat` turns a missing one into `new Date(NaN)`. `toISOString()`
74
+ * throws `RangeError: Invalid time value` on that, inside the `map` that builds
75
+ * the response — so a single entry from a server that omits the flag failed the
76
+ * whole listing with an opaque error instead of returning the directory. The
77
+ * mode fields already degrade rather than throw (`undefined & mask` is 0, which
78
+ * renders as `?` and `---------`); this makes the timestamp behave the same way.
79
+ */
80
+ function renderMtime(mtime) {
81
+ return Number.isNaN(mtime.getTime()) ? '(no mtime)'.padStart(MTIME_WIDTH) : mtime.toISOString();
82
+ }
83
+ function renderEntry(entry) {
84
+ const size = String(entry.size).padStart(12, ' ');
85
+ return `${typeChar(entry.mode)}${renderPermissions(entry.mode)} ${size} ${renderMtime(entry.mtime)} ${entry.path}`;
86
+ }
87
+ /**
88
+ * Render the listing, stopping at the byte budget the caller's profile sets.
89
+ *
90
+ * `SftpClient.list` bills `filename + longname + 256` per retained entry, which
91
+ * is a proxy for what a line costs and not the line itself: `entry.path` is the
92
+ * directory joined to the filename, so the directory is repeated on every line
93
+ * and the flat 256 never covered it. With a long remote path the rendered body
94
+ * ran several times past the profile's `maxOutputBytes` while the layer that
95
+ * believed it was enforcing the budget had spent a fraction of it.
96
+ *
97
+ * So the bound is applied to the bytes actually produced. The primitive's budget
98
+ * still matters — it is what stops a hostile server's `longname` being retained
99
+ * in memory — but the number the operator configured now describes the thing
100
+ * they configured it for, which is what reaches the model.
101
+ */
102
+ function renderListing(entries, maxBytes) {
103
+ const lines = [];
104
+ let bytes = 0;
105
+ for (const entry of entries) {
106
+ const line = renderEntry(entry);
107
+ const cost = Buffer.byteLength(line, 'utf8') + 1;
108
+ if (bytes + cost > maxBytes)
109
+ return { body: lines.join('\n'), truncated: true };
110
+ bytes += cost;
111
+ lines.push(line);
112
+ }
113
+ return { body: lines.join('\n'), truncated: false };
114
+ }
115
+ /**
116
+ * Refuse a mode that grants authority the transfer itself does not carry.
117
+ *
118
+ * Everything above the low nine bits is setuid, setgid and the sticky bit. A
119
+ * caller allowed to write a file is not thereby allowed to write one that runs
120
+ * as its owner, and `uploadFile` passes this straight to the remote `open`.
121
+ *
122
+ * `0` is refused rather than accepted. `uploadFile` reads it as "unset" through
123
+ * an `||`, so a caller asking for mode 0 got 0600 — and on the overwrite path it
124
+ * also suppressed the inherit-the-destination's-mode step, yielding neither the
125
+ * mode asked for nor the one replaced. An argument silently rewritten is worse
126
+ * than one refused.
127
+ *
128
+ * Runs as a `preCheck`, i.e. inside the pipeline, so a caller probing the
129
+ * boundary leaves an audit record. The range is deliberately *not* duplicated as
130
+ * a zod bound on the field: the SDK validates the schema before the handler is
131
+ * entered, so a bound there refused a setuid mode outside the pipeline — no
132
+ * audit record, and this message unreachable. Measured: with both in place, a
133
+ * probe at 0o4755 produced no audit record at all.
134
+ */
135
+ export function checkMode(mode) {
136
+ if (mode === undefined)
137
+ return undefined;
138
+ if (!Number.isInteger(mode) || mode < 1 || mode > 0o777) {
139
+ throw new McpError(ErrorCode.InvalidParams, 'mode must be between 1 and 511 (0o777), or omitted. setuid, setgid and the sticky bit ' +
140
+ 'are refused: uploading a file does not carry the authority to make it run as its owner. ' +
141
+ '0 is refused because it is read as "unset" downstream rather than as a mode.');
142
+ }
143
+ return mode;
144
+ }
145
+ /** The arguments that change what a transfer does, spelled for policy and the approver. */
146
+ function effectSuffix(overwrite, mode) {
147
+ return (overwrite ? ' --overwrite' : '') + (mode !== undefined ? ` --mode=${mode.toString(8)}` : '');
148
+ }
149
+ /**
150
+ * What the audit record adds once the local path has been resolved.
151
+ *
152
+ * Appended rather than substituted, and empty when the two agree. The refinement
153
+ * has to satisfy the pipeline's append-only rule — the audited string may
154
+ * elaborate what was approved, never replace it — and swapping `./sub/../x.bin`
155
+ * for `x.bin` is a replacement, which `refineCommand` correctly threw on.
156
+ *
157
+ * Appending turns out to be the better record anyway: an auditor sees the
158
+ * spelling the caller sent *and* the file it resolved to, so a divergence
159
+ * between them is visible rather than silently normalised away.
160
+ */
161
+ function resolvedSuffix(asked, resolved) {
162
+ return asked === resolved ? '' : ` (resolved ${resolved})`;
163
+ }
164
+ /**
165
+ * Streaming SFTP tools: bounded directory listing, and transfers that move
166
+ * between the remote host and local disk without passing through model context.
167
+ *
168
+ * Two orderings are load-bearing here, and both come from #207.
169
+ *
170
+ * **Nothing observable precedes the decision.** The audited string is built from
171
+ * the caller's own spelling of both paths, through `remotePathForAudit` and
172
+ * `localPathForAudit`, neither of which performs any I/O. Every local effect —
173
+ * the existence check, the staged `.part`, the operator-configuration errors the
174
+ * transfer-root gate produces — happens inside `run()`, which the pipeline
175
+ * reaches only after the call is allowed and approved. The throwing validators
176
+ * run as `preCheck`, inside the pipeline's own try, so a refused call still
177
+ * leaves an audit record rather than letting a client probe the boundary
178
+ * invisibly.
179
+ *
180
+ * **The decision is about the whole operation.** `overwrite` and `mode` change
181
+ * what a call does — one destroys an existing remote file, the other sets its
182
+ * permissions — so they belong in the string policy classifies, in the prompt a
183
+ * human reads, and in the key an approval grant is remembered under. Left out,
184
+ * one approval of a path covered every other spelling of the call.
185
+ *
186
+ * The resolved local path is then *appended* to the audit record through
187
+ * `onResolved`, which the gate fires after confining the path and before it
188
+ * stats or opens anything — so a refusal raised by the checks that follow is
189
+ * audited with the resolution too, not only a success. Appended and not
190
+ * substituted, because the pipeline's rule is that an audited string may be
191
+ * elaborated and never replaced, and because an auditor is better served seeing
192
+ * both the spelling that was approved and the file it resolved to.
193
+ */
194
+ export function registerTransferTools({ server, localPath }, pipeline) {
195
+ const { runAudited } = pipeline;
196
+ // Absent only when nothing was configured. The gate reads `transferRoot` and
197
+ // refuses with the message that names the config key, which is the same
198
+ // answer an operator who has a config but no root should get.
199
+ const ctx = localPath ?? { transferRoot: undefined };
200
+ // ─── sftp-list ─────────────────────────────────────────────────────────
201
+ server.tool('sftp-list', D['sftp-list'], {
202
+ remotePath: z.string().describe('Remote directory to list'),
203
+ maxEntries: z.number().int().min(1).max(MAX_LIST_ENTRIES).optional()
204
+ .describe(`Max entries to return (default ${DEFAULT_LIST_ENTRIES}, hard cap ${MAX_LIST_ENTRIES})`),
205
+ profile: z.string().optional().describe('Profile name'),
206
+ }, { readOnlyHint: true }, async ({ remotePath, maxEntries, profile }, extra) => {
207
+ return runAudited(`sftp:list ${remotePathForAudit(remotePath)}`, {
208
+ toolName: 'sftp-list',
209
+ failureClass: 'read-only',
210
+ profile,
211
+ extra,
212
+ synthetic: true,
213
+ preCheck: () => { sanitizeRemotePath(remotePath); },
214
+ }, async (rt) => {
215
+ const budget = rt.conn.profile.maxOutputBytes;
216
+ const { entries, truncated } = await new SftpClient(rt.conn).list(remotePath, {
217
+ maxEntries: maxEntries ?? DEFAULT_LIST_ENTRIES,
218
+ // The listing is output headed for the model, so it is billed
219
+ // against the same budget every other tool's output is.
220
+ maxResponseBytes: budget,
221
+ idleTimeoutMs: rt.conn.profile.transferTimeoutMs,
222
+ abortSignal: rt.abortSignal,
223
+ });
224
+ const rendered = renderListing(entries, budget);
225
+ const body = rendered.body || '(empty directory)';
226
+ const note = truncated || rendered.truncated
227
+ ? '\n\n[truncated: more entries exist than the entry or byte budget allowed]'
228
+ : '';
229
+ return {
230
+ audited: syntheticSuccess(rt.profileName),
231
+ // Filenames are remote, attacker-influenced content on their way
232
+ // into model context, exactly like command output.
233
+ output: textResult(redactText(body, { entropyScan: true }) + note),
234
+ };
235
+ });
236
+ });
237
+ // ─── sftp-upload-file ──────────────────────────────────────────────────
238
+ server.tool('sftp-upload-file', D['sftp-upload-file'], {
239
+ localPath: z.string().describe('Local file to upload, inside defaults.transferRoot'),
240
+ remotePath: z.string().describe('Remote destination path'),
241
+ overwrite: z.boolean().optional().describe('Replace an existing remote file (default false)'),
242
+ // The range is deliberately NOT declared here, only in `checkMode`. The SDK
243
+ // validates this schema before the handler runs, so a zod bound refuses a
244
+ // setuid mode *outside* the pipeline — no audit record, and the message
245
+ // explaining why setuid is refused unreachable. A probe at a security
246
+ // boundary that leaves no trace is the thing `preCheck` exists to prevent,
247
+ // so the bound lives where the refusal can be logged. `.int()` stays,
248
+ // because a non-integer is a type error rather than a probe.
249
+ mode: z.number().int().optional()
250
+ .describe('Remote file mode, 1 to 511 (0o777); setuid, setgid and the sticky bit are refused. e.g. 420 for 0644. Omit for 0600 on a new file, or the replaced file’s permission bits.'),
251
+ profile: z.string().optional().describe('Profile name'),
252
+ }, { destructiveHint: true }, async ({ localPath: source, remotePath, overwrite, mode, profile }, extra) => {
253
+ const target = remotePathForAudit(remotePath);
254
+ const approved = `sftp:upload-file ${target}${effectSuffix(overwrite, mode)} <- ${localPathForAudit(source)}`;
255
+ return runAudited(approved, {
256
+ toolName: 'sftp-upload-file',
257
+ failureClass: 'destructive',
258
+ profile,
259
+ extra,
260
+ synthetic: true,
261
+ preCheck: () => { sanitizeRemotePath(remotePath); checkMode(mode); },
262
+ }, async (rt) => {
263
+ const maxBytes = rt.conn.profile.transferMaxBytes;
264
+ // Refined through `onResolved`, which the gate fires after confining
265
+ // the path and before it stats or opens anything. Refining after the
266
+ // call returned covered only the success path — every refusal the gate
267
+ // itself raises (symlink source, outside the root, the root's own
268
+ // misconfiguration) audited the caller's spelling instead.
269
+ const local = await localFileForRead(ctx, source, (displayPath) => {
270
+ rt.refineCommand(`${approved}${resolvedSuffix(source, displayPath)}`);
271
+ });
272
+ try {
273
+ // Checked here as well as inside the transfer, because the size is
274
+ // already known: refusing before a byte moves beats refusing after
275
+ // the cap is passed mid-stream and a remote `.part` has to be
276
+ // cleaned up.
277
+ if (local.size > maxBytes) {
278
+ throw new McpError(ErrorCode.InvalidParams, `Local file is ${local.size} bytes, over this profile's ${maxBytes} byte transfer limit`);
279
+ }
280
+ const bytes = await new SftpClient(rt.conn).uploadFile(local.handle.createReadStream({ autoClose: false }), remotePath, {
281
+ maxBytes,
282
+ idleTimeoutMs: rt.conn.profile.transferTimeoutMs,
283
+ abortSignal: rt.abortSignal,
284
+ overwrite,
285
+ mode,
286
+ });
287
+ return {
288
+ audited: syntheticSuccess(rt.profileName),
289
+ output: textResult(`Uploaded ${bytes} bytes from ${local.displayPath} to ${remotePath}`),
290
+ };
291
+ }
292
+ finally {
293
+ await local.handle.close().catch(() => { });
294
+ }
295
+ });
296
+ });
297
+ // ─── sftp-download-file ────────────────────────────────────────────────
298
+ server.tool('sftp-download-file', D['sftp-download-file'], {
299
+ remotePath: z.string().describe('Remote file to download'),
300
+ localPath: z.string().describe('Local destination, inside defaults.transferRoot'),
301
+ overwrite: z.boolean().optional().describe('Replace an existing local file (default false)'),
302
+ profile: z.string().optional().describe('Profile name'),
303
+ }, { destructiveHint: true }, async ({ remotePath, localPath: destination, overwrite, profile }, extra) => {
304
+ const target = remotePathForAudit(remotePath);
305
+ const approved = `sftp:download-file ${target}${effectSuffix(overwrite)} -> ${localPathForAudit(destination)}`;
306
+ return runAudited(approved, {
307
+ toolName: 'sftp-download-file',
308
+ failureClass: 'destructive',
309
+ profile,
310
+ extra,
311
+ synthetic: true,
312
+ preCheck: () => { sanitizeRemotePath(remotePath); },
313
+ }, async (rt) => {
314
+ // As on the upload side, and for the same reason: refined before the
315
+ // `.part` exists, so the overwrite and symlink refusals are audited
316
+ // with the resolved destination, and a throw from `refineCommand`
317
+ // itself cannot strand a staged file.
318
+ const download = await createLocalDownload(ctx, destination, overwrite ?? false, (t) => {
319
+ rt.refineCommand(`${approved}${resolvedSuffix(destination, t.displayPath)}`);
320
+ });
321
+ try {
322
+ const bytes = await new SftpClient(rt.conn).downloadFile(remotePath, download.createStream(), {
323
+ maxBytes: rt.conn.profile.transferMaxBytes,
324
+ idleTimeoutMs: rt.conn.profile.transferTimeoutMs,
325
+ abortSignal: rt.abortSignal,
326
+ });
327
+ await download.publish();
328
+ return {
329
+ audited: syntheticSuccess(rt.profileName),
330
+ output: textResult(`Downloaded ${bytes} bytes from ${remotePath} to ${download.target.displayPath}`),
331
+ };
332
+ }
333
+ finally {
334
+ // Always, and after publish(): destroying the stream closes the
335
+ // handle, and publish() needs it open to sync(). On the failure
336
+ // path this is what removes the staged `.part`.
337
+ await download.cleanup();
338
+ }
339
+ });
340
+ });
341
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ssh-mcp",
3
3
  "license": "MIT",
4
- "version": "2.8.3",
4
+ "version": "2.9.0",
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",