Server · #206 trending MCP repo
Ssh MCP
tufantunc/ssh-mcp
MCP server exposing SSH control for Linux servers via Model Context Protocol.
- Stars
- 862
- This week
- —
- Forks
- 134
- Open issues
- 1
- Latest release
v2.23.1
Get started
Add it to your agent
claude mcp add --transport stdio ssh-mcp -- ssh-mcpRun in your terminal. Add --scope project to share it with your team through .mcp.json.
codex mcp add ssh -- npx ssh-mcp --host=1.2.3.4 --user=root --password=hunter2Saved to ~/.codex/config.toml.
{
"mcpServers": {
"ssh": {
"command": "npx",
"args": [
"ssh-mcp",
"--host=1.2.3.4",
"--user=root",
"--password=hunter2"
]
}
}
}Add to ~/.cursor/mcp.json (or .cursor/mcp.json in a project).
{
"servers": {
"ssh": {
"type": "stdio",
"command": "npx",
"args": [
"ssh-mcp",
"--host=1.2.3.4",
"--user=root",
"--password=hunter2"
]
}
}
}Add to .vscode/mcp.json, then start it from the MCP view in Copilot Chat.
gemini mcp add ssh npx ssh-mcp --host=1.2.3.4 --user=root --password=hunter2Saved to .gemini/settings.json.
Documentation
README
SSH MCP Server v2
SSH MCP Server is a security-first Model Context Protocol server that gives LLM agents controlled SSH access to remote hosts — with command classification, policy-based authorization, human-in-the-loop approval, and full audit logging.
The risk this server exists to manage. Giving an LLM shell access on a remote host puts private data, untrusted input and network egress in one place — Simon Willison's "lethal trifecta". Prompt injection has no general fix, so ssh-mcp assumes any command may be attacker-influenced: it classifies before executing, authorizes against a role × host-group matrix, gates destructive work behind approval, and records the decision either way. That narrows the blast radius; it does not remove the risk. Two things stay yours: never point it at a root account, and never set
autoapproval on a production profile. SECURITY.md has the full threat model.
Quick Start
1. Install
npm install -g ssh-mcp
2. Configure
Without a config the server still starts, so a client or directory can complete the MCP
handshake and read tools/list — but every tool call is refused until you configure it,
with a message naming the path below. Nothing runs on a host until this step is done. The
one exception is reload-config: write the file, then call it with action: "reload" (or
restart the server) to configure a server that is already running — see
Reloading.
Create the config file at the path for your platform:
| Platform | Path |
|---|---|
| Linux | ~/.config/ssh-mcp/config.toml (or $XDG_CONFIG_HOME/ssh-mcp/config.toml) |
| macOS | ~/Library/Application Support/ssh-mcp/config.toml |
| Windows | %APPDATA%\ssh-mcp\config.toml |
[defaults]
defaultProfile = "dev"
approvalMode = "ask-destructive"
[[profiles]]
name = "dev"
host = "192.168.1.100"
port = 22
user = "deploy" # NOT root!
auth = "key"
keyRef = "~/.ssh/id_ed25519"
role = "admin"
approvalPolicy = "auto" # dev is permissive
chmod 700 ~/.config/ssh-mcp && chmod 600 ~/.config/ssh-mcp/config.toml
The config decides which hosts, roles and policy rules this server honours, so it checks that nobody but you can read it — and treats the two platforms differently, because the question has a much clearer answer on one of them.
Linux and macOS: enforced. The mode check above, on the file and the directory —
which is why chmod 700 is in that command, since mkdir -p under the default umask
leaves the directory 0755. The server refuses to start otherwise. "Only the owner" is
unambiguous here and chmod is a one-line fix.
Windows: split by what the ACL actually allows. There are no mode bits, so the ACL is read instead — and read exposure and write exposure are not treated alike, because Windows is much clearer about one of them than the other.
| The ACL lets another account… | Default |
|---|---|
| only read the config | reported, and the server starts |
| change the config | refused |
| nothing (no ACL at all) | refused — that is full control for everyone |
| …and if the ACL could not be read | refused, except when icacls is absent or the check timed out |
A config under %APPDATA% inherits access for you, SYSTEM and Administrators and needs
nothing done to it. One created elsewhere does not: a file under C:\ inherits read for
every local account and modify for every authenticated one. The message names the two
icacls commands that fix it either way.
Read exposure is reported rather than refused because that is where Windows is genuinely muddier than POSIX, and refusing over it blocked a config at the documented location (#138). Write exposure is refused because it is not muddy at all: another account being able to rewrite the file that decides which hosts, roles and approval policy this server honours is an authorization bypass, not a disclosure.
Two flags move the whole thing: --strictConfigAcl refuses everything the check objects
to, read-only grants included; --allowUncheckedConfigAcl reports everything and refuses
nothing. Neither combination leaves you without an exit, which is the lesson of #138.
Exit statuses
| Status | Meaning |
|---|---|
0 |
Clean shutdown |
1 |
A defect in the server — printed with a stack trace; please report it |
2 |
How it was invoked or configured — printed as a message, no stack |
A supervisor that treats any non-zero status as a failure needs no change. One
that matched on 1 to detect a startup problem should match on 2 as well.
Starting with nothing configured is not an exit-2 condition, as of the release that
added introspection without a config: the server starts so it can be described, and
refuses each tool call instead, except reload-config. A supervisor that used a non-zero
exit to catch an unconfigured deployment should watch for starting unconfigured on
stderr, or read configured from GET /health when running the HTTP transport.
3. Set credentials via environment variables
export SSH_MCP_PASSWORD="your-password" # if using auth=password
# OR use SSH agent (recommended):
export SSH_AUTH_SOCK="$SSH_AUTH_SOCK" # already set if agent running
4. Connect from your MCP client
Claude Code:
claude mcp add --transport stdio ssh-mcp -- ssh-mcp
Claude Desktop / Cursor / Windsurf:
{
"mcpServers": {
"ssh-mcp": {
"command": "ssh-mcp",
"env": {
"SSH_MCP_PASSWORD": "your-password"
}
}
}
}
Never pass passwords as CLI arguments — they're visible via ps aux. Use env vars, config files, SSH agent, or OS keychain.
Tools (15)
| Tool | Purpose | readOnly | destructive |
|---|---|---|---|
list-connections |
Discover available hosts and connection status | ✅ | — |
list-sessions |
List active sessions per host | ✅ | — |
open-session |
Create a named interactive (stateful) or background session | — | — |
close-session |
Close a session. A background session's command is signalled (INT/TERM/KILL) before its channel is dropped | — | ✅ |
read-session-output |
Read output from background sessions (e.g., tail -f) |
✅ | — |
read-command |
Execute allowlisted read-only commands (ls, cat, grep, ...) |
✅ | — |
run-command |
Execute arbitrary commands (destructive/privileged need approval, unless approvalPolicy = "auto") |
— | — |
privileged-command |
Execute with sudo (needs approval, unless approvalPolicy = "auto") |
— | ✅ |
sftp-upload |
Upload a file via SFTP (replaces the destination unconditionally) | — | ✅ |
sftp-download |
Download a file via SFTP | ✅ | — |
sftp-list |
List a remote directory, bounded in entries and bytes | ✅ | — |
sftp-upload-file |
Stream a local file to the remote host, never through model context | — | ✅ |
sftp-download-file |
Stream a remote file to local disk, never through model context | — | ✅ |
signal-process |
Send INT/TERM/KILL to a remote PID | — | ✅ |
reload-config |
Show the config file's status, or re-read and apply it without a restart (see Reloading) | — | ✅ |
Streaming file transfer
sftp-upload/sftp-download move file contents through the model's context:
the text is an argument on the way out and a response on the way back. That is
what you want for a config snippet and exactly what you do not want for a 200 MB
tarball or anything binary.
The two also differ on what they do to an existing destination, and the approved
string now says which is which. sftp-upload replaces unconditionally and spells
--overwrite every time, because that is what it always does; sftp-upload-file
refuses unless you pass overwrite: true, and only then carries the flag. Since
sftp-upload takes its content as an argument rather than naming a local file,
its string also carries --bytes=<n> --sha256=<32 hex> — without that, two
uploads to the same path are the same string, which means one approval covers
both and an auditor cannot tell which set of bytes landed. The digest is 128
bits rather than a short prefix because that claim has to hold against a caller
who picks both payloads, not only against an accidental repeat.
[policy].denylist patterns are tested against that whole string and, for every
SFTP tool, against the remote path on its own — as given, lexically normalized,
and read the way Windows reads it (see "Policy Engine"). A rule written for the
path, such as authorized_keys$, therefore does not depend on where the string
puts it or on how the file is spelled. A rule anchored on the whole string
(^sftp:upload /root/.*$) still does: that layout is ours to change, so anchor
on the path instead.
sftp-upload-file and sftp-download-file stream between the remote host and
local disk instead. Neither the bytes nor a base64 encoding of them ever reaches
the model — the response is a byte count and two paths.
The two transfer tools are off until you configure defaults.transferRoot,
and refuse with an explanation until then. (sftp-list has no local side and
needs none of this.) That directory is the whole of their local reach:
[defaults]
transferRoot = "/srv/ssh-mcp-transfers"
transferMaxBytes = 268435456 # 256MB per transfer
transferTimeoutMs = 300000 # 5min with no bytes moving
Checked on every call, and refused rather than degraded if any of it fails: the
directory must be 0700 and owned by the account running this server, with no
group- or world-writable parent, and must not overlap the ssh-mcp installation,
the config directory, the audit log directory, or ~/.ssh. Caller paths are
confined to it, symlinks are refused rather than followed, and a download is
staged as a sibling .part file and published atomically, so a failed transfer
never leaves a half-written file at the destination.
transferTimeoutMs is an idle budget, not a total one: it bounds one
metadata round-trip, or one stretch of the copy with no bytes moving, and is
re-armed on progress. A slow but live transfer of a large file survives it; a
stalled channel still fails within one window. That is why the byte cap above
does not have to be divided by it — a total budget would have made a 256MB cap
mean "only if the link sustains 900 KB/s".
Not available on Windows, where the transfer root cannot yet be verified private; the two transfer tools refuse there rather than writing into a directory other accounts may be able to read.
sftp-upload-file takes an optional mode (1–511; setuid, setgid and the
sticky bit are refused). Omitted, a new remote file is published 0600; an
overwritten one inherits the replaced file's permission bits — its permission
bits only, not its setuid or setgid.
What a transfer is authorized for includes these arguments: the string the
policy engine classifies, the approval prompt a human reads, and the audit
record all name --overwrite and --mode when they are given. So approving one
upload to a path does not approve a different one to the same path, and an
approval grant (approvalGrantTtlMs) cannot be replayed with a different mode.
Interactive Sessions
Sessions maintain state (CWD, environment variables) between commands:
Agent: open-session(name="deploy", type="interactive")
Agent: run-command(session="deploy", command="cd /opt/myapp")
Agent: run-command(session="deploy", command="git pull") # runs in /opt/myapp
Agent: run-command(session="deploy", command="npm ci") # CWD persists
Agent: close-session(name="deploy")
Background Sessions
Long-running processes (logs, builds):
Agent: open-session(name="logs", type="background", command="tail -f /var/log/syslog")
Agent: read-session-output(name="logs", lines=20) # poll
Agent: close-session(name="logs")
Remote host support
Tested against Linux (Debian/bash, Alpine/busybox ash), Dropbear, and Windows OpenSSH on Windows 11.
| Linux / BSD / macOS | Windows OpenSSH | |
|---|---|---|
read-command, run-command, privileged-command, signal-process |
✅ | ✅ |
sftp-upload, sftp-download |
✅ | ✅ |
sftp-list |
✅ | ✅ |
sftp-upload-file, sftp-download-file |
✅ | ❌ (local side unverifiable) |
| Background sessions | ✅ | ✅ |
| Interactive sessions | ✅ | ❌ |
Interactive sessions require a POSIX shell (sh, bash, ash, zsh). They work by
bracketing each command with printf markers and reading $? and $PWD from a
trailer — none of which exist in cmd.exe, the default shell for Windows
OpenSSH. Opening one against such a host fails immediately with an explicit
error rather than timing out; everything else works normally.
Setting PowerShell as the OpenSSH DefaultShell does not help: the protocol is
POSIX-specific, not merely non-cmd.
Configuration
Profile options
[defaults]
defaultProfile = "dev"
sessionMaxPerConnection = 5
sessionIdleTimeoutMs = 600000 # 10min
sessionBackgroundMaxMs = 3600000 # 1hr
commandTimeoutMs = 60000
commandMaxChars = 5000 # 0 = unlimited, the config spelling of --maxChars=none
commandMaxOutputBytes = 1048576 # 1MB
connectionIdleReapMs = 900000 # 15min
commandQuotaPerDay = 0 # 0 = unlimited; circuit breaker for runaway agents
approvalGrantTtlMs = 0 # 0 = always prompt; see "Approval Grants"
approvalMode = "ask-destructive" # auto | ask-destructive | ask-all | deny
# transferRoot = "/srv/ssh-mcp-transfers" # enables the streaming file tools; see above
transferMaxBytes = 268435456 # 256MB per streaming transfer
transferTimeoutMs = 300000 # 5min with no bytes moving (idle, not total)
[[profiles]]
name = "prod-web-1"
host = "10.0.1.50"
port = 22
user = "deploy"
auth = "agent" # agent | key | password | keychain
keyRef = "~/.ssh/id_ed25519" # for auth=key
keychainEntry = "ssh-mcp/prod" # for auth=keychain (requires @napi-rs/keyring)
via = "bastion" # ProxyJump — route through bastion profile
group = "prod" # Policy tier: prod | staging | dev, or your own (see [policy])
workdir = "/var/www"
trustedHostKey = "SHA256:..." # Pin host key (optional)
tty = false
role = "operator" # viewer | operator | admin
readOnly = false
approvalPolicy = "ask-all"
cert = false # SSH CA cert auth — auto-detects keyRef-cert.pub
announceAgent = true # Send AI_AGENT=ssh-mcp to this host (see below); clear it for a host you do not control
sessionMaxPerConnection = 3 # per-profile override
sessionIdleTimeoutMs = 300000 # stricter for prod
commandQuotaPerDay = 200 # per-profile override
maxChars = 2000 # per-profile override; stricter for prod
transferMaxBytes = 16777216 # per-profile override; transferRoot is not per-profile
transferTimeoutMs = 60000 # per-profile override
# Optional. Merged over the built-in role matrix; see "Policy Engine" below.
# roleBindings is keyed by role and then by tier, so the block below changes
# operator on prod and leaves operator's other tiers, and viewer and admin,
# on their defaults.
[policy]
denylist = ["^terraform\\s+destroy", "\\.ssh/authorized_keys$"]
[policy.roleBindings.operator]
prod = ["read-only", "safe", "destructive"]
Unknown sections and keys are a startup error, not a warning, so a typo cannot
leave you running defaults you thought you had overridden. That extends to role
and tier names: every one you write under [policy.roleBindings] has to be
reachable by some profile, and every profile's role and tier has to resolve to
real bindings. Both directions are checked at startup and on every reload.
Every profile needs a name of its own. Two profiles with the same name are a startup
error, and a reload refuses them too; the error names the two entries by index
(profiles[2].name repeats profiles[0].name), not by the name. Before this release such a
config started, and the first of the two was used.
Host-side identification
Every command this server runs announces itself to the host as the environment
variable AI_AGENT=ssh-mcp, so an operator can tell an agent's session from a
person's from anything on the host that reads the session environment — a
/etc/profile snippet or a ForceCommand wrapper, for instance. It covers all
three channels a command can run on: one-shot commands, background sessions and
interactive sessions. SFTP transfers do not carry it, because the SSH library
this server uses offers no way to send an environment request on a subsystem
channel.
The variable only lands in the session if the host opts in with
AcceptEnv AI_AGENT in sshd_config. Without that line sshd ignores it and the
session environment is unchanged — measured against Dropbear, which has no
AcceptEnv mechanism at all, and against Windows OpenSSH on a default
configuration: the command runs, the exit code is unchanged, the variable is
simply absent.
The request is nevertheless sent to every host, opted in or not. AcceptEnv
is the server's policy about what it stores; it is not a gate on what this client
transmits. A host that has not opted in — including one that is hostile — can
still see on the wire that an agent rather than a person is driving the session.
That is why announceAgent = false exists on the profile: set it for any host you
do not control and nothing is sent to it. It is on by default, because the
operators who benefit are the ones running the hosts you do control.
No version is sent, deliberately. A version would narrow a hostile host to one specific build; the tool's name alone does not. (The SSH transport already discloses the library version in its identification string, which is one library's version across every program that uses it.)
Treat the variable as a courtesy label, not an attestation. Any SSH client can set it and any agent can omit it, so it is useful for attributing sessions you already trust and must not be used as an authorization or intrusion-detection input. Authoritative attribution belongs to the key or principal that authenticated.
ProxyJump (Bastion)
Reach internal hosts behind a bastion/jump server. The via field specifies a profile name to tunnel through:
[[profiles]]
name = "bastion"
host = "bastion.example.com"
user = "deploy"
auth = "agent"
[[profiles]]
name = "internal-db"
host = "10.0.1.50" # private IP — not directly reachable
user = "dbadmin"
auth = "key"
keyRef = "~/.ssh/db_key"
via = "bastion" # tunnel through bastion
No agent forwarding — only a TCP tunnel via forwardOut. The bastion stays connected and reusable for multiple internal hosts.
SSH CA Certificates
For enterprise setups with a central SSH Certificate Authority:
[[profiles]]
name = "prod-db"
host = "db.internal"
user = "admin"
auth = "key"
keyRef = "~/.ssh/id_ed25519"
cert = true # enable CA cert auth
The certificate file is auto-detected using OpenSSH convention (keyRef + -cert.pub, e.g. ~/.ssh/id_ed25519-cert.pub). You can override the path with SSH_MCP_<NAME>_CERT env var. The cert is concatenated with the private key per ssh2 convention.
Credential Resolution Order
- SSH agent (
SSH_AUTH_SOCK) — no key material in process memory - OS keychain (macOS Keychain / Windows Credential Manager / Linux Secret Service) — requires
auth = "keychain"and@napi-rs/keyring - Environment variables —
SSH_MCP_PASSWORD,SSH_MCP_KEY,SSH_MCP_SUDO_PASSWORD, or profile-specificSSH_MCP_<NAME>_PASSWORD - Key file —
keyRefpath orSSH_MCP_KEYenv var
Never CLI arguments. v2 removes --password, --sudoPassword, --suPassword entirely.
Reloading
The config file is read at startup, and again only when you ask: through the
reload-config tool with action: "reload", or by sending the process SIGHUP
(kill -HUP <pid>) on every platform but Windows. On Windows SIGHUP means the console
is closing, so it does not reload there; use the tool. Nothing watches the file, so a
half-written file never becomes live policy. A reload reads the same path the server
started with: --config, or the default path for your platform.
reload-config takes one argument, action:
"status"(the default) reports the config path, where the live config came from (file,unconfiguredorflags), when it was loaded, the sha256 of the bytes it was parsed from, and the result of the last reload. It never shows the file's contents."reload"re-reads the file, applies it if every check passes, and reports what changed: profiles added, removed and changed (by name and field), whether the policy rules changed, and which connections it closed, why, and with which sessions. The same report goes to stderr.
The tool asks for no approval and spends no command quota. A caller can only make the server re-read what the operator wrote; the file is the operator's consent.
All or nothing. A reload runs the same checks as startup on the new file before anything
changes: the permission check (mode bits on Linux and macOS, the ACL on Windows, with the
--strictConfigAcl or --allowUncheckedConfigAcl the server started with), the TOML parse,
the strict schema, the check that no two profiles share a name, the role and tier
cross-check, and compiling the denylist. A file with no
profiles fails the schema, as at startup, so a reload cannot leave a server unconfigured.
Two more refusals belong to reloading alone:
defaults.transferRootchanged or removed. A transfer in flight holds the old root, so this needs a restart. Adding atransferRootwhere there was none is allowed: with no root there can be no transfer in flight.- The server was started from
--host/--userflags. Swapping the flag-built profile for whatever file sits at the default path would change where the server's trust comes from without the operator choosing it. Restart with a config file instead.
A refused reload changes nothing: the config, the policy, connections, sessions and approval grants stay as they were. The refusal names the error class and the path, and for a schema error the field paths and issue codes; it never quotes a value from the file.
What applies when. Once every check passes, the new config replaces the old in one step, so no tool call sees part of each. For each open connection, by profile name:
| Changed on the profile | What happens |
|---|---|
role, group, approvalPolicy, readOnly, commandQuotaPerDay, maxChars, transferMaxBytes, transferTimeoutMs, or the [policy] section |
The connection stays open; the change applies from its next command |
workdir, tty, timeout, maxOutputBytes, announceAgent, sessionMaxPerConnection, sessionIdleTimeoutMs, sessionBackgroundMaxMs |
The connection stays open; the new values apply to new sessions and commands, and the report names these fields |
host, port, user, auth, keyRef, keychainEntry, cert, via, trustedHostKey |
The connection closes with its sessions; the next call connects with the new profile |
| the profile was removed | The connection closes with its sessions |
A connection that runs through a bastion that is closing closes too, and so does anything
running through it in turn; dependents close before the bastion they run through. A
[defaults] value reaches every profile that does not set its own, so changing it counts as
a change to each of those profiles. defaultProfile, connectionIdleReapMs and
approvalGrantTtlMs apply at once. Command quota counts are kept, so a new
commandQuotaPerDay applies to what has already been spent. Host keys accepted on first use
are kept too, because the connection registry is updated in place rather than rebuilt. A
new connection that was still being opened when the reload landed is discarded only if the
reload closes its profile (removed, a connection field changed, or its bastion closing); that
call fails with "The configuration was reloaded while connecting; retry the call." Any other
is kept, with the profile as the reload left it, so reloading an unchanged file does not fail
other calls' connects.
Approvals. Every applied reload, even of an unchanged file, retires every approval grant
made before it, so the next run of a granted command asks again. An approval answered after
a reload landed is re-checked against the new rules: the command runs only if its connection
is still open and the new rules still allow it or ask for it, in the same command class.
Otherwise it is refused with CONFIG_RELOADED and no grant is recorded; retry the call to
be asked under the new rules. A decision that needed no prompt is re-checked the same way
when a reload lands while it is being made (while the connection is resolved, or during an
OPA round-trip): a command the old rules allowed, or a grant reuse, runs only if its
connection is still open and the new rules allow it outright, in the same class; otherwise
it is refused with CONFIG_RELOADED. The command quota is charged against the profile as
the reload left it.
Already running. A command or transfer that is running when the reload lands finishes
under the rules it started with, unless the reload closes its connection; the next call is
checked under the new ones. Removing a profile and reloading closes its connection, but that
stops less than it sounds: a background session's command is signalled INT, then TERM,
then KILL, while a one-shot command, or one running in an interactive session, is not
signalled at all and may keep running on the host. Stop such a command with
signal-process first, while the profile still reaches the host (see
SECURITY.md).
Never reloaded. Command-line flags keep the values the process started with: the
transport and its HTTP options, --hostKeyMode, the OPA, audit and OpenTelemetry flags, the
ACL flags and --config itself. The flags that stand in for [defaults] (--timeout,
--maxChars, --sessionMax, --sessionTtl, --commandQuota, --approvalGrantTtl) apply
only while no file is loaded, as at startup, so a server that started unconfigured takes its
defaults from the file once a reload loads one.
Audit. Every reload that runs writes one config:reload record, in one of three forms:
| Outcome | decision |
ruleId |
error |
|---|---|---|---|
| applied | allow |
config-reload |
unset |
| refused: a check failed, nothing changed | deny |
config-reload |
the refusal |
| failed after the swap began, so possibly partly applied | deny |
config-reload-partial |
the error class, and that it may be partly applied |
The other fields are fixed: command and binary are config:reload, commandClass is
safe, profile and user are -, and mcpRequestId is the tool call's request id, or
sighup for a reload the signal triggered. Each session a reload closes also writes a
session:close … (reload) record, as the close-session tool does. A connection that fails
to close is reported, not rolled back: the new config is already in effect.
Reloads run one at a time: one asked for while another is running waits for it and then
reads the file afresh, and any asked for while that one is still waiting join it and share
its report and its record, so a burst of requests or SIGHUPs costs at most two reloads.
If something fails after the checks have passed (a defect, not a refusal), the report says
the reload failed after the swap began and may be partly applied; check reload-config
status, and restart if it does not show the new config.
SIGHUP no longer stops the server. Before, its default action ended the process
without flushing the audit log. Now, everywhere but Windows, it triggers a reload and the
server keeps running. A stdio server still exits when its client closes stdin. An HTTP
server started from a terminal is no longer stopped when that terminal closes, since the
hangup it sends is now handled as a reload: stop it with SIGTERM or SIGINT, or run it
under a supervisor.
Policy Engine
Roles
| Role | Dev | Staging | Prod |
|---|---|---|---|
| viewer | read-only | read-only | read-only |
| operator | read-only, safe, destructive | read-only, safe, destructive | read-only, safe |
| admin | all | all | read-only, safe, destructive |
Which column applies comes from the profile's group. Set it explicitly —
without it the tier is guessed from the profile name (prod/staging/dev,
local, test, sandbox), and an unrecognised name resolves to prod,
the strictest tier. A production host named web-01 is therefore treated as
production rather than silently getting dev permissions.
Note what this means for sudo: admin has no privileged on prod, so
privileged-command is refused there by design — including on a quick-start
profile, which has no name to infer from and therefore lands on prod. If the
host is not production, say so:
npx ssh-mcp --host=10.0.0.5 --user=deploy --group=dev
[[profiles]]
name = "build-box"
group = "dev"
Configuring the matrix
The table above is the default, not a limit. An optional [policy] section is
merged over it at startup and on every reload, so granting sudo on a host you have honestly
labelled prod is a reviewable line in a config file rather than a relabelling:
[policy.roleBindings.admin]
prod = ["read-only", "safe", "destructive", "privileged"]
The merge is at role and tier depth. That block changes admin on prod and
nothing else: admin on staging and dev keep their defaults, and viewer
and operator are untouched. Roles and tiers the defaults have never heard of
are added rather than rejected, which is what makes a custom group resolve to
real bindings instead of falling back to the strictest tier:
[[profiles]]
name = "build-box"
role = "admin"
group = "tier-1"
[policy.roleBindings.admin]
"tier-1" = ["read-only", "safe", "destructive"]
Extra deny patterns live in the same section, and are applied on top of the never-allowed list rather than replacing it:
[policy]
denylist = ["^terraform\\s+destroy", "\\.ssh/authorized_keys$"]
Each pattern is a regular expression tested against the command string of every
call. For the five SFTP tools it is also tested against the remote path on its
own, three ways: as given; in a normalized reading, where // and /./
collapse, .. resolves against the segment before it, a trailing / is
dropped, and \ is read as /; and in a Windows reading, tested
case-insensitively. The refusal says which matched.
The Windows reading is what Win32 resolves a path to, measured over SFTP on
Windows 11 (build 26200): the case variants, a trailing run of dots and spaces,
and the ::$DATA default-stream spelling all reach the same file, so a rule
that caught one spelling and not another was a bypass, not a distinction. On
top of the normalized reading it strips those spellings, roots a
drive-relative path (C:x/…) at its drive — where it actually resolves
depends on process state this server cannot see, and rooting is the fail-closed
reading — drops \\?\ and \\.\ device prefixes, and keeps a UNC root
(\\server\share\… → //server/share/…). Tested case-insensitively, it also
catches Authorized_Keys with a rule written for authorized_keys; on a POSIX
target those are different files, but erring toward refusal is the direction
this engine takes everywhere else too.
What the readings do not do:
…
Releases