ssh-mcp 2.8.1 → 2.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/config/path-containment.js +15 -0
- package/build/config/windows-acl.js +7 -3
- package/build/guard/sanitizer.js +30 -2
- package/build/policy/classifier.js +15 -4
- package/build/ssh/connection.js +21 -1
- package/build/ssh/sftp.js +543 -33
- package/build/tools/descriptions.js +3 -3
- package/build/tools/local-path.js +313 -0
- package/package.json +2 -2
|
@@ -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,
|
|
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
|
-
|
|
291
|
-
|
|
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.
|
package/build/guard/sanitizer.js
CHANGED
|
@@ -1,13 +1,41 @@
|
|
|
1
1
|
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
-
|
|
2
|
+
/** Every character a shell would read as the end of one command and the start of another. */
|
|
3
|
+
const LINE_BREAKS = /[\r\n\u2028\u2029]/;
|
|
4
|
+
const NUL = /\u0000/;
|
|
5
|
+
/**
|
|
6
|
+
* Validate a caller-supplied command.
|
|
7
|
+
*
|
|
8
|
+
* A line break is refused, not removed. The constraint itself is not negotiable
|
|
9
|
+
* — a newline inside `command` would let a second command ride along past a
|
|
10
|
+
* classifier that parsed only the first, which is #44 — but an earlier version
|
|
11
|
+
* enforced it by replacing the break with a space, and *that* is the part worth
|
|
12
|
+
* changing. The caller got no error and a different command than it asked for:
|
|
13
|
+
* two lines joined, so `ls\necho x` ran `ls echo x`; or a `#` comment in a
|
|
14
|
+
* `python3 -c` body pulled onto the same line, commenting out everything after
|
|
15
|
+
* it. Sometimes that raises. Sometimes it runs and quietly does half the work,
|
|
16
|
+
* which is the failure mode worth removing (#198).
|
|
17
|
+
*
|
|
18
|
+
* Trailing and leading breaks are trimmed rather than refused. A client that
|
|
19
|
+
* appends a newline works today, refusing it would break that for no gain, and
|
|
20
|
+
* a break at either end cannot join two commands.
|
|
21
|
+
*/
|
|
3
22
|
export function sanitizeCommand(command, maxChars) {
|
|
4
23
|
if (typeof command !== 'string') {
|
|
5
24
|
throw new McpError(ErrorCode.InvalidParams, 'Command must be a string');
|
|
6
25
|
}
|
|
7
|
-
const cleaned = command.
|
|
26
|
+
const cleaned = command.trim();
|
|
8
27
|
if (!cleaned) {
|
|
9
28
|
throw new McpError(ErrorCode.InvalidParams, 'Command cannot be empty');
|
|
10
29
|
}
|
|
30
|
+
if (LINE_BREAKS.test(cleaned)) {
|
|
31
|
+
throw new McpError(ErrorCode.InvalidParams, 'Command cannot contain a line break: a second line would run past the classifier ' +
|
|
32
|
+
'that only saw the first. It is refused rather than silently joined into one line, ' +
|
|
33
|
+
'which is what happened before and changed what ran without saying so. ' +
|
|
34
|
+
'To run a multi-line script, upload it with sftp-upload and execute it by path.');
|
|
35
|
+
}
|
|
36
|
+
if (NUL.test(cleaned)) {
|
|
37
|
+
throw new McpError(ErrorCode.InvalidParams, 'Command cannot contain a null byte');
|
|
38
|
+
}
|
|
11
39
|
if (Number.isFinite(maxChars) && cleaned.length > maxChars) {
|
|
12
40
|
throw new McpError(ErrorCode.InvalidParams, `Command is too long (max ${maxChars} characters)`);
|
|
13
41
|
}
|
|
@@ -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.
|
|
1004
|
-
* from `safe` — download to `read-only`, matching
|
|
1005
|
-
* release rather than an acquisition. Lowering a class
|
|
1006
|
-
* release is the wrong place for one; they keep the class
|
|
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/connection.js
CHANGED
|
@@ -47,6 +47,8 @@ export class SSHConnection {
|
|
|
47
47
|
sessions;
|
|
48
48
|
activeChannels = 0;
|
|
49
49
|
connecting = null;
|
|
50
|
+
/** Which connect() call currently owns `connecting`; see connect(). */
|
|
51
|
+
connectAttempt = null;
|
|
50
52
|
connected = false;
|
|
51
53
|
connectedAt = null;
|
|
52
54
|
lastActivity = new Date();
|
|
@@ -94,6 +96,22 @@ export class SSHConnection {
|
|
|
94
96
|
return this.connecting;
|
|
95
97
|
}
|
|
96
98
|
async connect() {
|
|
99
|
+
// Identity for this attempt, so "does this attempt still own `connecting`?"
|
|
100
|
+
// can be asked without going through `this.client`.
|
|
101
|
+
//
|
|
102
|
+
// Those are two different questions and conflating them is #197: settle()
|
|
103
|
+
// used to clear `connecting` only `if (isCurrent())`, and the disconnect
|
|
104
|
+
// handler nulls `this.client` *before* it settles. `isCurrent()` was
|
|
105
|
+
// therefore already false, `connecting` kept the rejected promise, and
|
|
106
|
+
// `ensureConnected()` replayed it for the life of the process — every later
|
|
107
|
+
// call on the profile failing instantly with a stale message, and no way
|
|
108
|
+
// back even once the server recovered. Restarting the server was the only
|
|
109
|
+
// exit, which is exactly how it was reported.
|
|
110
|
+
//
|
|
111
|
+
// Reordering the two statements would also have worked and would have left
|
|
112
|
+
// the same trap for the next edit; the coupling is what had to go.
|
|
113
|
+
const attempt = Symbol('connect');
|
|
114
|
+
this.connectAttempt = attempt;
|
|
97
115
|
return new Promise((resolve, reject) => {
|
|
98
116
|
// Every handler below acts on `client`, not `this.client`, and only
|
|
99
117
|
// mutates shared state while it is still the current client. A late event
|
|
@@ -108,7 +126,9 @@ export class SSHConnection {
|
|
|
108
126
|
return;
|
|
109
127
|
settled = true;
|
|
110
128
|
clearTimeout(timeoutId);
|
|
111
|
-
|
|
129
|
+
// Unconditional for *this* attempt: a superseded one leaves the newer
|
|
130
|
+
// attempt's promise alone, and nothing else can strand it.
|
|
131
|
+
if (this.connectAttempt === attempt)
|
|
112
132
|
this.connecting = null;
|
|
113
133
|
fn();
|
|
114
134
|
};
|
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
|
-
*
|
|
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
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
sftp.
|
|
92
|
-
|
|
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
|
});
|
|
@@ -5,9 +5,9 @@ export const TOOL_DESCRIPTIONS = {
|
|
|
5
5
|
'open-session': 'Open a named session on a remote host. Use type="interactive" for stateful shell (CWD/env persists between commands) or type="background" for long-running processes.',
|
|
6
6
|
'close-session': 'Close a named session. A background session\'s command is signalled on the host (INT, then TERM, then KILL) before its channel is dropped; an interactive session\'s shell is ended. The response says so if the command could not be signalled or had not stopped in time.',
|
|
7
7
|
'read-session-output': 'Read recent output from a background session (e.g., tail -f logs).',
|
|
8
|
-
'read-command': 'Execute a READ-ONLY command from an allowlist (ls, cat, grep, find, stat, df, etc.). This tool does NOT modify the system. Prefer this tool for all read operations.',
|
|
9
|
-
'run-command': 'Execute an arbitrary shell command on the remote server. May modify the system. Commands classified destructive or privileged go through the approval gate; approvalPolicy on the profile decides whether that is a prompt, an automatic allow, or a refusal.',
|
|
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).',
|
|
8
|
+
'read-command': 'Execute a READ-ONLY command from an allowlist (ls, cat, grep, find, stat, df, etc.). This tool does NOT modify the system. Prefer this tool for all read operations. 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.',
|
|
9
|
+
'run-command': 'Execute an arbitrary shell command on the remote server. May modify the system. Commands classified destructive or privileged go through the approval gate; approvalPolicy on the profile decides whether that is a prompt, an automatic allow, or a refusal. 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.',
|
|
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
13
|
'signal-process': 'Send a signal (INT, TERM, KILL) to a remote process by PID.',
|
|
@@ -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.
|
|
4
|
+
"version": "2.8.3",
|
|
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.
|
|
36
|
+
"smol-toml": "^1.7.1",
|
|
37
37
|
"ssh2": "^1.17.0",
|
|
38
38
|
"zod": "^4.4.3"
|
|
39
39
|
},
|