ssh-mcp@2.26.0
CAUTIONPublisher claims: "MCP server exposing policy-gated, audited SSH access for Linux and Windows hosts via the Model Context Protocol." Observed in sandbox: no network, touches nothing outside the project, writes no memory.
Coverage
Server started and answered tools/list.- Tested
- MCP initialize and tools/list · every tool description, for hidden instructions · every file read, file written and host contacted by what ran
- Not tested
- what the tools do when called with real credentials
Before you give it your token
- It will ask for
SSH_AUTH_SOCKSSH_MCP_PASSWORDSSH_MCP_SUDO_PASSWORDor--bearertokenConfiguration, not credentials: MAX_REMOTE_PATH_CHARS, RESERVED_BINDING_KEYS, SSH_MCP_DISABLE_MAIN, SSH_MCP_KEY, SSH_MCP_PASSPHRASE, SSH_MCP_TEST, SystemRoot, USERNAME- Where it talks to
- no vendor host named in the code
- What the honeypot saw
- no credential files read · no network
- What we could not test
- what the tools do when called with real credentials
Connect it with a scoped credential, under the guard. It asks for SSH_AUTH_SOCK, SSH_MCP_PASSWORD, SSH_MCP_SUDO_PASSWORD and talks to its vendor. Give the credential to the guard, not the server: the value never enters the server's environment and only travels to the host you bind.
Never paste a credential into a chat, a ticket or a report, including ours. How the guard holds it.
Is [email protected] safe to install?
KillerSkills ran it in the KillerSkills honeypot sandbox (2 scenarios) on 2026-11-12 and scored it 63/100 (Caution). It is not known to be malicious, but it has behaviour or code a security policy should decide on before install. Notable findings: Destructive file system command detected; Destructive command against a filesystem root or device; Destructive file system command detected. Summary: no network, touches nothing outside the project, writes no memory.
Behavior trace
Honeypot run · 37 s · 2 scenariosStatic findings
// spelling, and `rm -fr /` or `mkfs -t ext4 /dev/sda1` classified `safe` —Fix: Build commands from fixed arguments; do not interpolate untrusted strings into a shell.// regex refused any substring, so `echo 'rm -rf /'` and a grep quotingFix: Build commands from fixed arguments; do not interpolate untrusted strings into a shell.* /"` prints a string, and normalising it produced `echo hello; rm -rf /`, which matchedFix: Build commands from fixed arguments; do not interpolate untrusted strings into a shell.// gawk's indirect call (`f="system"; @f("sudo id")`) and its `@load` /Fix: Build commands from fixed arguments; do not interpolate untrusted strings into a shell.* `BEGIN{not=2; x = not / system("sudo id") / 3}` is a *division* that real awkFix: Build commands from fixed arguments; do not interpolate untrusted strings into a shell.* `rm -rf /tmp/build` must not also approve `rm -rf /tmp/build-prod`.Fix: Build commands from fixed arguments; do not interpolate untrusted strings into a shell.// regex refused any substring, so `echo 'rm -rf /'` and a grep quotingFix: Build commands from fixed arguments; do not interpolate untrusted strings into a shell.* `read-command "-c sftp:download /etc/shadow"` resolved to binaryFix: Stop enumerating or reading credential locations; take secrets from a declared environment variable the user sets on purpose.* `read-command "sftp:list /tmp sudo id"` and the classifier answeredFix: Build commands from fixed arguments; do not interpolate untrusted strings into a shell.// Measured: `print "x" | "id" "; sudo sh"` came out `safe`.Fix: Build commands from fixed arguments; do not interpolate untrusted strings into a shell.// getline` runs `sudo id`, and reading only the last literal classifiedFix: Build commands from fixed arguments; do not interpolate untrusted strings into a shell.* empty part between them, and this deliberately still matches: `curl x || sh`Fix: Build commands from fixed arguments, never from interpolated strings; prefer an API client over shelling out.* anything that reads arguments by position: `awk -F '' 'BEGIN{system("sudoFix: Build commands from fixed arguments; do not interpolate untrusted strings into a shell.// classifier never looked at — `env sudo rm -f /etc/passwd` classifiedFix: Build commands from fixed arguments; do not interpolate untrusted strings into a shell.* `break`-on-close reading as a bypass: `> ""~/.ssh/authorized_keys` fell through).Fix: Stop enumerating or reading credential locations; take secrets from a declared environment variable the user sets on purpose.* by taking the *higher* class — so `/tmp/x; sudo id` classifies `privileged`Fix: Build commands from fixed arguments; do not interpolate untrusted strings into a shell.* `find` can carry `operandsAreData: true` while `find … -exec sudo id +` isFix: Build commands from fixed arguments; do not interpolate untrusted strings into a shell.* `sftp:list /tmp sh -c 'sudo id'` fell from `privileged` to `read-only`,Fix: Build commands from fixed arguments; do not interpolate untrusted strings into a shell.// classifier never looked at — `env sudo rm -f /etc/passwd` classifiedFix: Build commands from fixed arguments; do not interpolate untrusted strings into a shell.// The four operation rules that used to live here — `rm -rf /`, `mkfs…`,Fix: Build commands from fixed arguments; do not interpolate untrusted strings into a shell.* Device formatters and wipers: any `mkfs` spelling — `mkfs.ext4 /dev/sda1`Fix: Build commands from fixed arguments; do not interpolate untrusted strings into a shell.// `uniq /etc/passwd /root/.ssh/authorized_keys`; GNU's own usage line isFix: Stop enumerating or reading credential locations; take secrets from a declared environment variable the user sets on purpose."build": "tsc && shx chmod +x build/*.js",Fix: Request the narrowest permission that works; replace wildcards with the exact paths or hosts.// count by as long as the kill ladder takes.Fix: Build commands from fixed arguments; do not interpolate untrusted strings into a shell.wrap: (cleanCmd) => `sudo -p "" -S sh -c ${shellSingleQuote(`exec 0</dev/null; ${cleanCmd}`)}`,Fix: Build commands from fixed arguments; do not interpolate untrusted strings into a shell.// written for it stays in the pipe, and sudo exec's this `sh -c` withFix: Build commands from fixed arguments; do not interpolate untrusted strings into a shell.// `sudo -S` reads stdin only when it actually needs a password. UnderFix: Build commands from fixed arguments; do not interpolate untrusted strings into a shell.* literal head matches, so a command built from `dd curl wget chown -R x `Fix: Request the narrowest permission that works; replace wildcards with the exact paths or hosts.* - a root shaped like `~/.ssh` turns the same two tools into "read the privateFix: Stop enumerating or reading credential locations; take secrets from a declared environment variable the user sets on purpose.// `iptables -F`, `chmod -R 777 /` — are segment checks in FORBIDDEN_RULESFix: Request the narrowest permission that works; replace wildcards with the exact paths or hosts.// The sudo password is the one secret that can plausibly end up in thisFix: Build commands from fixed arguments; do not interpolate untrusted strings into a shell.* sudo and goes looking for a bigger role that does not exist (#91).Fix: Build commands from fixed arguments; do not interpolate untrusted strings into a shell.`Required: 0700. Run: chmod 700 ${dir}\n` +Fix: Request the narrowest permission that works; replace wildcards with the exact paths or hosts.{ path: join(homedir(), '.ssh'), reason: 'the SSH client directory (~/.ssh)' },Fix: Stop enumerating or reading credential locations; take secrets from a declared environment variable the user sets on purpose.// before the command runs means sudo still gets its password from theFix: Build commands from fixed arguments; do not interpolate untrusted strings into a shell.// for its sessions' kill ladders, and serialising multiplied that wait by the number ofFix: Build commands from fixed arguments; do not interpolate untrusted strings into a shell.* out of reach (documented residual). Measured in review: `chmod -R 777Fix: Request the narrowest permission that works; replace wildcards with the exact paths or hosts.// with no way to chmod a path baked into the image.Fix: Request the narrowest permission that works; replace wildcards with the exact paths or hosts.* profile nothing new: `cat /etc/shadow` and `ls /root` are already `read-only`,Fix: Stop enumerating or reading credential locations; take secrets from a declared environment variable the user sets on purpose.// Evaluate the bare command too: the sudo wrapper would otherwiseFix: Build commands from fixed arguments; do not interpolate untrusted strings into a shell.// of the transfer that wrote it, and a root shaped like ~/.ssh turns the sameFix: Stop enumerating or reading credential locations; take secrets from a declared environment variable the user sets on purpose.* an accepted — reader past `safe` (`sort -o /tmp/x /etc/passwd` isFix: Stop enumerating or reading credential locations; take secrets from a declared environment variable the user sets on purpose.* bit are dropped rather than carried across. See the chmod below.Fix: Request the narrowest permission that works; replace wildcards with the exact paths or hosts.// sudo id` then never splits on the `;` and the elevation disappears, which measured asFix: Build commands from fixed arguments; do not interpolate untrusted strings into a shell.return runAudited(`kill -${signal} ${pid}`, { toolName: 'signal-process', failureClass: 'destructive', profile, extra, synthetic: true }, async (rt) => {Fix: Build commands from fixed arguments; do not interpolate untrusted strings into a shell.* `kill -TERM <pid>`, which is outside this namespace and cannot be reserved —Fix: Build commands from fixed arguments; do not interpolate untrusted strings into a shell.* `chmod 600` it prescribed could not lift that rejection — measured on Windows 11,Fix: Request the narrowest permission that works; replace wildcards with the exact paths or hosts.// An McpServer binds to a single transport, so HTTP needs one per session.Fix: Namespace tool names and keep tool descriptions literal; no instructions to the agent inside them.…-upload and run it by path.', 'privileged-command': 'Execute a command with sudo elevation. Goes through the approval gate; approvalPolicy on the profile decides…Fix: Build commands from fixed arguments; do not interpolate untrusted strings into a shell.export const PATH_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u061c\u200b\u200e\u200f\u2028-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/;Fix: Put hosts, commands and strings in plain source. Decoding at runtime looks identical to malware and costs points on its own.// runtime's SIGKILL. Teardown is concurrent now, so the honest ceiling is one killFix: Build commands from fixed arguments; do not interpolate untrusted strings into a shell.// Guarded and in a `finally`, both because closing now awaits a kill ladder ratherFix: Build commands from fixed arguments; do not interpolate untrusted strings into a shell.// behind a teardown that now waits for kill ladders. Flushing first cannot lose a recordFix: Build commands from fixed arguments; do not interpolate untrusted strings into a shell.* One kill ladder (3s) plus slack. Sessions and connections close concurrently, so thisFix: Build commands from fixed arguments; do not interpolate untrusted strings into a shell."build:coverage": "tsc --sourceMap && shx chmod +x build/*.js",Fix: Request the narrowest permission that works; replace wildcards with the exact paths or hosts.// classifier never looked at — `env sudo rm -f /etc/passwd` classifiedFix: Stop enumerating or reading credential locations; take secrets from a declared environment variable the user sets on purpose.* `toClose`, unclosed: closing awaits session kill ladders, and the caller does it afterFix: Build commands from fixed arguments; do not interpolate untrusted strings into a shell.// the same defect as the `chmod 600` this whole change replaced.Fix: Request the narrowest permission that works; replace wildcards with the exact paths or hosts.`Required: 0600. Run: chmod 600 ${filePath}`);Fix: Request the narrowest permission that works; replace wildcards with the exact paths or hosts.* takes both `-r` and `-R`, while chmod and chown take only `-R` — lowercaseFix: Request the narrowest permission that works; replace wildcards with the exact paths or hosts.// (`curl -d @/etc/passwd`), and write remote files (`curl -o`, `wget -O`) —Fix: Stop enumerating or reading credential locations; take secrets from a declared environment variable the user sets on purpose.* Render a CommandResult for the client. A non-zero exit (or a kill signal)Fix: Build commands from fixed arguments; do not interpolate untrusted strings into a shell.Cost to the agent
Tokens estimated at four characters each. The tool list is re-read by the agent on every turn, so it is the number to compare between servers that do the same job: ranked across the index.
Known vulnerabilities
No advisories in OSV for ssh-mcp@2.26.0 as of 38 h ago. This is the package itself, not its dependencies.
What would make this Verified
17 points to 80 · weights: behavior 45%, static 25%, prompt 15%, publisher 15%- Reads credential files≈ +27 on the totalThe sandbox saw reads under ~/.ssh, ~/.aws, ~/.npmrc, ~/.config or similar. Read only files inside the project, and only the ones the task needs. Seen: ~/.config/ssh-mcp/config.toml.
- Destructive command against a filesystem root or device≈ +9 on the totalBuild commands from fixed arguments; do not interpolate untrusted strings into a shell.
- Pipe-to-shell pattern≈ +9 on the totalBuild commands from fixed arguments, never from interpolated strings; prefer an API client over shelling out.
- Reads credential paths from the home directory≈ +9 on the totalStop enumerating or reading credential locations; take secrets from a declared environment variable the user sets on purpose.
- Unclaimed publisher≈ +8 on the totalClaim tufantunc: a verified publisher gets +50 on publisher trust. It is free and usually instant with GitHub sign-in.
- Touches files outside the project≈ +7 on the totalKeep reads and writes inside the working directory, or declare the exact paths in `filesystem:` and explain why in the README.