ssh-mcp 2.9.1 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -154,7 +154,7 @@ claude mcp add --transport stdio ssh-mcp -- ssh-mcp
154
154
  | `read-command` | Execute allowlisted read-only commands (`ls`, `cat`, `grep`, ...) | ✅ | — |
155
155
  | `run-command` | Execute arbitrary commands (destructive/privileged need approval, unless `approvalPolicy = "auto"`) | — | — |
156
156
  | `privileged-command` | Execute with sudo (needs approval, unless `approvalPolicy = "auto"`) | — | ✅ |
157
- | `sftp-upload` | Upload a file via SFTP | — | ✅ |
157
+ | `sftp-upload` | Upload a file via SFTP (replaces the destination unconditionally) | — | ✅ |
158
158
  | `sftp-download` | Download a file via SFTP | ✅ | — |
159
159
  | `sftp-list` | List a remote directory, bounded in entries and bytes | ✅ | — |
160
160
  | `sftp-upload-file` | Stream a local file to the remote host, never through model context | — | ✅ |
@@ -168,6 +168,24 @@ the text is an argument on the way out and a response on the way back. That is
168
168
  what you want for a config snippet and exactly what you do not want for a 200 MB
169
169
  tarball or anything binary.
170
170
 
171
+ The two also differ on what they do to an existing destination, and the approved
172
+ string now says which is which. `sftp-upload` replaces unconditionally and spells
173
+ `--overwrite` every time, because that is what it always does; `sftp-upload-file`
174
+ refuses unless you pass `overwrite: true`, and only then carries the flag. Since
175
+ `sftp-upload` takes its content as an argument rather than naming a local file,
176
+ its string also carries `--bytes=<n> --sha256=<32 hex>` — without that, two
177
+ uploads to the same path are the same string, which means one approval covers
178
+ both and an auditor cannot tell which set of bytes landed. The digest is 128
179
+ bits rather than a short prefix because that claim has to hold against a caller
180
+ who picks both payloads, not only against an accidental repeat.
181
+
182
+ The path comes last in that string on purpose. `[policy].denylist` patterns are
183
+ matched against the whole approved string, so a rule anchored on the path —
184
+ `authorized_keys$`, the natural way to write "nothing may write here" — keeps
185
+ working. A rule anchored on the *whole* string (`^sftp:upload /root/.*$`) does
186
+ not: it was coupled to a format that is ours to change, and this release changes
187
+ it. Anchor on the path segment instead.
188
+
171
189
  `sftp-upload-file` and `sftp-download-file` stream between the remote host and
172
190
  local disk instead. Neither the bytes nor a base64 encoding of them ever reaches
173
191
  the model — the response is a byte count and two paths.
@@ -298,6 +316,7 @@ role = "operator" # viewer | operator | admin
298
316
  readOnly = false
299
317
  approvalPolicy = "ask-all"
300
318
  cert = false # SSH CA cert auth — auto-detects keyRef-cert.pub
319
+ announceAgent = true # Send AI_AGENT=ssh-mcp to this host (see below); clear it for a host you do not control
301
320
  sessionMaxPerConnection = 3 # per-profile override
302
321
  sessionIdleTimeoutMs = 300000 # stricter for prod
303
322
  commandQuotaPerDay = 200 # per-profile override
@@ -322,6 +341,43 @@ and tier names: every one you write under `[policy.roleBindings]` has to be
322
341
  reachable by some profile, and every profile's role and tier has to resolve to
323
342
  real bindings. Both directions are checked at startup.
324
343
 
344
+ ### Host-side identification
345
+
346
+ Every command this server runs announces itself to the host as the environment
347
+ variable `AI_AGENT=ssh-mcp`, so an operator can tell an agent's session from a
348
+ person's from anything on the host that reads the session environment — a
349
+ `/etc/profile` snippet or a `ForceCommand` wrapper, for instance. It covers all
350
+ three channels a command can run on: one-shot commands, background sessions and
351
+ interactive sessions. SFTP transfers do not carry it, because the SSH library
352
+ this server uses offers no way to send an environment request on a subsystem
353
+ channel.
354
+
355
+ **The variable only lands in the session if the host opts in** with
356
+ `AcceptEnv AI_AGENT` in `sshd_config`. Without that line sshd ignores it and the
357
+ session environment is unchanged — measured against Dropbear, which has no
358
+ `AcceptEnv` mechanism at all, and against Windows OpenSSH on a default
359
+ configuration: the command runs, the exit code is unchanged, the variable is
360
+ simply absent.
361
+
362
+ **The request is nevertheless sent to every host, opted in or not.** `AcceptEnv`
363
+ is the server's policy about what it stores; it is not a gate on what this client
364
+ transmits. A host that has not opted in — including one that is hostile — can
365
+ still see on the wire that an agent rather than a person is driving the session.
366
+ That is why `announceAgent = false` exists on the profile: set it for any host you
367
+ do not control and nothing is sent to it. It is on by default, because the
368
+ operators who benefit are the ones running the hosts you do control.
369
+
370
+ No version is sent, deliberately. A version would narrow a hostile host to one
371
+ specific build; the tool's name alone does not. (The SSH transport already
372
+ discloses the *library* version in its identification string, which is one
373
+ library's version across every program that uses it.)
374
+
375
+ Treat the variable as a courtesy label, not an attestation. Any SSH client can
376
+ set it and any agent can omit it, so it is useful for attributing sessions you
377
+ already trust and must not be used as an authorization or intrusion-detection
378
+ input. Authoritative attribution belongs to the key or principal that
379
+ authenticated.
380
+
325
381
  ### ProxyJump (Bastion)
326
382
 
327
383
  Reach internal hosts behind a bastion/jump server. The `via` field specifies a profile name to tunnel through:
@@ -646,11 +702,12 @@ level number.
646
702
  - **Non-root** user in all examples
647
703
  - **TOFU** host key verification (accept on first connect, verify after — within one process; see [SECURITY.md](./SECURITY.md#host-key-trust-does-not-survive-a-restart))
648
704
  - **RFC 9142** algorithm allow-list (no SHA-1, no CBC, no ssh-rsa)
649
- - **exec()-only** (no persistent su shells — fixes PTY leak)
705
+ - **exec()-only for elevation** (`privileged-command` takes no `session`, so sudo never runs in a persistent shell — fixes PTY leak. Interactive sessions exist for ordinary commands; this bullet is about elevation, not about every path being an exec channel)
650
706
  - **Sudo via stdin** (not argv — fixes process list leak)
651
707
  - **Sanitizer** strips CR/LF/NUL from all metadata
652
708
  - **3-layer redaction** (field → regex → entropy) on audit logs
653
709
  - **No CLI-arg secrets** (use env vars, keychain, or config)
710
+ - **Agent announcement is disclosed, not hidden** — every command tells the host `AI_AGENT=ssh-mcp`, including hosts that never opted in to storing it; clear `announceAgent` on a profile pointing at a host you do not control (see [Host-side identification](#host-side-identification))
654
711
 
655
712
  ### Hardening Checklist
656
713
 
@@ -858,8 +915,10 @@ is an opt-out only for hosts you have not pinned (test environments only).
858
915
  ## Testing
859
916
 
860
917
  ```bash
861
- # Start test SSH server
862
- docker-compose --profile test up -d
918
+ # Start test SSH servers. --build matters: compose reuses an existing image, and a
919
+ # fixture change (such as the alpine target's AcceptEnv line) then never lands,
920
+ # which shows up as a product failure in the integration suite.
921
+ docker compose --profile test up -d --build
863
922
 
864
923
  # Run all tests
865
924
  npm test
package/build/cli.js CHANGED
@@ -331,6 +331,9 @@ export async function buildAppConfig(argv) {
331
331
  role: 'admin',
332
332
  group: resolveHostGroup(argv),
333
333
  readOnly: false,
334
+ // Same default as the config schema. A CLI profile is the one-off case —
335
+ // --host/--user with no config file — and it announces like any other.
336
+ announceAgent: true,
334
337
  approvalPolicy: flagEnabled(argv, 'disableApproval') ? 'auto' : defaults.approvalMode,
335
338
  cert: false,
336
339
  sessionMaxPerConnection: defaults.sessionMaxPerConnection,
@@ -161,6 +161,12 @@ export const profileSchema = z.object({
161
161
  role: z.string().default('operator'),
162
162
  readOnly: z.boolean().default(false),
163
163
  cert: z.boolean().default(false),
164
+ // A schema-level default rather than a [defaults] entry, like tty/readOnly/cert
165
+ // above: which hosts an operator trusts with the announcement is a property of
166
+ // the individual host, not something worth setting globally and overriding per
167
+ // profile. Defaults to true — the announcement is the feature; the switch is for
168
+ // the host you do not control.
169
+ announceAgent: z.boolean().default(true),
164
170
  // Left optional on purpose: normalizeConfig() fills these from [defaults], and
165
171
  // a schema-level .default() would make "user omitted it" indistinguishable
166
172
  // from "user set the default value", silently shadowing [defaults].
@@ -1,24 +1,42 @@
1
1
  import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
2
+ // The classifier's own resolver, not a second one. See SYNTHETIC_NAMESPACE below.
3
+ import { extractBinary } from '../policy/classifier.js';
2
4
  /** Every character a shell would read as the end of one command and the start of another. */
3
5
  const LINE_BREAKS = /[\r\n\u2028\u2029]/;
4
6
  const NUL = /\u0000/;
5
7
  /**
6
- * Validate a caller-supplied command.
8
+ * Verbs this server builds for itself, which a caller may therefore not type.
7
9
  *
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).
10
+ * `classifyOuter` cannot tell a synthetic string from one a caller typed — both
11
+ * arrive as text. So the moment `sftp:list` was given a class, `read-command`
12
+ * accepted it as a command word too: measured, a `readOnly` viewer could send
13
+ * `read-command "sftp:list /tmp sudo id"` and the classifier answered
14
+ * `read-only`, the gate allowed it, and `exec` ran it. On main the same string
15
+ * was `safe` and refused.
17
16
  *
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.
17
+ * The audit log had the same problem from the other side: a forged record was
18
+ * identical in every field to one the tool itself produced.
19
+ *
20
+ * `sanitizeCommand` is the right place because it runs, by construction, only
21
+ * for commands that did *not* come from us — `runAudited` skips it when
22
+ * `synthetic: true`.
23
+ *
24
+ * Tested against `extractBinary`, the classifier's own resolver, rather than
25
+ * against a first-token helper of this file's own. A private one was written
26
+ * first and drifted immediately: `extractBinary` strips a leading `-c ` and the
27
+ * leading privilege prefixes, which the helper did not, so
28
+ * `read-command "-c sftp:download /etc/shadow"` resolved to binary
29
+ * `sftp:download`, classified `read-only`, was allowed on a profile that denies
30
+ * it on main, and executed. Two resolvers answering one question is the bug;
31
+ * asking the one that decides is the fix.
32
+ *
33
+ * Scope of the guarantee, stated narrowly on purpose: an `sftp:*` or `session:*`
34
+ * audit record is provably tool-generated. `signal-process` synthesises
35
+ * `kill -TERM <pid>`, which is outside this namespace and cannot be reserved —
36
+ * `kill` is a binary a caller may legitimately need — so a record of that shape
37
+ * is not covered.
21
38
  */
39
+ const SYNTHETIC_NAMESPACE = /^(?:sftp|session):/;
22
40
  export function sanitizeCommand(command, maxChars) {
23
41
  if (typeof command !== 'string') {
24
42
  throw new McpError(ErrorCode.InvalidParams, 'Command must be a string');
@@ -36,6 +54,11 @@ export function sanitizeCommand(command, maxChars) {
36
54
  if (NUL.test(cleaned)) {
37
55
  throw new McpError(ErrorCode.InvalidParams, 'Command cannot contain a null byte');
38
56
  }
57
+ if (SYNTHETIC_NAMESPACE.test(extractBinary(cleaned))) {
58
+ throw new McpError(ErrorCode.InvalidParams, 'Command cannot begin with "sftp:" or "session:": that namespace is reserved for the ' +
59
+ 'strings this server builds for its own tools, and the classifier cannot tell a typed ' +
60
+ 'one from a generated one. Use the sftp-* and *-session tools instead.');
61
+ }
39
62
  if (Number.isFinite(maxChars) && cleaned.length > maxChars) {
40
63
  throw new McpError(ErrorCode.InvalidParams, `Command is too long (max ${maxChars} characters)`);
41
64
  }
@@ -45,23 +68,35 @@ export function sanitizeCommand(command, maxChars) {
45
68
  * Every character that could make the audited string, the approval prompt and
46
69
  * the path actually used disagree with each other.
47
70
  *
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.
71
+ * C0 and C1 controls, the Unicode line separators, the Bidi_Control set
72
+ * (U+061C, U+200E, U+200F, U+202A-U+202E, U+2066-U+2069), and the zero-width
73
+ * characters that carry no meaning of their own. A remote path is quoted back to
74
+ * a human in the approval prompt and written into a hash-chained audit record,
75
+ * so a name carrying a right-to-left override renders as one path and transfers
76
+ * another, and one carrying a zero-width space renders identically to a
77
+ * different path.
54
78
  *
55
79
  * An earlier version stopped at the overrides and isolates, which left the
56
80
  * marks that reorder *neutral* characters — and a path is mostly neutrals:
57
81
  * slashes, dots, hyphens and digits.
58
82
  *
83
+ * **U+200C and U+200D are deliberately not here**, though they sit inside the
84
+ * range an earlier version of this class swept up. ZWNJ and ZWJ are invisible
85
+ * but not meaningless: they are orthographically required in Persian and in the
86
+ * Indic scripts, and structural inside an emoji sequence. Refusing them rejected
87
+ * real filenames — measured, `/srv/mi<ZWNJ>ravad.txt` is a name a filesystem
88
+ * accepts and a user types. The spoofing argument does not reach them either,
89
+ * since it rests on two *different* paths rendering identically, and this
90
+ * codebase already accepts that for homoglyphs (Cyrillic а against Latin a),
91
+ * which no character class can catch. Refusing the joiners while accepting those
92
+ * was inconsistent and cost legitimate input.
93
+ *
59
94
  * Exported because `tools/local-path.ts` asks the same question of the local
60
95
  * half. The two *functions* are split for a real reason — that one has to stat
61
96
  * and this one must not — but the character class is one threat model, and two
62
97
  * copies of it drift the first time a codepoint is added.
63
98
  */
64
- export const PATH_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u2028-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/;
99
+ export const PATH_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u061c\u200b\u200e\u200f\u2028-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/;
65
100
  /** Longer than any path a real filesystem accepts, so this bounds nothing legitimate. */
66
101
  const MAX_REMOTE_PATH_CHARS = 4096;
67
102
  /** What the audit record calls a path that never passed validation. */
@@ -47,6 +47,36 @@ const READ_ONLY_ALLOWLIST = new Set([
47
47
  // Residual risk kept on purpose: dig/nslookup/host/ping/traceroute can leak
48
48
  // small amounts of data through DNS/ICMP queries. They cannot modify the host,
49
49
  // so they stay read-only; tighten them via profile policy if egress matters.
50
+ /**
51
+ * The SFTP read verbs, which the tool layer synthesises rather than a caller typing.
52
+ *
53
+ * A second set rather than two more entries in READ_ONLY_ALLOWLIST, and the
54
+ * reason is that that Set is dual-purpose: `operandsAreData` reads it too, to
55
+ * decide whether a segment's operands are data rather than commands. Putting
56
+ * these verbs there silenced the carrier scan for them — measured,
57
+ * `sftp:list /tmp sh -c 'sudo id'` fell from `privileged` to `read-only`,
58
+ * because `nestedCommands` stopped extracting the `sh -c` payload. That is the
59
+ * one carrier form carrying no character from SHELL_CONTROL_CHARS, i.e. exactly
60
+ * the form the scan is load-bearing for.
61
+ *
62
+ * The suppression buys nothing here anyway. It exists so `grep python3 -c file`
63
+ * is not read as invoking python; these verbs take a path nobody parses, so
64
+ * there is no false positive to suppress.
65
+ *
66
+ * Why they are lowered at all: both carry `readOnlyHint: true`, the README marks
67
+ * both read-only, `sftp-list`'s description ends "Read-only." — and `safe` is
68
+ * refused outright by a `readOnly` profile, so the one profile class the
69
+ * annotation targets was the one that could not run them (#217).
70
+ * (`sftp-download`'s description makes no read-only claim; an earlier version of
71
+ * this paragraph said both did, which is the overstatement this file keeps
72
+ * catching.) The lowering grants that
73
+ * profile nothing new: `cat /etc/shadow` and `ls /root` are already `read-only`,
74
+ * so the authority to read any file the SSH user can read is already held.
75
+ *
76
+ * `sftp:upload`, `sftp:upload-file` and `sftp:download-file` stay out — the
77
+ * first two write on the remote host, the third inside the transfer root.
78
+ */
79
+ const READ_ONLY_SYNTHETIC = new Set(['sftp:list', 'sftp:download']);
50
80
  /**
51
81
  * Commands that are never allowed, whatever the role or approval policy.
52
82
  *
@@ -1091,14 +1121,22 @@ function hasUnnameableCommand(command) {
1091
1121
  * `~/.ssh/authorized_keys` without touching a shell. Opening a session is the same
1092
1122
  * argument, since it hands over an interactive shell.
1093
1123
  *
1094
- * `sftp:download`, `sftp:list` and `session:close` are deliberately absent. Each would
1095
- * move *down* from `safe` — download and list to `read-only`, matching their
1096
- * `readOnlyHint`, and close being a release rather than an acquisition. Lowering a class
1097
- * is a widening, and a security release is the wrong place for one; they keep the class
1098
- * they have today. `sftp:list` is worth spelling out because it is new: a `read-only`
1099
- * entry for it would also be inert, since the floor below can only raise, and the effect
1100
- * anyone reaching for it actually wants — letting a `viewer` list a remote directory — is
1101
- * a binding change, not a classification one.
1124
+ * `session:close` is deliberately absent: it is a release rather than an acquisition, and
1125
+ * no tool advertises it as a read.
1126
+ *
1127
+ * `sftp:download` and `sftp:list` are absent for a different reason now. They *are*
1128
+ * lowered to `read-only` — by `READ_ONLY_SYNTHETIC` near the top of this file — because
1129
+ * both tools advertise `readOnlyHint: true` and `safe` is refused outright by a `readOnly`
1130
+ * profile, so the one profile class the annotation targeted was the one that could not run
1131
+ * them (#217). They cannot be lowered *here*, because this table is a floor and a floor
1132
+ * can only raise; an entry would be inert. That inertness is the trap worth keeping
1133
+ * written down — it produced two wrong attempts during #212.
1134
+ *
1135
+ * What this paragraph used to say, and what was wrong with it: that the effect anyone
1136
+ * wants from lowering `sftp:list` — letting a `viewer` list a remote directory — is "a
1137
+ * binding change, not a classification one". It is a classification one, because the tool
1138
+ * ships an annotation and a description that both claim read-only, and a policy that
1139
+ * disagreed with them was the defect rather than the binding.
1102
1140
  *
1103
1141
  * `sftp:upload-file` and `sftp:download-file` are `destructive` because each writes a
1104
1142
  * file: the first on the remote host, the second inside the operator's transfer root.
@@ -1181,7 +1219,8 @@ function classifyOuter(trimmed) {
1181
1219
  return { binary, fullCommand, class: 'destructive' };
1182
1220
  }
1183
1221
  const twoWordPrefix = (tokenizeSegments(fullCommand)[0] ?? []).slice(0, 2).join(' ');
1184
- if (READ_ONLY_ALLOWLIST.has(binary) || READ_ONLY_ALLOWLIST.has(twoWordPrefix)) {
1222
+ if (READ_ONLY_ALLOWLIST.has(binary) || READ_ONLY_ALLOWLIST.has(twoWordPrefix)
1223
+ || READ_ONLY_SYNTHETIC.has(binary)) {
1185
1224
  if (SHELL_CONTROL_CHARS.test(trimmed)) {
1186
1225
  return { binary, fullCommand, class: 'safe' };
1187
1226
  }
@@ -1189,4 +1228,4 @@ function classifyOuter(trimmed) {
1189
1228
  }
1190
1229
  return { binary, fullCommand, class: 'safe' };
1191
1230
  }
1192
- export { READ_ONLY_ALLOWLIST, isDestructive };
1231
+ export { READ_ONLY_ALLOWLIST, READ_ONLY_SYNTHETIC, isDestructive };
@@ -7,6 +7,51 @@ import { redactText } from '../guard/redactor.js';
7
7
  import { shellSingleQuote } from '../guard/sanitizer.js';
8
8
  import { openWithRetry } from './channel-retry.js';
9
9
  import { terminateChannel, COULD_NOT_SIGNAL } from './channel-signal.js';
10
+ /**
11
+ * What this server tells a host it is, on every channel it opens for a command.
12
+ *
13
+ * ssh2's `exec()` calls `reqEnv(chan, opts.env)` with no callback, so the request
14
+ * goes out with `want_reply=0` and the server has nothing to reply to: an
15
+ * unaccepted name cannot fail the exec. Measured against ssh2 1.17.0
16
+ * (`lib/client.js:1230` and the `wantReply` line at `:1863`), against Dropbear,
17
+ * and against Windows OpenSSH on a default config — the command runs, the
18
+ * variable is simply absent, exit code unchanged.
19
+ *
20
+ * **The request is sent to every host, `AcceptEnv` or not.** `AcceptEnv` is the
21
+ * server's policy about whether it *stores* the pair in the session environment;
22
+ * it is not a client-side gate, and nothing here consults it. Measured on the
23
+ * wire against Dropbear, which has no `AcceptEnv` mechanism at all:
24
+ *
25
+ * Outbound: Sending CHANNEL_REQUEST (r:0, env: AI_AGENT=ssh-mcp)
26
+ * Outbound: Sending CHANNEL_REQUEST (r:0, exec: echo hi)
27
+ *
28
+ * So a host that never opted in still learns that an agent, not a person, is
29
+ * driving this session. That is a disclosure, and SECURITY.md's threat model
30
+ * includes a host tailoring its output to inject the model — which is why
31
+ * `announceAgent = false` exists on the profile. An earlier draft of this comment
32
+ * called the declaration "inert" on a host that had not opted in; that is true of
33
+ * the session environment and false of the wire, and the distinction is the whole
34
+ * reason the profile has a switch.
35
+ *
36
+ * Name only, deliberately — no ssh-mcp build number. The transport already
37
+ * discloses the *library* version in its identification string (ssh2 sends
38
+ * `SSH-2.0-ssh2js<ver>` and nothing here overrides `ident`), but that is one
39
+ * library's version across every user of it; a per-build number for this tool
40
+ * would narrow a hostile host to one deployment.
41
+ *
42
+ * `AI_AGENT` is deliberately outside this project's `SSH_MCP_*` namespace. Every
43
+ * other environment name this repo owns is prefixed; this one is not, because it
44
+ * is a cross-tool convention read by server-side detectors that do not know or
45
+ * care which client sent it, and a prefixed name would defeat the only purpose it
46
+ * has. Renaming it to `SSH_MCP_AGENT` would look like a consistency fix and would
47
+ * silently break every consumer — hence this paragraph.
48
+ *
49
+ * Frozen because it is one object shared by every channel on every connection in
50
+ * the process. Nothing mutates it today; the first per-command addition to a
51
+ * channel's `env` would, and would leak across profiles for the life of the
52
+ * process.
53
+ */
54
+ const AGENT_ENV = Object.freeze({ AI_AGENT: 'ssh-mcp' });
10
55
  /**
11
56
  * Stop the command behind `channel`, record why on the span, and return the sentence the
12
57
  * rejection should carry.
@@ -74,19 +119,46 @@ export class SSHConnection {
74
119
  openShell: async () => {
75
120
  await this.ensureConnected();
76
121
  return new Promise((resolve, reject) => {
77
- this.getClient().shell({ term: 'xterm-256color', cols: 200, rows: 50 }, (err, stream) => (err ? reject(err) : resolve(stream)));
122
+ this.getClient().shell({ term: 'xterm-256color', cols: 200, rows: 50 },
123
+ // Three-argument form, deliberately. ssh2 reclassifies the first
124
+ // argument as the options object when it carries `env` or `x11`
125
+ // (lib/client.js:1260), so folding the announcement into the window
126
+ // object above would silently drop term/cols/rows on every
127
+ // interactive session.
128
+ this.channelEnv(), (err, stream) => (err ? reject(err) : resolve(stream)));
78
129
  });
79
130
  },
80
131
  openExec: async (command) => {
81
132
  await this.ensureConnected();
82
133
  return new Promise((resolve, reject) => {
83
- this.getClient().exec(this.applyWorkdir(command), (err, stream) => (err ? reject(err) : resolve(stream)));
134
+ this.getClient().exec(this.applyWorkdir(command), this.channelEnv(), (err, stream) => (err ? reject(err) : resolve(stream)));
84
135
  });
85
136
  },
86
137
  onChannelOpened: () => { this.activeChannels++; },
87
138
  onChannelClosed: () => { this.activeChannels--; },
88
139
  });
89
140
  }
141
+ /**
142
+ * The announcement for a channel about to be opened, or nothing.
143
+ *
144
+ * One accessor rather than an object literal at each site. The first version of
145
+ * this feature wrote `{ env: AGENT_ENV }` at two of the three channel-opening
146
+ * sites and missed `openShell`, which left every command run inside an
147
+ * interactive session unannounced while the README claimed otherwise. Three
148
+ * literals give a site the chance to look complete while carrying nothing; a
149
+ * missing call to this does not.
150
+ *
151
+ * Read live, like every other profile field here: the profile object can be
152
+ * replaced after construction, so an operator clearing `announceAgent` is
153
+ * honoured on the next channel rather than at the next restart.
154
+ *
155
+ * The return type is the intersection both callers accept — `ExecOptions` and
156
+ * `ShellOptions` declare `env` identically — so one accessor serves `exec()`,
157
+ * `openExec` and `openShell` without naming either API.
158
+ */
159
+ channelEnv() {
160
+ return this.profile.announceAgent === false ? {} : { env: AGENT_ENV };
161
+ }
90
162
  async ensureConnected() {
91
163
  if (this.isConnected())
92
164
  return;
@@ -254,7 +326,7 @@ export class SSHConnection {
254
326
  reject(new Error(`Command timed out after ${timeoutMs}ms${note}`));
255
327
  }
256
328
  }, timeoutMs);
257
- const execOpts = {};
329
+ const execOpts = { ...this.channelEnv() };
258
330
  if (opts.tty || this.profile.tty) {
259
331
  execOpts.pty = { term: 'xterm-256color', cols: 200, rows: 50 };
260
332
  }
@@ -0,0 +1,76 @@
1
+ import { createHash } from 'crypto';
2
+ /**
3
+ * How the transfer tools spell what they are about to do, in the one string the
4
+ * approver reads, the policy classifies and the audit record keeps.
5
+ *
6
+ * Shared because the two tools spelled one concept differently: `sftp-upload-file`
7
+ * already carried `--overwrite` when it would clobber, while `sftp-upload` — which
8
+ * always clobbers — carried nothing, so the presence of the flag read as a
9
+ * difference between the tools rather than between operations (#223). A second
10
+ * copy of `--overwrite` is one rename away from putting them back out of step, so
11
+ * there is one.
12
+ *
13
+ * `--mode` is *not* at parity and was left that way deliberately. `sftp-upload`
14
+ * applies 0o644 on a create and never says so, while an absent `--mode=` on
15
+ * `sftp-upload-file` means 0o600-or-inherited — one absence, two meanings, which
16
+ * is the same shape of defect #223 describes. Fixing it means deciding what the
17
+ * effective mode even is on an overwrite (createWriteStream's mode is ignored for
18
+ * an existing file), which is a separate question from what this module landed for.
19
+ */
20
+ /**
21
+ * Spelled once so a rename cannot reach one tool and miss the other.
22
+ *
23
+ * Exported rather than reached through `effectSuffix(true)`: `sftp-upload` has no
24
+ * `overwrite` parameter, so passing a literal `true` to a two-argument helper and
25
+ * letting the reader constant-fold it needed three lines of comment to say what
26
+ * one token says.
27
+ */
28
+ export const OVERWRITE_FLAG = ' --overwrite';
29
+ /** The arguments that change what a transfer does, spelled for policy and the approver. */
30
+ export function effectSuffix(overwrite, mode) {
31
+ return (overwrite ? OVERWRITE_FLAG : '') + (mode !== undefined ? ` --mode=${mode.toString(8)}` : '');
32
+ }
33
+ /**
34
+ * 128 bits, because the property claimed here is adversarial, not accidental.
35
+ *
36
+ * This was 12 characters on first writing, and 48 truncated bits is ~24 bits of
37
+ * birthday security — measured, a collision took 12.3 seconds and 10.9M hashes on
38
+ * one core, producing two 96-byte payloads with the same `--bytes` and the same
39
+ * digest, hence the byte-identical approved string. `--bytes` costs an attacker
40
+ * nothing to match, since both candidates can be padded to one length. At 32
41
+ * characters the same search is 2^64 and not worth describing.
42
+ *
43
+ * Length is free on the redaction axis, which is the reason a short digest looked
44
+ * attractive. The emitted run is `--sha256=<hex>` — `-` and `=` are both in the
45
+ * redactor's character class, so the run is the flag *plus* the digest, and at any
46
+ * digest length it is past that scan's 20-character window and is inspected. It
47
+ * can nonetheless never be redacted: the run draws on 20 distinct symbols, so its
48
+ * Shannon entropy cannot exceed log2(20) = 4.32 against a 4.5 threshold. Measured
49
+ * 4.24 at this length. An earlier version of this comment claimed the digest sat
50
+ * *under* the window; it did not, and reasoning about a length change from that
51
+ * claim would have started from the wrong mechanism.
52
+ */
53
+ const DIGEST_CHARS = 32;
54
+ /**
55
+ * What the approved string says about the bytes, for a tool whose payload is an
56
+ * argument rather than a named file.
57
+ *
58
+ * `sftp-upload` takes its content inline, so unlike the streaming tools there is
59
+ * no local path in the string to stand for it. Without this, two uploads to the
60
+ * same destination are the same string — to the approver deciding, to
61
+ * `ApprovalGrants` keying on it, and to an auditor later asking which one ran.
62
+ *
63
+ * Byte length rather than `content.length`: a JS string counts UTF-16 code units,
64
+ * and the upload writes `Buffer.from(content)` as utf8, so the two disagree on
65
+ * every multi-byte character. The digest is over the same encoding, which is why
66
+ * `update(content, 'utf8')` states the encoding rather than relying on a default.
67
+ *
68
+ * `string` only, though `SftpClient.upload` also accepts a Buffer: the one tool
69
+ * that calls this takes its content through `z.string()`, so a Buffer branch here
70
+ * would be a branch no test could reach honestly. Widen it when a caller needs it,
71
+ * with the case that needs it.
72
+ */
73
+ export function payloadSuffix(content) {
74
+ const digest = createHash('sha256').update(content, 'utf8').digest('hex').slice(0, DIGEST_CHARS);
75
+ return ` --bytes=${Buffer.byteLength(content, 'utf8')} --sha256=${digest}`;
76
+ }
@@ -8,7 +8,7 @@ export const TOOL_DESCRIPTIONS = {
8
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
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
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
- 'sftp-upload': 'Upload a file to the remote server via SFTP (secure file transfer, not shell-based).',
11
+ 'sftp-upload': 'Upload a file to the remote server via SFTP (secure file transfer, not shell-based). Replaces an existing file at that path unconditionally — there is no overwrite flag and no way to require a new destination.',
12
12
  'sftp-download': 'Download a file from the remote server via SFTP.',
13
13
  'sftp-list': 'List a remote directory over SFTP, with a bounded number of entries and a bounded response size. Read-only.',
14
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.',
@@ -1,35 +1,88 @@
1
1
  import { z } from 'zod';
2
2
  import { redactText } from '../guard/redactor.js';
3
+ import { remotePathForAudit, sanitizeRemotePath } from '../guard/sanitizer.js';
3
4
  import { SftpClient } from '../ssh/sftp.js';
5
+ import { OVERWRITE_FLAG, payloadSuffix } from './audit-effects.js';
4
6
  import { TOOL_DESCRIPTIONS as D } from './descriptions.js';
5
7
  import { syntheticSuccess, textResult } from './results.js';
6
- /** SFTP transfer tools. */
8
+ /**
9
+ * SFTP transfer tools.
10
+ *
11
+ * Both paths go through `sanitizeRemotePath`, the same bar the streaming tools
12
+ * hold — for the *path*.
13
+ *
14
+ * `sftp-upload` truncates an existing remote file unconditionally where
15
+ * `sftp-upload-file` refuses unless `overwrite` is passed. What #223 changed is
16
+ * that the approved string says so: before it named a destination and no effect,
17
+ * and said nothing at all about the bytes, so two uploads to one path were one
18
+ * string — one approval, one indistinguishable audit record. The behaviour is
19
+ * unchanged on purpose: a default of `overwrite: false` would break every caller
20
+ * that relies on replacement. `--overwrite` is therefore constant here, which does
21
+ * mean it carries no per-call information — the per-call signal is `--bytes` and
22
+ * `--sha256`. It stays because the sibling tool omits the flag when it will not
23
+ * clobber, so an approver comparing the two needs its presence to mean something.
24
+ *
25
+ * `synthetic: true` skips `sanitizeCommand`, and these two interpolated
26
+ * the caller's raw string, so nothing refused a bidi override or a zero-width
27
+ * character in a path that is quoted back in the approval prompt and written
28
+ * into a hash-chained audit record — the exact confusion that validator exists
29
+ * to stop. The check runs as a `preCheck`, inside the pipeline's try, so a
30
+ * refused call still leaves an audit record.
31
+ *
32
+ * Pre-existing, and reachable before only by roles holding `safe`; lowering
33
+ * `sftp:download` to `read-only` (#217) is what would have opened it to every
34
+ * `readOnly` profile, so it is closed here rather than after.
35
+ */
7
36
  export function registerFileTools({ server }, pipeline) {
8
37
  const { runAudited } = pipeline;
9
38
  // ─── sftp-upload ───────────────────────────────────────────────────────
10
39
  server.tool('sftp-upload', D["sftp-upload"], {
11
- remotePath: z.string().describe('Remote file path'),
40
+ remotePath: z.string().describe('Remote file path. No leading or trailing whitespace, and no control, bidirectional or zero-width characters: the approval prompt and the audit record quote this path back.'),
12
41
  content: z.string().describe('File content to upload'),
13
42
  profile: z.string().optional().describe('Profile name'),
14
43
  }, { destructiveHint: true }, async ({ remotePath, content, profile }, extra) => {
15
- return runAudited(`sftp:upload ${remotePath}`, { toolName: 'sftp-upload', failureClass: 'destructive', profile, extra, synthetic: true }, async (rt) => {
44
+ return runAudited(
45
+ // Verb, then what the operation does, then the path it does it to — and the
46
+ // path LAST on purpose. The engine matches `[policy].denylist` against this
47
+ // whole string, so an operator rule anchored on the path (`authorized_keys$`,
48
+ // the natural and portable way to write "nothing may write here") keeps
49
+ // working only while the path ends the line. Measured: with the suffixes
50
+ // appended after the path instead, that rule silently stops matching and the
51
+ // refusal degrades to a prompt with no warning. A whole-string rule
52
+ // (`^sftp:upload /root.*$`) breaks either way — it is coupled to a format
53
+ // that is ours to change — so the layout is chosen for the form that is not.
54
+ // `sftp:list` and `sftp:download` carry no flags, so they already read this
55
+ // way and need no change.
56
+ `sftp:upload${OVERWRITE_FLAG}${payloadSuffix(content)} ${remotePathForAudit(remotePath)}`, {
57
+ toolName: 'sftp-upload',
58
+ failureClass: 'destructive',
59
+ profile,
60
+ extra,
61
+ synthetic: true,
62
+ preCheck: () => { sanitizeRemotePath(remotePath); },
63
+ }, async (rt) => {
16
64
  await new SftpClient(rt.conn).upload({ remotePath, content });
17
65
  return {
18
66
  audited: syntheticSuccess(rt.profileName),
19
- // Byte count, not string length: `content` is a JS string, so
20
- // `.length` counts UTF-16 code units and under-reports every
21
- // multi-byte character. SftpClient.upload writes Buffer.from(content),
22
- // which is utf8, so this is exactly what lands on the remote side.
67
+ // utf8 bytes, matching the count the approved string already carries —
68
+ // see `payloadSuffix` in audit-effects.ts for why `.length` is wrong.
23
69
  output: textResult(`Uploaded ${Buffer.byteLength(content, 'utf8')} bytes to ${remotePath}`),
24
70
  };
25
71
  });
26
72
  });
27
73
  // ─── sftp-download ─────────────────────────────────────────────────────
28
74
  server.tool('sftp-download', D["sftp-download"], {
29
- remotePath: z.string().describe('Remote file path to download'),
75
+ remotePath: z.string().describe('Remote file path. No leading or trailing whitespace, and no control, bidirectional or zero-width characters: the approval prompt and the audit record quote this path back.'),
30
76
  profile: z.string().optional().describe('Profile name'),
31
77
  }, { readOnlyHint: true }, async ({ remotePath, profile }, extra) => {
32
- return runAudited(`sftp:download ${remotePath}`, { toolName: 'sftp-download', failureClass: 'read-only', profile, extra, synthetic: true }, async (rt) => {
78
+ return runAudited(`sftp:download ${remotePathForAudit(remotePath)}`, {
79
+ toolName: 'sftp-download',
80
+ failureClass: 'read-only',
81
+ profile,
82
+ extra,
83
+ synthetic: true,
84
+ preCheck: () => { sanitizeRemotePath(remotePath); },
85
+ }, async (rt) => {
33
86
  const data = await new SftpClient(rt.conn).download({ remotePath });
34
87
  return {
35
88
  audited: syntheticSuccess(rt.profileName),
@@ -3,6 +3,7 @@ import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
3
3
  import { redactText } from '../guard/redactor.js';
4
4
  import { remotePathForAudit, sanitizeRemotePath } from '../guard/sanitizer.js';
5
5
  import { SftpClient } from '../ssh/sftp.js';
6
+ import { effectSuffix } from './audit-effects.js';
6
7
  import { TOOL_DESCRIPTIONS as D } from './descriptions.js';
7
8
  import { createLocalDownload, localFileForRead, localPathForAudit, } from './local-path.js';
8
9
  import { syntheticSuccess, textResult } from './results.js';
@@ -142,10 +143,6 @@ export function checkMode(mode) {
142
143
  }
143
144
  return mode;
144
145
  }
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
146
  /**
150
147
  * What the audit record adds once the local path has been resolved.
151
148
  *
@@ -199,7 +196,7 @@ export function registerTransferTools({ server, localPath }, pipeline) {
199
196
  const ctx = localPath ?? { transferRoot: undefined };
200
197
  // ─── sftp-list ─────────────────────────────────────────────────────────
201
198
  server.tool('sftp-list', D['sftp-list'], {
202
- remotePath: z.string().describe('Remote directory to list'),
199
+ remotePath: z.string().describe('Remote directory to list. No leading or trailing whitespace, and no control, bidirectional or zero-width characters.'),
203
200
  maxEntries: z.number().int().min(1).max(MAX_LIST_ENTRIES).optional()
204
201
  .describe(`Max entries to return (default ${DEFAULT_LIST_ENTRIES}, hard cap ${MAX_LIST_ENTRIES})`),
205
202
  profile: z.string().optional().describe('Profile name'),
@@ -237,7 +234,7 @@ export function registerTransferTools({ server, localPath }, pipeline) {
237
234
  // ─── sftp-upload-file ──────────────────────────────────────────────────
238
235
  server.tool('sftp-upload-file', D['sftp-upload-file'], {
239
236
  localPath: z.string().describe('Local file to upload, inside defaults.transferRoot'),
240
- remotePath: z.string().describe('Remote destination path'),
237
+ remotePath: z.string().describe('Remote destination path. No leading or trailing whitespace, and no control, bidirectional or zero-width characters.'),
241
238
  overwrite: z.boolean().optional().describe('Replace an existing remote file (default false)'),
242
239
  // The range is deliberately NOT declared here, only in `checkMode`. The SDK
243
240
  // validates this schema before the handler runs, so a zod bound refuses a
@@ -296,7 +293,7 @@ export function registerTransferTools({ server, localPath }, pipeline) {
296
293
  });
297
294
  // ─── sftp-download-file ────────────────────────────────────────────────
298
295
  server.tool('sftp-download-file', D['sftp-download-file'], {
299
- remotePath: z.string().describe('Remote file to download'),
296
+ remotePath: z.string().describe('Remote file to download. No leading or trailing whitespace, and no control, bidirectional or zero-width characters.'),
300
297
  localPath: z.string().describe('Local destination, inside defaults.transferRoot'),
301
298
  overwrite: z.boolean().optional().describe('Replace an existing local file (default false)'),
302
299
  profile: z.string().optional().describe('Profile name'),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ssh-mcp",
3
3
  "license": "MIT",
4
- "version": "2.9.1",
4
+ "version": "2.11.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",