ssh-mcp 2.10.0 → 2.12.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 -4
- package/build/cli.js +3 -0
- package/build/config/schema.js +6 -0
- package/build/policy/classifier.js +522 -80
- package/build/ssh/connection.js +75 -3
- package/build/tools/audit-effects.js +76 -0
- package/build/tools/descriptions.js +1 -1
- package/build/tools/file-tools.js +30 -11
- package/build/tools/transfer-tools.js +1 -4
- package/package.json +1 -1
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
|
|
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
|
|
862
|
-
|
|
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,
|
package/build/config/schema.js
CHANGED
|
@@ -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].
|
|
@@ -17,18 +17,100 @@ import { AWK_NAMES, readAwkInvocation } from './awk.js';
|
|
|
17
17
|
* write: run-command still accepts it under policy.
|
|
18
18
|
*/
|
|
19
19
|
const SHELL_CONTROL_CHARS = /[;&|<>`$(){}\n\r]/;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
20
|
+
/**
|
|
21
|
+
* The binaries this classifier will vouch for, and what it vouches for about them.
|
|
22
|
+
*
|
|
23
|
+
* Two questions, kept apart because one Set answering both is how #217 turned the
|
|
24
|
+
* interpreter carrier scan off for two verbs by adding them to a list about
|
|
25
|
+
* classes:
|
|
26
|
+
*
|
|
27
|
+
* readOnly does this binary only read? -> decides the class
|
|
28
|
+
* operandsAreData can its operands hide a command? -> decides whether the
|
|
29
|
+
* carrier scan runs
|
|
30
|
+
*
|
|
31
|
+
* Both are `true` for every entry today. The value is not the contents but that
|
|
32
|
+
* the type will not let the next person add a name without answering both.
|
|
33
|
+
*
|
|
34
|
+
* Two mechanisms answer the second question outside this table and are not
|
|
35
|
+
* affected by it — `DISQUALIFYING_ARGS` and `FIND_EXEC_FLAGS` — which is why
|
|
36
|
+
* `find` can carry `operandsAreData: true` while `find … -exec sudo id +` is
|
|
37
|
+
* still `privileged`.
|
|
38
|
+
*
|
|
39
|
+
* The two-word entries are looked up only for the class: `operandsAreData` reads
|
|
40
|
+
* a single word, so those rows never reach the second question.
|
|
41
|
+
*/
|
|
42
|
+
const READERS = {
|
|
43
|
+
"arp": { readOnly: true, operandsAreData: true },
|
|
44
|
+
"basename": { readOnly: true, operandsAreData: true },
|
|
45
|
+
"cat": { readOnly: true, operandsAreData: true },
|
|
46
|
+
"comm": { readOnly: true, operandsAreData: true },
|
|
47
|
+
"cut": { readOnly: true, operandsAreData: true },
|
|
48
|
+
"date": { readOnly: true, operandsAreData: true },
|
|
49
|
+
"df": { readOnly: true, operandsAreData: true },
|
|
50
|
+
"diff": { readOnly: true, operandsAreData: true },
|
|
51
|
+
"dig": { readOnly: true, operandsAreData: true },
|
|
52
|
+
"dirname": { readOnly: true, operandsAreData: true },
|
|
53
|
+
"docker images": { readOnly: true, operandsAreData: true }, // two-word: class only
|
|
54
|
+
"docker inspect": { readOnly: true, operandsAreData: true }, // two-word: class only
|
|
55
|
+
"docker logs": { readOnly: true, operandsAreData: true }, // two-word: class only
|
|
56
|
+
"docker ps": { readOnly: true, operandsAreData: true }, // two-word: class only
|
|
57
|
+
"docker stats": { readOnly: true, operandsAreData: true }, // two-word: class only
|
|
58
|
+
"du": { readOnly: true, operandsAreData: true },
|
|
59
|
+
"echo": { readOnly: true, operandsAreData: true },
|
|
60
|
+
"false": { readOnly: true, operandsAreData: true },
|
|
61
|
+
"file": { readOnly: true, operandsAreData: true },
|
|
62
|
+
"find": { readOnly: true, operandsAreData: true },
|
|
63
|
+
"free": { readOnly: true, operandsAreData: true },
|
|
64
|
+
"git branch": { readOnly: true, operandsAreData: true }, // two-word: class only
|
|
65
|
+
"git diff": { readOnly: true, operandsAreData: true }, // two-word: class only
|
|
66
|
+
"git log": { readOnly: true, operandsAreData: true }, // two-word: class only
|
|
67
|
+
"git remote": { readOnly: true, operandsAreData: true }, // two-word: class only
|
|
68
|
+
"git show": { readOnly: true, operandsAreData: true }, // two-word: class only
|
|
69
|
+
"git status": { readOnly: true, operandsAreData: true }, // two-word: class only
|
|
70
|
+
"grep": { readOnly: true, operandsAreData: true },
|
|
71
|
+
"head": { readOnly: true, operandsAreData: true },
|
|
72
|
+
"host": { readOnly: true, operandsAreData: true },
|
|
73
|
+
"hostname": { readOnly: true, operandsAreData: true },
|
|
74
|
+
"htop": { readOnly: true, operandsAreData: true },
|
|
75
|
+
"id": { readOnly: true, operandsAreData: true },
|
|
76
|
+
"ifconfig": { readOnly: true, operandsAreData: true },
|
|
77
|
+
"iostat": { readOnly: true, operandsAreData: true },
|
|
78
|
+
"ip addr": { readOnly: true, operandsAreData: true }, // two-word: class only
|
|
79
|
+
"ip route": { readOnly: true, operandsAreData: true }, // two-word: class only
|
|
80
|
+
"journalctl": { readOnly: true, operandsAreData: true },
|
|
81
|
+
"ls": { readOnly: true, operandsAreData: true },
|
|
82
|
+
"netstat": { readOnly: true, operandsAreData: true },
|
|
83
|
+
"nslookup": { readOnly: true, operandsAreData: true },
|
|
84
|
+
"ping": { readOnly: true, operandsAreData: true },
|
|
85
|
+
"printenv": { readOnly: true, operandsAreData: true },
|
|
86
|
+
"printf": { readOnly: true, operandsAreData: true },
|
|
87
|
+
"ps": { readOnly: true, operandsAreData: true },
|
|
88
|
+
"pwd": { readOnly: true, operandsAreData: true },
|
|
89
|
+
"readlink": { readOnly: true, operandsAreData: true },
|
|
90
|
+
"realpath": { readOnly: true, operandsAreData: true },
|
|
91
|
+
"seq": { readOnly: true, operandsAreData: true },
|
|
92
|
+
"sort": { readOnly: true, operandsAreData: true },
|
|
93
|
+
"ss": { readOnly: true, operandsAreData: true },
|
|
94
|
+
"stat": { readOnly: true, operandsAreData: true },
|
|
95
|
+
"systemctl status": { readOnly: true, operandsAreData: true }, // two-word: class only
|
|
96
|
+
"tail": { readOnly: true, operandsAreData: true },
|
|
97
|
+
"test": { readOnly: true, operandsAreData: true },
|
|
98
|
+
"top": { readOnly: true, operandsAreData: true },
|
|
99
|
+
"tr": { readOnly: true, operandsAreData: true },
|
|
100
|
+
"traceroute": { readOnly: true, operandsAreData: true },
|
|
101
|
+
"true": { readOnly: true, operandsAreData: true },
|
|
102
|
+
"uname": { readOnly: true, operandsAreData: true },
|
|
103
|
+
"uniq": { readOnly: true, operandsAreData: true },
|
|
104
|
+
"uptime": { readOnly: true, operandsAreData: true },
|
|
105
|
+
"vmstat": { readOnly: true, operandsAreData: true },
|
|
106
|
+
"wc": { readOnly: true, operandsAreData: true },
|
|
107
|
+
"whereis": { readOnly: true, operandsAreData: true },
|
|
108
|
+
"which": { readOnly: true, operandsAreData: true },
|
|
109
|
+
"who": { readOnly: true, operandsAreData: true },
|
|
110
|
+
"whoami": { readOnly: true, operandsAreData: true },
|
|
111
|
+
};
|
|
112
|
+
/** The class half of READERS, as the shape its consumers already expect. */
|
|
113
|
+
const READ_ONLY_ALLOWLIST = new Set(Object.entries(READERS).filter(([, e]) => e.readOnly).map(([name]) => name));
|
|
32
114
|
// Deliberately NOT read-only: `env`, because it is an exec wrapper. `env <cmd>`
|
|
33
115
|
// runs <cmd>, so allowlisting the name `env` vouched for a command the
|
|
34
116
|
// classifier never looked at — `env sudo rm -f /etc/passwd` classified
|
|
@@ -177,44 +259,123 @@ const CLASS_RANK = {
|
|
|
177
259
|
destructive: 2,
|
|
178
260
|
privileged: 3,
|
|
179
261
|
};
|
|
262
|
+
const INTERPRETERS = Object.assign(Object.create(null), {
|
|
263
|
+
sh: { programBearingWords: ['-c'], readable: true },
|
|
264
|
+
bash: { programBearingWords: ['-c'], readable: true },
|
|
265
|
+
dash: { programBearingWords: ['-c'], readable: true },
|
|
266
|
+
zsh: { programBearingWords: ['-c'], readable: true },
|
|
267
|
+
ksh: { programBearingWords: ['-c'], readable: true },
|
|
268
|
+
ash: { programBearingWords: ['-c'], readable: true },
|
|
269
|
+
python: { programBearingWords: ['-c'], readable: false },
|
|
270
|
+
python2: { programBearingWords: ['-c'], readable: false },
|
|
271
|
+
python3: { programBearingWords: ['-c'], readable: false },
|
|
272
|
+
perl: { programBearingWords: ['-e', '-E'], readable: false },
|
|
273
|
+
ruby: { programBearingWords: ['-e'], readable: false },
|
|
274
|
+
// `-p`/`--print` evaluate exactly as `-e` does and then print the result.
|
|
275
|
+
node: { programBearingWords: ['-e', '--eval', '-p', '--print'], readable: false },
|
|
276
|
+
php: { programBearingWords: ['-r'], readable: false },
|
|
277
|
+
// Measured on 2.11.0: each of these classified `safe` while the identical
|
|
278
|
+
// attack through python3 -c classified `destructive` (GHSA-qmx6-47vm-3vf7).
|
|
279
|
+
// `readable: false` throughout, matching python/perl/node: the program is not
|
|
280
|
+
// shell text, so its presence is what counts rather than its content.
|
|
281
|
+
osascript: { programBearingWords: ['-e'], readable: false },
|
|
282
|
+
lua: { programBearingWords: ['-e'], readable: false },
|
|
283
|
+
Rscript: { programBearingWords: ['-e'], readable: false },
|
|
284
|
+
bun: { programBearingWords: ['-e'], readable: false },
|
|
285
|
+
// No program-bearing flag: real tclsh takes a script FILE as its positional
|
|
286
|
+
// argument or reads one from stdin, unlike sh/bash/python/pwsh, none of
|
|
287
|
+
// which have a `-c` of its own. An empty list still marks tclsh as an
|
|
288
|
+
// unreadable interpreter for `readsProgramFromStdin` (`echo … | tclsh`),
|
|
289
|
+
// which is the genuine carrier; it is simply never handed a program inline
|
|
290
|
+
// on its own command line, so there is no flag to name here.
|
|
291
|
+
tclsh: { programBearingWords: [], readable: false },
|
|
292
|
+
// `eval` is a subcommand, not a flag — the field is named for what it holds.
|
|
293
|
+
deno: { programBearingWords: ['eval'], readable: false },
|
|
294
|
+
pwsh: { programBearingWords: ['-c', '-Command', '-e', '-EncodedCommand'], readable: false },
|
|
295
|
+
powershell: { programBearingWords: ['-c', '-Command', '-e', '-EncodedCommand'], readable: false },
|
|
296
|
+
});
|
|
180
297
|
/**
|
|
181
|
-
*
|
|
298
|
+
* Every `INTERPRETERS` entry, keyed by its name lower-cased.
|
|
182
299
|
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
300
|
+
* A second index rather than lower-casing the lookup key against `INTERPRETERS`
|
|
301
|
+
* directly, because that table's own keys are not uniformly lower-case —
|
|
302
|
+
* `Rscript` is not `rscript` — so folding only the *input* would still miss it.
|
|
303
|
+
* Built once at module load: the table is fixed, so there is nothing to keep in
|
|
304
|
+
* sync.
|
|
305
|
+
*/
|
|
306
|
+
const INTERPRETERS_BY_LOWERCASE_NAME = Object.assign(Object.create(null), Object.fromEntries(Object.entries(INTERPRETERS).map(([name, spec]) => [name.toLowerCase(), spec])));
|
|
307
|
+
/** A Windows executable suffix this table's keys never carry. */
|
|
308
|
+
const WINDOWS_EXE_SUFFIX = /\.(exe|cmd|bat)$/i;
|
|
309
|
+
/**
|
|
310
|
+
* Resolve an interpreter table entry for a command word already stripped of
|
|
311
|
+
* its path (`stripPath(unquote(word))`, as every call site already computes
|
|
312
|
+
* it for the exact-match case below).
|
|
186
313
|
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
314
|
+
* The table's keys are exact, case-sensitive, extension-free spellings, so a
|
|
315
|
+
* direct lookup is tried first and is the whole cost for the common case —
|
|
316
|
+
* `sh`, `python3`, the bare lowercase `pwsh`. The fallback strips a trailing
|
|
317
|
+
* `.exe`/`.cmd`/`.bat` and folds case, which is what a Windows target's own
|
|
318
|
+
* shell hands back for the *same* binary: `powershell.exe`, `PWSH.EXE` and
|
|
319
|
+
* `PowerShell` all name the interpreter this table already lists under
|
|
320
|
+
* `powershell`/`pwsh`.
|
|
193
321
|
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
322
|
+
* Applied to every entry, not only pwsh/powershell. The measured bug is
|
|
323
|
+
* about those two, but the gap is not specific to them: a target running
|
|
324
|
+
* `python.exe` or `Node.EXE` carries the identical mismatch, and scoping the
|
|
325
|
+
* fix to two names would leave the rest of the table exactly as blind as it
|
|
326
|
+
* was. This is the binary-*name* question, answered once — it does not touch
|
|
327
|
+
* flag matching, which stays case-sensitive per interpreter (`perl -E` and
|
|
328
|
+
* `perl -e` are still two different flags) via the existing, separate
|
|
329
|
+
* `caseInsensitive` parameter threaded through `programAfterFlag` and
|
|
330
|
+
* friends.
|
|
196
331
|
*/
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
dash: { flags: ['-c'], readable: true },
|
|
204
|
-
zsh: { flags: ['-c'], readable: true },
|
|
205
|
-
ksh: { flags: ['-c'], readable: true },
|
|
206
|
-
ash: { flags: ['-c'], readable: true },
|
|
207
|
-
python: { flags: ['-c'], readable: false },
|
|
208
|
-
python2: { flags: ['-c'], readable: false },
|
|
209
|
-
python3: { flags: ['-c'], readable: false },
|
|
210
|
-
perl: { flags: ['-e', '-E'], readable: false },
|
|
211
|
-
ruby: { flags: ['-e'], readable: false },
|
|
212
|
-
// `-p`/`--print` evaluate exactly as `-e` does and then print the result.
|
|
213
|
-
node: { flags: ['-e', '--eval', '-p', '--print'], readable: false },
|
|
214
|
-
php: { flags: ['-r'], readable: false },
|
|
215
|
-
});
|
|
332
|
+
function resolveInterpreter(word) {
|
|
333
|
+
const exact = INTERPRETERS[word];
|
|
334
|
+
if (exact !== undefined)
|
|
335
|
+
return exact;
|
|
336
|
+
return INTERPRETERS_BY_LOWERCASE_NAME[word.replace(WINDOWS_EXE_SUFFIX, '').toLowerCase()];
|
|
337
|
+
}
|
|
216
338
|
/** `find … -exec <cmd> +` runs cmd. */
|
|
217
339
|
const FIND_EXEC_FLAGS = new Set(['-exec', '-execdir', '-ok', '-okdir']);
|
|
340
|
+
/**
|
|
341
|
+
* GNU sort's own long-option table, spelled in full — every entry `sort
|
|
342
|
+
* --help` lists (coreutils 9.11, `docker exec ssh-mcp-ssh-admin-1 sort
|
|
343
|
+
* --help`). Used only to compute which prefixes of `--output` and
|
|
344
|
+
* `--compress-program` `getopt_long` treats as unambiguous.
|
|
345
|
+
*
|
|
346
|
+
* `getopt_long` resolves a `--` word to the option whose name it is an
|
|
347
|
+
* unambiguous prefix of: a prefix shared with another option name is
|
|
348
|
+
* refused outright ("option '--c' is ambiguous; possibilities: '--check'
|
|
349
|
+
* '--compress-program'") rather than run, so it must not be treated as a
|
|
350
|
+
* write. Measured against the binary: `sort --c=x k` exits 2 with no file
|
|
351
|
+
* created; `sort --co=x k` — the second letter is where `--check` and
|
|
352
|
+
* `--compress-program` diverge — runs.
|
|
353
|
+
*/
|
|
354
|
+
const SORT_LONG_OPTIONS = [
|
|
355
|
+
'ignore-leading-blanks', 'dictionary-order', 'ignore-case',
|
|
356
|
+
'general-numeric-sort', 'ignore-nonprinting', 'month-sort',
|
|
357
|
+
'human-numeric-sort', 'numeric-sort', 'random-sort', 'random-source',
|
|
358
|
+
'reverse', 'sort', 'version-sort', 'batch-size', 'check',
|
|
359
|
+
'compress-program', 'debug', 'files0-from', 'key', 'merge', 'output',
|
|
360
|
+
'stable', 'buffer-size', 'field-separator', 'temporary-directory',
|
|
361
|
+
'parallel', 'unique', 'zero-terminated', 'help', 'version',
|
|
362
|
+
];
|
|
363
|
+
/**
|
|
364
|
+
* The regex-alternation source matching any prefix of `full` that names
|
|
365
|
+
* exactly one entry of `SORT_LONG_OPTIONS` — every prefix `getopt_long`
|
|
366
|
+
* would accept for it, from the shortest unambiguous one up to `full`
|
|
367
|
+
* itself — each optionally followed by `=value`.
|
|
368
|
+
*/
|
|
369
|
+
function unambiguousLongOptionSource(full) {
|
|
370
|
+
const prefixes = [];
|
|
371
|
+
for (let len = 1; len <= full.length; len++) {
|
|
372
|
+
const prefix = full.slice(0, len);
|
|
373
|
+
if (SORT_LONG_OPTIONS.filter((opt) => opt.startsWith(prefix)).length === 1) {
|
|
374
|
+
prefixes.push(prefix);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return `--(?:${prefixes.join('|')})(?:=|$)`;
|
|
378
|
+
}
|
|
218
379
|
/**
|
|
219
380
|
* How deep a substitution may nest before we stop reading and refuse to guess.
|
|
220
381
|
*
|
|
@@ -406,8 +567,22 @@ function matchesEitherForm(command, test) {
|
|
|
406
567
|
* Four carriers, all of which a remote shell expands and runs:
|
|
407
568
|
* `$(...)`, backticks, process substitution `<(...)` / `>(...)`, and a shell
|
|
408
569
|
* given `-c`.
|
|
570
|
+
*
|
|
571
|
+
* @param speculativeOperands Whether the catch-all below — any multi-word,
|
|
572
|
+
* non-flag operand of a segment no more specific reader claimed — should be
|
|
573
|
+
* pushed. That catch-all is a guess about a binary this file does not
|
|
574
|
+
* recognise; the four carriers above are not guesses, the shell really does
|
|
575
|
+
* run what they hold, and are pushed regardless of this flag. Defaults to
|
|
576
|
+
* `true` for `classifyCommand`'s own recursion, where a guess may raise a
|
|
577
|
+
* command's *class* and leave role, tier and approval to weigh in.
|
|
578
|
+
* `findForbiddenMatch` passes `false`: its recursion feeds
|
|
579
|
+
* `FORBIDDEN_RULES`, the one unconditional denylist, and a guess must not
|
|
580
|
+
* be able to produce a refusal nobody can override (the maintainer's
|
|
581
|
+
* ruling — see the block comment above `findForbiddenMatch`). `$()`,
|
|
582
|
+
* backticks and `sh -c` are certain carriers and keep reaching the
|
|
583
|
+
* denylist either way.
|
|
409
584
|
*/
|
|
410
|
-
export function nestedCommands(command) {
|
|
585
|
+
export function nestedCommands(command, speculativeOperands = true) {
|
|
411
586
|
const found = [];
|
|
412
587
|
// `$(...)`, `<(...)`, `>(...)` — scanned rather than matched, because a regex
|
|
413
588
|
// cannot balance parentheses and `echo $(echo $(sudo id))` is the case that
|
|
@@ -468,13 +643,86 @@ export function nestedCommands(command) {
|
|
|
468
643
|
for (const inner of awk?.pipedInto ?? [])
|
|
469
644
|
found.push(inner);
|
|
470
645
|
if (!operandsAreData(words)) {
|
|
646
|
+
// Which operand *indices* an interpreter actually consumed as a program —
|
|
647
|
+
// not whether the segment's head happens to be a name in the table, and not
|
|
648
|
+
// a single segment-wide flag either. A set of indices rather than a boolean:
|
|
649
|
+
// `unknownbin sh -c true 'sudo id'` has `sh -c` consume `true` (harmless,
|
|
650
|
+
// pushed below) and leave `'sudo id'` untouched, and a boolean here defused
|
|
651
|
+
// the catch-all for that second operand too — measured, it classified
|
|
652
|
+
// `safe`. Only the specific word an interpreter actually read should be
|
|
653
|
+
// excluded from the catch-all; every other operand in the segment is still
|
|
654
|
+
// this binary's own, unclaimed by anything more specific.
|
|
655
|
+
//
|
|
656
|
+
// A binary being recognised is also not the same as this segment's
|
|
657
|
+
// invocation of it being one the loop could parse:
|
|
658
|
+
// `pwsh -ExecutionPolicy Bypass -Command 'sudo id'` has a recognised head
|
|
659
|
+
// and a program on the line, but `-ExecutionPolicy` takes a value
|
|
660
|
+
// (`Bypass`) that isn't itself a flag, and `programAfterFlag` gives up
|
|
661
|
+
// rather than guess past it. Gating the catch-all below on "head is in the
|
|
662
|
+
// table" excluded exactly the binaries this file just learned, and for
|
|
663
|
+
// precisely the invocations its own flag-walk cannot follow — a net
|
|
664
|
+
// regression, not a wash.
|
|
665
|
+
const consumedOperands = new Set();
|
|
471
666
|
for (let i = 0; i < words.length; i++) {
|
|
472
|
-
const spec =
|
|
473
|
-
|
|
667
|
+
const spec = resolveInterpreter(stripPath(unquote(words[i])));
|
|
668
|
+
// pwsh/powershell's own parameter binder resolves `-ENC`/`-Enc`/`-enc` to the
|
|
669
|
+
// same parameter; every other interpreter's flags are exact letters (`-E` and
|
|
670
|
+
// `-e` are different flags to perl). `-EncodedCommand` only ever appears on
|
|
671
|
+
// the two entries whose own parameter binder works this way, so its presence
|
|
672
|
+
// is the signal for which interpreter this is, not a hardcoded name check.
|
|
673
|
+
const caseInsensitive = spec?.programBearingWords.includes('-EncodedCommand') ?? false;
|
|
674
|
+
if (spec === undefined || isFlagValue(words, i, spec.programBearingWords, caseInsensitive))
|
|
474
675
|
continue;
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
676
|
+
// Same signal, second job: pwsh/powershell's argument parser also does not stop
|
|
677
|
+
// at an unrecognised option's bare value (`-ExecutionPolicy Bypass`) the way a
|
|
678
|
+
// POSIX interpreter stops at its first positional argument. See
|
|
679
|
+
// `programAfterFlag`'s `tolerateUnknownWordsAtHead` for why this is scoped to
|
|
680
|
+
// `i === 0` there rather than here.
|
|
681
|
+
const result = programAfterFlag(words, i, spec.programBearingWords, caseInsensitive, caseInsensitive);
|
|
682
|
+
if (result !== null) {
|
|
683
|
+
consumedOperands.add(result.index);
|
|
684
|
+
found.push(result.program);
|
|
685
|
+
// An encoded program is opaque to every text scan until it is decoded. Gated on
|
|
686
|
+
// the word `programAfterFlag` actually matched — the table's own canonical
|
|
687
|
+
// spelling, so every case-folded or clustered form of `-EncodedCommand`/`-e`
|
|
688
|
+
// reaches this the same way a literal `-EncodedCommand` does — not a scan of
|
|
689
|
+
// the whole segment for one long spelling.
|
|
690
|
+
if (caseInsensitive && (result.flag === '-EncodedCommand' || result.flag === '-e')) {
|
|
691
|
+
const decoded = decodedPowerShellCommand(result.program);
|
|
692
|
+
if (decoded !== null)
|
|
693
|
+
found.push(decoded);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
// An operand of a binary nothing more specific has read is classified as a
|
|
698
|
+
// command in its own right, rather than scanned as text.
|
|
699
|
+
//
|
|
700
|
+
// The gate is the awk reader's own result, and now which specific operands
|
|
701
|
+
// an interpreter actually consumed, rather than a list of names: a name
|
|
702
|
+
// list here would be the defect this change exists to fix
|
|
703
|
+
// (GHSA-qmx6-47vm-3vf7), and "the head is a name in the table" turned out
|
|
704
|
+
// to be one too — it answers a different question than "did this
|
|
705
|
+
// invocation's program-bearing word actually resolve". `awk` is already
|
|
706
|
+
// null for every non-awk segment.
|
|
707
|
+
//
|
|
708
|
+
// Whitespace is what separates an operand worth classifying from one that is
|
|
709
|
+
// not: a single token is a path, a flag value or a subcommand, while a
|
|
710
|
+
// multi-word operand has the shape of a command. Flags are skipped.
|
|
711
|
+
//
|
|
712
|
+
// Deliberately NOT a text scan for `sudo`. That version asserted elevations
|
|
713
|
+
// the awk reader and the variable-command-word logic refuse to assert — both
|
|
714
|
+
// cap at `destructive` on purpose — and it out-ranked the nested
|
|
715
|
+
// classification that names the elevated binary, reporting `awk` where `id`
|
|
716
|
+
// was correct.
|
|
717
|
+
if (speculativeOperands && awk === null) {
|
|
718
|
+
for (let i = 1; i < words.length; i++) {
|
|
719
|
+
if (consumedOperands.has(i))
|
|
720
|
+
continue;
|
|
721
|
+
if (words[i].startsWith('-'))
|
|
722
|
+
continue;
|
|
723
|
+
if (/\s/.test(words[i]))
|
|
724
|
+
found.push(words[i]);
|
|
725
|
+
}
|
|
478
726
|
}
|
|
479
727
|
}
|
|
480
728
|
// `-exec` is a flag rather than a command word, so this one is still a scan.
|
|
@@ -541,7 +789,58 @@ const EXEC_WRAPPERS = new Set([
|
|
|
541
789
|
* the `+` terminator carries none.
|
|
542
790
|
*/
|
|
543
791
|
// Null-prototype for the same reason as INTERPRETERS: indexed by the command word.
|
|
544
|
-
const DISQUALIFYING_ARGS = Object.assign(Object.create(null), {
|
|
792
|
+
const DISQUALIFYING_ARGS = Object.assign(Object.create(null), {
|
|
793
|
+
find: /^-(exec|execdir|ok|okdir|delete|fprintf?|fls)$/,
|
|
794
|
+
// GNU sort execs this for every temporary file it spills, so a reader
|
|
795
|
+
// becomes a launcher. Measured against coreutils 9.11: an attacker-named
|
|
796
|
+
// script ran 14,224 times for one 200k-line input, and the whole command
|
|
797
|
+
// classified `read-only` — which a `readOnly` viewer is allowed to run,
|
|
798
|
+
// while running that same program directly is denied.
|
|
799
|
+
//
|
|
800
|
+
// `-o FILE` / `--output=FILE` is the same shape of bug with a plainer
|
|
801
|
+
// payoff: it creates and truncates FILE, which is a write a `readOnly`
|
|
802
|
+
// profile must never reach through a command classified `read-only`.
|
|
803
|
+
// Measured on HEAD before this rule: `sort -o /root/.ssh/authorized_keys
|
|
804
|
+
// /tmp/key.pub` classified `read-only`.
|
|
805
|
+
//
|
|
806
|
+
// Targeted round (2026-09-24, R2+R3): the version above closed the exact
|
|
807
|
+
// spellings `-o`, `--output` and `--compress-program` and left the CLASS
|
|
808
|
+
// open — GNU sort's own option grammar has two escapes past a fixed
|
|
809
|
+
// spelling, both measured against the real binary:
|
|
810
|
+
//
|
|
811
|
+
// - A short-option cluster is scanned left to right, and any of sort's
|
|
812
|
+
// own *argument-less* short flags may sit ahead of `-o` without
|
|
813
|
+
// consuming it: `sort -mo out in` writes `out` exactly as `sort -o out
|
|
814
|
+
// in` does. `-m` (merge) is itself argument-less, and its absence from
|
|
815
|
+
// the char class below is exactly what let `sort -mo …` through as
|
|
816
|
+
// `read-only`.
|
|
817
|
+
// - `getopt_long` resolves a `--` word by unambiguous-prefix matching:
|
|
818
|
+
// `--o`, `--ou`, `--out`, `--outp`, `--outpu` all mean `--output`
|
|
819
|
+
// (nothing else starts with `o`), and `--co` through
|
|
820
|
+
// `--compress-progra` all mean `--compress-program` (`--c` alone is
|
|
821
|
+
// ambiguous with `--check` and sort refuses to run at all —
|
|
822
|
+
// `unambiguousLongOptionSource` excludes it for exactly that reason,
|
|
823
|
+
// computed from sort's own option table rather than hand-picked).
|
|
824
|
+
//
|
|
825
|
+
// The cluster branch is still deliberately narrower than "any `o` in a
|
|
826
|
+
// dash word": `-[bcCdfghiMmnRrsuVz]*o` only allows GNU sort's own
|
|
827
|
+
// argument-less short flags ahead of the `o`, so it stops at the first
|
|
828
|
+
// flag that takes a value of its own. Without that, `-tofile` — `-t`
|
|
829
|
+
// (field separator) with its value attached, not `-o` — would be
|
|
830
|
+
// misread as a write. `-t`, `-k`, `-S` and `-T` are exactly the short
|
|
831
|
+
// flags this excludes, because each consumes the rest of a clustered
|
|
832
|
+
// word as its own argument, so a following `o` is that argument's text,
|
|
833
|
+
// not `-o` invoked.
|
|
834
|
+
//
|
|
835
|
+
// `find`'s entry above does not get the same prefix-abbreviation
|
|
836
|
+
// treatment: `find` parses its predicates itself rather than through
|
|
837
|
+
// `getopt_long`, and accepts no abbreviated spelling of `-exec` — `find .
|
|
838
|
+
// -exe` is "unknown predicate `-exe`", measured. There is no class to
|
|
839
|
+
// close there.
|
|
840
|
+
sort: new RegExp(`^(?:${unambiguousLongOptionSource('compress-program')}`
|
|
841
|
+
+ `|${unambiguousLongOptionSource('output')}`
|
|
842
|
+
+ '|-[bcCdfghiMmnRrsuVz]*o)'),
|
|
843
|
+
});
|
|
545
844
|
/** A leading `NAME=value`, which a shell treats as an assignment, not a command. */
|
|
546
845
|
const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
547
846
|
/** `timeout 5s`, `nice 10` — a bare argument some wrappers take before the command. */
|
|
@@ -673,11 +972,26 @@ function elevatedBinaryOf(command) {
|
|
|
673
972
|
* behind a wrapper, behind an assignment, or after a separator all reached root
|
|
674
973
|
* with the command classified `safe` or `read-only`.
|
|
675
974
|
*/
|
|
676
|
-
/**
|
|
975
|
+
/**
|
|
976
|
+
* An allowlisted binary carrying a flag that makes it write or execute.
|
|
977
|
+
*
|
|
978
|
+
* Keyed on `effectiveCommandIndex`, not `parseSegments`. `parseSegments`
|
|
979
|
+
* (`parseWords`) only steps over a privilege prefix (`sudo`, `su`, …), not an
|
|
980
|
+
* exec wrapper (`env`, `nohup`, `timeout`, …), so `env sort
|
|
981
|
+
* --compress-program=X` read `head` as `"env"` — not in `DISQUALIFYING_ARGS`
|
|
982
|
+
* — and the flag went unnoticed. `effectiveCommandIndex` already reads past
|
|
983
|
+
* both, which is what `operandsAreData` and `readsProgramFromStdin` use it
|
|
984
|
+
* for. Deliberately not fixed by changing `parseWords` itself:
|
|
985
|
+
* `invokedWords` also reads it, and widening what counts as "the command"
|
|
986
|
+
* there is a different, larger change this fix does not take on.
|
|
987
|
+
*/
|
|
677
988
|
function hasDisqualifyingArgs(command) {
|
|
678
|
-
return
|
|
679
|
-
const
|
|
680
|
-
|
|
989
|
+
return tokenizeSegments(command).some((words) => {
|
|
990
|
+
const idx = effectiveCommandIndex(words);
|
|
991
|
+
if (idx === -1)
|
|
992
|
+
return false;
|
|
993
|
+
const rule = DISQUALIFYING_ARGS[stripPath(words[idx])];
|
|
994
|
+
return rule !== undefined && words.slice(idx + 1).some((arg) => rule.test(arg));
|
|
681
995
|
});
|
|
682
996
|
}
|
|
683
997
|
function invokedWords(command) {
|
|
@@ -769,9 +1083,21 @@ export function findForbiddenMatch(command, depth = 0) {
|
|
|
769
1083
|
// They still classified `destructive`, which on the `prod` tier degrades an
|
|
770
1084
|
// absolute `deny` into `require-approval`: a rule that answers "never" became
|
|
771
1085
|
// one a human can click through.
|
|
1086
|
+
//
|
|
1087
|
+
// `speculativeOperands: false` — the maintainer's ruling. `nestedCommands`'
|
|
1088
|
+
// catch-all is a guess about an unrecognised binary's operands, and this loop
|
|
1089
|
+
// feeds the one unconditional rule in the policy: a guess may raise a
|
|
1090
|
+
// command's class, which role, tier and approval still get to weigh in on,
|
|
1091
|
+
// but it must not manufacture a refusal nobody can override. Measured before
|
|
1092
|
+
// this parameter existed: `git commit -m 'reboot the worker pool'` was a hard
|
|
1093
|
+
// deny on an admin profile with `approvalPolicy: 'auto'`, because the catch-all
|
|
1094
|
+
// read the quoted commit message as an operand starting with a forbidden word.
|
|
1095
|
+
// `$()`, backticks and `sh -c` are certain carriers, not guesses — the shell
|
|
1096
|
+
// really does run what they hold — and are unaffected: they are pushed by
|
|
1097
|
+
// `nestedCommands` regardless of this flag.
|
|
772
1098
|
if (depth >= MAX_NESTING_DEPTH)
|
|
773
1099
|
return null;
|
|
774
|
-
for (const inner of nestedCommands(command)) {
|
|
1100
|
+
for (const inner of nestedCommands(command, false)) {
|
|
775
1101
|
const match = findForbiddenMatch(inner, depth + 1);
|
|
776
1102
|
if (match !== null)
|
|
777
1103
|
return match;
|
|
@@ -874,7 +1200,9 @@ function effectiveCommandIndex(words) {
|
|
|
874
1200
|
*/
|
|
875
1201
|
function operandsAreData(words) {
|
|
876
1202
|
const idx = effectiveCommandIndex(words);
|
|
877
|
-
|
|
1203
|
+
if (idx === -1)
|
|
1204
|
+
return false;
|
|
1205
|
+
return READERS[stripPath(unquote(words[idx]))]?.operandsAreData === true;
|
|
878
1206
|
}
|
|
879
1207
|
/**
|
|
880
1208
|
* Whether an interpreter name is the value of the flag before it rather than a command.
|
|
@@ -904,59 +1232,169 @@ function operandsAreData(words) {
|
|
|
904
1232
|
function skippableBetweenFlags(word) {
|
|
905
1233
|
return word === '' || word.startsWith('-');
|
|
906
1234
|
}
|
|
907
|
-
|
|
1235
|
+
/**
|
|
1236
|
+
* Whether `word` is a spelling of `flag`, honouring case-insensitivity when asked.
|
|
1237
|
+
*
|
|
1238
|
+
* Scoped per call, never globally: pwsh/powershell's own parameter binder resolves
|
|
1239
|
+
* `-ENC`, `-Enc` and `-enc` to the same parameter, but `-E` and `-e` are two different
|
|
1240
|
+
* flags to perl. Every caller here is told explicitly, per invocation, whether the
|
|
1241
|
+
* interpreter it is matching against is one of the case-insensitive ones — folding case
|
|
1242
|
+
* is never the default.
|
|
1243
|
+
*/
|
|
1244
|
+
function sameFlag(word, flag, caseInsensitive) {
|
|
1245
|
+
return caseInsensitive ? word.toLowerCase() === flag.toLowerCase() : word === flag;
|
|
1246
|
+
}
|
|
1247
|
+
function isFlagValue(words, i, flags, caseInsensitive = false) {
|
|
908
1248
|
if (i === 0)
|
|
909
1249
|
return false;
|
|
910
1250
|
const previous = words[i - 1];
|
|
911
|
-
|
|
1251
|
+
if (!(previous.length > 1 && previous.startsWith('-')))
|
|
1252
|
+
return false;
|
|
1253
|
+
return flags.some((f) => sameFlag(previous, f, caseInsensitive));
|
|
912
1254
|
}
|
|
913
1255
|
/**
|
|
914
1256
|
* The program an interpreter was handed on its command line, or null.
|
|
915
1257
|
*
|
|
916
1258
|
* Only flags may sit between the interpreter and its flag; anything else means this was
|
|
917
1259
|
* not that kind of invocation, which is what keeps `python3 script.py` — a program this
|
|
918
|
-
* cannot read either, but one every deployment runs — out of the gate.
|
|
1260
|
+
* cannot read either, but one every deployment runs — out of the gate. That rule holds
|
|
1261
|
+
* for every interpreter here except pwsh/powershell (see `tolerateUnknownWordsAtHead`):
|
|
1262
|
+
* real POSIX interpreters stop parsing their own options at the first positional
|
|
1263
|
+
* argument — `python3 script.py -c 'evil'` hands `-c evil` to the script as `argv`, not
|
|
1264
|
+
* to python — so treating a bare word as "not this kind of invocation" is correct for
|
|
1265
|
+
* them, not merely convenient.
|
|
1266
|
+
*
|
|
1267
|
+
* Returns which of `flags` matched alongside the program, not just the program text, so
|
|
1268
|
+
* a caller can tell `-EncodedCommand` from its `-e` abbreviation apart from every other
|
|
1269
|
+
* program-bearing word — decoding is specific to that one flag, and both spellings reach
|
|
1270
|
+
* here as an ordinary match.
|
|
1271
|
+
*
|
|
1272
|
+
* @param tolerateUnknownWordsAtHead pwsh/powershell's own argument parser walks the
|
|
1273
|
+
* whole command line looking for named parameters it recognises and does not stop at
|
|
1274
|
+
* an unrecognised one's value (`-ExecutionPolicy Bypass`) the way a POSIX interpreter
|
|
1275
|
+
* stops at its first positional argument. Only takes effect when `from` is the
|
|
1276
|
+
* segment's *effective* command word — `effectiveCommandIndex(words)`, which reads
|
|
1277
|
+
* past a privilege prefix or an exec wrapper (`env`, `nohup`, `timeout`, …), not merely
|
|
1278
|
+
* position 0. `from === 0` missed exactly the shape those wrappers exist to describe:
|
|
1279
|
+
* `env pwsh -ExecutionPolicy Bypass -EncodedCommand …` put `pwsh` at index 1, the
|
|
1280
|
+
* tolerance never engaged, and the interpreter loop's own flag-walk gave up at
|
|
1281
|
+
* `Bypass` before ever reaching `-EncodedCommand` — classified `safe`. Still unambiguous
|
|
1282
|
+
* about *which* word is being invoked, not a value or a search term this file has no
|
|
1283
|
+
* business reinterpreting (`grep -e perl -e python` never reaches here with this set,
|
|
1284
|
+
* because `perl` is not the segment's effective command word and isn't pwsh-family
|
|
1285
|
+
* regardless; nor is `customtool --search pwsh …`, where `customtool` is). Structural,
|
|
1286
|
+
* not a list of pwsh's value-taking flags: this file does not need to know
|
|
1287
|
+
* `-ExecutionPolicy` exists to stop being confused by it.
|
|
919
1288
|
*/
|
|
920
|
-
function programAfterFlag(words, from, flags) {
|
|
1289
|
+
function programAfterFlag(words, from, flags, caseInsensitive = false, tolerateUnknownWordsAtHead = false) {
|
|
1290
|
+
const tolerateUnknownWords = tolerateUnknownWordsAtHead && from === effectiveCommandIndex(words);
|
|
921
1291
|
for (let j = from + 1; j < words.length; j++) {
|
|
922
1292
|
const word = words[j];
|
|
923
|
-
|
|
924
|
-
|
|
1293
|
+
const exact = flags.find((f) => sameFlag(word, f, caseInsensitive));
|
|
1294
|
+
if (exact !== undefined) {
|
|
1295
|
+
// A subcommand — a program-bearing word that is not itself a `-` flag, such as
|
|
1296
|
+
// `deno`'s `eval` — may still have its own options before the code: `deno eval
|
|
1297
|
+
// --unstable <code>` is one invocation, not two. A real flag like `-c` or
|
|
1298
|
+
// `-Command` never has anything of its own between it and the program, so this
|
|
1299
|
+
// only widens the subcommand case.
|
|
1300
|
+
let k = j + 1;
|
|
1301
|
+
if (!exact.startsWith('-')) {
|
|
1302
|
+
while (k < words.length && skippableBetweenFlags(words[k]))
|
|
1303
|
+
k++;
|
|
1304
|
+
}
|
|
1305
|
+
const program = words[k];
|
|
1306
|
+
return program === undefined ? null : { program, flag: exact, index: k };
|
|
1307
|
+
}
|
|
1308
|
+
// Deliberately case-SENSITIVE even for pwsh/powershell: this branch exists for a
|
|
1309
|
+
// value glued directly onto a short flag with no separating space (`-csudo id`).
|
|
1310
|
+
// Folding case here as well made `-ExecutionPolicy` — a real pwsh option this file
|
|
1311
|
+
// does not otherwise track — case-insensitively start with `-e` and get read as
|
|
1312
|
+
// `-e` plus an attached `xecutionPolicy`, which swallowed `-ExecutionPolicy Bypass
|
|
1313
|
+
// -Command 'sudo id'` into `destructive` and, worse, made `foundProgram` true so
|
|
1314
|
+
// the catch-all below never ran. Every case-insensitive spelling the table needs
|
|
1315
|
+
// to accept (`-enc`, `-ec`, `-EC`, `-ENC`, `-E`, `-EncodedCOMMAND`, `-COMMAND`,
|
|
1316
|
+
// `-C`, …) is already reached through the exact match above or the cluster match
|
|
1317
|
+
// below, so this branch does not need to fold case to cover them.
|
|
925
1318
|
const attached = flags.find((f) => word.startsWith(f) && word.length > f.length);
|
|
926
1319
|
if (attached !== undefined) {
|
|
927
1320
|
const rest = word.slice(attached.length);
|
|
928
1321
|
// `sh -c'sudo id'` tokenises to `-csudo id`, so the program is attached. `bash -cx`
|
|
929
1322
|
// is a flag cluster and the program is the next word. A space, or the `=` of
|
|
930
1323
|
// `--eval=…`, is what tells them apart: a cluster is letters only.
|
|
931
|
-
if (/\s/.test(rest) || rest.startsWith('='))
|
|
932
|
-
return rest.replace(/^=/, '');
|
|
933
|
-
|
|
1324
|
+
if (/\s/.test(rest) || rest.startsWith('=')) {
|
|
1325
|
+
return { program: rest.replace(/^=/, ''), flag: attached, index: j };
|
|
1326
|
+
}
|
|
1327
|
+
const program = words[j + 1];
|
|
1328
|
+
return program === undefined ? null : { program, flag: attached, index: j + 1 };
|
|
934
1329
|
}
|
|
935
1330
|
// The program flag need not lead the cluster: `bash -xc 'sudo id'` runs exactly what
|
|
936
1331
|
// `bash -cx 'sudo id'` runs, and a prefix test saw the second and missed the first.
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
1332
|
+
const clusterFlag = clusterCarriesFlag(word, flags, caseInsensitive);
|
|
1333
|
+
if (clusterFlag !== null) {
|
|
1334
|
+
const program = words[j + 1];
|
|
1335
|
+
return program === undefined ? null : { program, flag: clusterFlag, index: j + 1 };
|
|
1336
|
+
}
|
|
1337
|
+
if (!skippableBetweenFlags(word) && !tolerateUnknownWords)
|
|
940
1338
|
return null;
|
|
941
1339
|
}
|
|
942
1340
|
return null;
|
|
943
1341
|
}
|
|
1342
|
+
/** How much base64 is worth decoding before the answer stops changing. */
|
|
1343
|
+
const MAX_ENCODED_CHARS = 64 * 1024;
|
|
1344
|
+
/**
|
|
1345
|
+
* The command inside `-EncodedCommand`, or null.
|
|
1346
|
+
*
|
|
1347
|
+
* PowerShell encodes UTF-16LE, so decoding as utf8 yields text with a NUL between
|
|
1348
|
+
* every character and no pattern matches it. `Buffer.from(x, 'base64')` never
|
|
1349
|
+
* throws — it drops characters outside the alphabet — so malformed input produces
|
|
1350
|
+
* a wrong answer rather than an exception, and the caller must treat null and
|
|
1351
|
+
* nonsense alike: the flag alone has already made the command `destructive`.
|
|
1352
|
+
*
|
|
1353
|
+
* Reads a bounded, 4-aligned PREFIX rather than refusing outright past the limit. A
|
|
1354
|
+
* refusal is a downgrade path: it turns "pad the payload past 64 KiB" into a way to
|
|
1355
|
+
* trade `privileged` for `destructive`, which is strictly better for whoever is padding
|
|
1356
|
+
* it. 4-aligned because base64 decodes in groups of four characters to three bytes;
|
|
1357
|
+
* truncating mid-group corrupts the last partial character instead of just dropping
|
|
1358
|
+
* trailing content the decode was never going to reach anyway.
|
|
1359
|
+
*/
|
|
1360
|
+
function decodedPowerShellCommand(operand) {
|
|
1361
|
+
const prefix = operand.length > MAX_ENCODED_CHARS
|
|
1362
|
+
? operand.slice(0, MAX_ENCODED_CHARS - (MAX_ENCODED_CHARS % 4))
|
|
1363
|
+
: operand;
|
|
1364
|
+
const decoded = Buffer.from(prefix, 'base64').toString('utf16le');
|
|
1365
|
+
return decoded.includes('�') || decoded.trim() === '' ? null : decoded;
|
|
1366
|
+
}
|
|
944
1367
|
/**
|
|
945
|
-
* What follows a short flag inside a single-dash cluster, or
|
|
1368
|
+
* What follows a short flag inside a single-dash cluster, or the `flags` entry it
|
|
1369
|
+
* carries, or null if it carries none.
|
|
946
1370
|
*
|
|
947
1371
|
* Clusters only — a run of single letters after one dash. Long flags and attached values
|
|
948
|
-
* are handled before this is reached.
|
|
1372
|
+
* are handled before this is reached. Returns the canonical flag rather than a boolean
|
|
1373
|
+
* for the same reason `programAfterFlag` does: a caller deciding whether to decode
|
|
1374
|
+
* compares against the table's own spellings, not against whatever letters happened to
|
|
1375
|
+
* be clustered together.
|
|
949
1376
|
*/
|
|
950
|
-
function clusterCarriesFlag(word, flags) {
|
|
1377
|
+
function clusterCarriesFlag(word, flags, caseInsensitive = false) {
|
|
951
1378
|
// Three letters at most. `/^-[A-Za-z]+$/` alone also matches every single-dash long
|
|
952
1379
|
// option, and `find`'s predicates are full of them: `-type` contains perl's `-e`, so
|
|
953
1380
|
// `find . -name perl -type f` read as a carrier and asked for approval.
|
|
954
1381
|
if (!/^-[A-Za-z]{2,3}$/.test(word))
|
|
955
|
-
return
|
|
956
|
-
const body = word.slice(1);
|
|
1382
|
+
return null;
|
|
1383
|
+
const body = caseInsensitive ? word.slice(1).toLowerCase() : word.slice(1);
|
|
1384
|
+
const shortFlags = flags.filter((flag) => flag.length === 2);
|
|
957
1385
|
// A cluster is letters only, so whatever follows the program flag is more flags — the
|
|
958
1386
|
// program itself is always the next word.
|
|
959
|
-
|
|
1387
|
+
//
|
|
1388
|
+
// Scanned by the BODY's own character order, not the table's order: `-ec` must
|
|
1389
|
+
// resolve to `-e` (so a caller deciding whether to decode sees `-e`), not to `-c`
|
|
1390
|
+
// just because `-c` happens to sit first in pwsh's `programBearingWords`. The first
|
|
1391
|
+
// letter in the cluster is the one the invocation leads with.
|
|
1392
|
+
for (const letter of body) {
|
|
1393
|
+
const match = shortFlags.find((flag) => (caseInsensitive ? flag[1].toLowerCase() : flag[1]) === letter);
|
|
1394
|
+
if (match !== undefined)
|
|
1395
|
+
return match;
|
|
1396
|
+
}
|
|
1397
|
+
return null;
|
|
960
1398
|
}
|
|
961
1399
|
/** Spellings of "the program is on standard input" that look like a file operand. */
|
|
962
1400
|
const STDIN_PATHS = new Set(['-', '/dev/stdin', '/dev/fd/0', '/proc/self/fd/0']);
|
|
@@ -975,10 +1413,10 @@ function readsProgramFromStdin(words) {
|
|
|
975
1413
|
const bin = stripPath(words[idx]);
|
|
976
1414
|
// Interpreters only. awk's one program-from-stdin form is `awk -f -`, already gated as a
|
|
977
1415
|
// file flag; every other piped awk reads data, not a program.
|
|
978
|
-
const spec =
|
|
1416
|
+
const spec = resolveInterpreter(bin);
|
|
979
1417
|
if (spec === undefined)
|
|
980
1418
|
return false;
|
|
981
|
-
const flags = spec.
|
|
1419
|
+
const flags = spec.programBearingWords;
|
|
982
1420
|
let sawStdinFlag = false;
|
|
983
1421
|
for (let j = idx + 1; j < words.length; j++) {
|
|
984
1422
|
const word = words[j];
|
|
@@ -1074,12 +1512,16 @@ function hasUnreadableProgram(command) {
|
|
|
1074
1512
|
return true;
|
|
1075
1513
|
if (!operandsAreData(words)) {
|
|
1076
1514
|
for (let j = 0; j < words.length; j++) {
|
|
1077
|
-
const spec =
|
|
1515
|
+
const spec = resolveInterpreter(stripPath(unquote(words[j])));
|
|
1078
1516
|
if (spec === undefined || spec.readable)
|
|
1079
1517
|
continue;
|
|
1080
|
-
|
|
1518
|
+
// Same case-insensitivity as `nestedCommands`'s interpreter loop, and for the
|
|
1519
|
+
// same reason: pwsh/powershell's own parameter binder does not distinguish
|
|
1520
|
+
// `-EC` from `-ec`, so neither should this.
|
|
1521
|
+
const caseInsensitive = spec.programBearingWords.includes('-EncodedCommand');
|
|
1522
|
+
if (isFlagValue(words, j, spec.programBearingWords, caseInsensitive))
|
|
1081
1523
|
continue;
|
|
1082
|
-
if (programAfterFlag(words, j, spec.
|
|
1524
|
+
if (programAfterFlag(words, j, spec.programBearingWords, caseInsensitive, caseInsensitive) !== null)
|
|
1083
1525
|
return true;
|
|
1084
1526
|
}
|
|
1085
1527
|
}
|
|
@@ -1228,4 +1670,4 @@ function classifyOuter(trimmed) {
|
|
|
1228
1670
|
}
|
|
1229
1671
|
return { binary, fullCommand, class: 'safe' };
|
|
1230
1672
|
}
|
|
1231
|
-
export { READ_ONLY_ALLOWLIST, READ_ONLY_SYNTHETIC, isDestructive };
|
|
1673
|
+
export { READ_ONLY_ALLOWLIST, READERS, READ_ONLY_SYNTHETIC, isDestructive };
|
package/build/ssh/connection.js
CHANGED
|
@@ -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 },
|
|
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.',
|
|
@@ -2,18 +2,27 @@ import { z } from 'zod';
|
|
|
2
2
|
import { redactText } from '../guard/redactor.js';
|
|
3
3
|
import { remotePathForAudit, sanitizeRemotePath } from '../guard/sanitizer.js';
|
|
4
4
|
import { SftpClient } from '../ssh/sftp.js';
|
|
5
|
+
import { OVERWRITE_FLAG, payloadSuffix } from './audit-effects.js';
|
|
5
6
|
import { TOOL_DESCRIPTIONS as D } from './descriptions.js';
|
|
6
7
|
import { syntheticSuccess, textResult } from './results.js';
|
|
7
8
|
/**
|
|
8
9
|
* SFTP transfer tools.
|
|
9
10
|
*
|
|
10
11
|
* Both paths go through `sanitizeRemotePath`, the same bar the streaming tools
|
|
11
|
-
* hold — for the *path
|
|
12
|
-
*
|
|
13
|
-
* `sftp-upload
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
17
26
|
* the caller's raw string, so nothing refused a bidi override or a zero-width
|
|
18
27
|
* character in a path that is quoted back in the approval prompt and written
|
|
19
28
|
* into a hash-chained audit record — the exact confusion that validator exists
|
|
@@ -32,7 +41,19 @@ export function registerFileTools({ server }, pipeline) {
|
|
|
32
41
|
content: z.string().describe('File content to upload'),
|
|
33
42
|
profile: z.string().optional().describe('Profile name'),
|
|
34
43
|
}, { destructiveHint: true }, async ({ remotePath, content, profile }, extra) => {
|
|
35
|
-
return runAudited(
|
|
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)}`, {
|
|
36
57
|
toolName: 'sftp-upload',
|
|
37
58
|
failureClass: 'destructive',
|
|
38
59
|
profile,
|
|
@@ -43,10 +64,8 @@ export function registerFileTools({ server }, pipeline) {
|
|
|
43
64
|
await new SftpClient(rt.conn).upload({ remotePath, content });
|
|
44
65
|
return {
|
|
45
66
|
audited: syntheticSuccess(rt.profileName),
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
// multi-byte character. SftpClient.upload writes Buffer.from(content),
|
|
49
|
-
// 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.
|
|
50
69
|
output: textResult(`Uploaded ${Buffer.byteLength(content, 'utf8')} bytes to ${remotePath}`),
|
|
51
70
|
};
|
|
52
71
|
});
|
|
@@ -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
|
*
|
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.12.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",
|