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-mcp

Run in your terminal. Add --scope project to share it with your team through .mcp.json.

Documentation

README

View on GitHub ↗

SSH MCP Server v2

NPM Version Downloads CI OpenSSF Scorecard OpenSSF Best Practices codecov License GitHub issues

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 auto approval 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

  1. SSH agent (SSH_AUTH_SOCK) — no key material in process memory
  2. OS keychain (macOS Keychain / Windows Credential Manager / Linux Secret Service) — requires auth = "keychain" and @napi-rs/keyring
  3. Environment variables — SSH_MCP_PASSWORD, SSH_MCP_KEY, SSH_MCP_SUDO_PASSWORD, or profile-specific SSH_MCP_<NAME>_PASSWORD
  4. Key file — keyRef path or SSH_MCP_KEY env 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, unconfigured or flags), 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.transferRoot changed or removed. A transfer in flight holds the old root, so this needs a restart. Adding a transferRoot where there was none is allowed: with no root there can be no transfer in flight.
  • The server was started from --host/--user flags. 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

Recent versions

All releases ↗
  1. v2.23.1
  2. v2.23.0
  3. v2.22.2
  4. v2.22.1
  5. v2.22.0
  6. v2.21.0