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 +63 -1
- package/build/cli.js +9 -0
- package/build/config/loader.js +2 -0
- package/build/config/schema.js +38 -0
- package/build/guard/sanitizer.js +89 -0
- package/build/index.js +10 -0
- package/build/ssh/sftp.js +30 -3
- package/build/tools/descriptions.js +3 -0
- package/build/tools/forbidden-dirs.js +31 -0
- package/build/tools/local-path.js +31 -1
- package/build/tools/pipeline.js +12 -1
- package/build/tools/registry.js +5 -2
- package/build/tools/session-tools.js +1 -1
- package/build/tools/transfer-tools.js +341 -0
- package/package.json +1 -1
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 (
|
|
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
|
}
|
package/build/config/loader.js
CHANGED
|
@@ -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
|
package/build/config/schema.js
CHANGED
|
@@ -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.
|
package/build/guard/sanitizer.js
CHANGED
|
@@ -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
|
|
400
|
-
* passes
|
|
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 &
|
|
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 || '.';
|
package/build/tools/pipeline.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/build/tools/registry.js
CHANGED
|
@@ -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
|
|
12
|
-
*
|
|
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
|
|
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.
|
|
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",
|