Is SSH ProxyCommand security weaker than it looks?
SSH ProxyCommand security requires reviewing local shell execution, expansions, includes, and jump-host routing before agents reach a remote host.

SSH ProxyCommand is local code execution that happens before the remote command you meant to run. That sounds obvious once stated, yet reviews routinely treat it as a transport detail: the agent asks for ssh host uptime, the reviewer checks the host and remote command, and the client quietly launches a local shell command that nobody included in the approval decision.
That gap matters more when an autonomous coding agent can invoke SSH. The remote destination may be tightly scoped while the local SSH configuration contains aliases, includes, conditional rules, a proxy helper, and a jump-host chain accumulated over years. A safe review starts with the client-side action SSH performs, then works outward to the network.
ProxyCommand runs before the remote session exists
ProxyCommand tells the SSH client to start a local command and use its standard input and output as the transport to the SSH server. The command is not executed on the destination. It runs on the machine that launched ssh, under that local account, before SSH can authenticate the intended server.
A typical entry looks harmless:
Host build-private
HostName build.internal.example
ProxyCommand /usr/local/bin/connect-private %h %p
When someone runs ssh build-private, SSH expands %h and %p, then starts /usr/local/bin/connect-private build.internal.example 22. The helper may open a socket, call another client, read a token file, load a library, consult environment variables, or run a shell script. The SSH client sees only a byte stream after that point. Your approval prompt may describe an SSH connection, while the first meaningful action was a local program launch.
The OpenSSH ssh_config manual is direct about the boundary: ProxyCommand specifies the command used to connect to the server, and it warns that the command is executed using the user's shell. That last clause changes the review. A command line is not an argument array. Shell parsing, expansion, redirection, command substitution, and the shell's startup environment belong to the action.
Do not wave this away because the helper lives in a dotfiles repository. A reviewed configuration from six months ago can call a binary found through PATH, source a file in a writable directory, or depend on an alias that now resolves differently. The review target is the resolved execution path on the machine that will run it.
A host alias can select much more than a host
SSH configuration is a matching system, not a simple dictionary. A short alias can trigger settings from system configuration, user configuration, included fragments, wildcard hosts, canonicalization, and conditional blocks. The final command may come from a file nobody associates with the alias.
Start by enumerating the configurations SSH reads. On a common OpenSSH client, that includes /etc/ssh/ssh_config, files reached through Include, and ~/.ssh/config. Distribution packaging and command-line options can alter the list, so inspect the invocation as well. A caller that supplies -F /some/file has changed the entire review surface.
Use ssh -G to print the resolved configuration for a concrete destination. For a controlled test alias, this is a useful artifact:
ssh -G build-private | grep -E '^(hostname|user|port|proxycommand|proxyjump|identityfile) '
The output has this shape:
hostname build.internal.example
user deploy
port 22
proxycommand /usr/local/bin/connect-private %h %p
identityfile ~/.ssh/id_deploy
ssh -G exposes what SSH selected, which catches a surprising number of mistakes: a broad Host * entry, an old include, or an alias that maps to a different account than expected. It does not prove that the helper is safe. It also may not tell you every condition that led there in a way a reviewer can understand, so follow each relevant directive back to its source file.
Treat host input as data only if the configuration keeps it data. If an agent can submit arbitrary aliases, then it can choose any matching Host stanza the local account can read. If it can submit arbitrary -o options or a configuration path, it can often bypass the alias review entirely. A wrapper that accepts a free-form SSH command is not a meaningful boundary.
A better interface accepts a destination identifier from a small allowlist and maps it to a fixed account, hostname, port, and connection method. The identifier can be production-readonly; the underlying SSH arguments should not come from an agent-generated string.
Shell expansion turns configuration into an input boundary
Because SSH passes ProxyCommand through a shell, quotes in the configuration do not create the same safety property that structured process arguments create. Shell syntax survives until the shell processes it. That includes spaces, glob characters, variable references, redirects, and command substitutions when they appear in the command text.
The risky pattern is a helper that builds its own shell command from values it receives. Consider this configuration:
Host *
ProxyCommand sh -c 'relay --target %h --port %p --token "$RELAY_TOKEN"'
It has several separate questions. Does every possible resolved host name contain only an expected hostname syntax? Does relay parse --target as one value? Can a user-controlled environment set RELAY_TOKEN or alter the search path? Does the outer shell receive a command string with characters that change meaning before relay starts? Saying that the hostname came from SSH does not answer them.
Percent tokens are not a safe templating system. OpenSSH expands tokens such as %h, %p, %r, and %n in many options. %n is the original host argument, while %h is the hostname SSH will use after configuration processing. That distinction is easy to miss. A proxy command that uses %n may receive a human-friendly alias or arbitrary requested text where the author expected a canonical DNS name. A command using %h can still see a value altered by HostName or canonicalization.
The fix is usually less clever than the original setup. Use a small dedicated helper with a fixed executable path, give it a restricted syntax, and make it reject anything outside that syntax. Have the helper call connection APIs or an argument-array process launcher instead of assembling another shell line. If a shell script is unavoidable, validate inputs before interpolation, quote each expansion for that shell, and keep the accepted set narrow enough that an auditor can test it.
Do not substitute eval for parsing. Teams reach for it because it makes a configuration-driven wrapper seem flexible. It also makes one missed quote a local execution defect. Flexibility belongs in a reviewed configuration format, not in a shell that reparses text.
ProxyJump removes one shell but not the trust problem
ProxyJump, often written as -J or ProxyJump, asks SSH to reach the destination through one or more SSH jump hosts. For ordinary bastion routing, prefer it over a hand-written ProxyCommand that merely launches another ssh -W command. It describes the intended topology and avoids placing that topology in a custom shell command.
For example:
Host build-private
HostName build.internal.example
User deploy
ProxyJump bastion-admin
Host bastion-admin
HostName bastion.example
User relay
This is easier to inspect than a string with nested quoting. It still needs careful review. The client authenticates to the jump host, the jump host participates in the route, and its name, account, host key, MFA requirements, forwarding rules, and network reachability affect the action. A compromised or misconfigured jump host can expose traffic patterns and can redirect a connection attempt. Host key verification remains mandatory at every SSH hop.
OpenSSH permits both settings, but ProxyCommand takes precedence over ProxyJump when both apply. That is a nasty configuration surprise. A reviewer may see a clean ProxyJump in a host-specific block while a broad earlier or later matching rule supplies a proxy command. Verify the effective result with ssh -G instead of inferring it from one stanza.
Multiple jump hosts also need an explicit reason. Do not treat a chain as extra security by default. Each added host is another credential use, host-key decision, and audit location. Use a chain only when the network path requires it, and record which account is expected at each hop.
Include and Match exec can execute local logic during selection
Include makes SSH configuration composable, which is useful until a repository, configuration-management tool, or installer drops a fragment into a broadly matched directory. OpenSSH expands glob patterns in Include; an unexpected file can therefore change the settings for a host without touching the main configuration. Review included directories for ownership and write permissions, not only their present contents.
Match adds conditions. Match host, user, localnetwork, and related conditions change which options apply. Match exec deserves a brighter warning: SSH runs the stated local command and uses its exit status to decide whether the block matches. Reading configuration can therefore invoke a local executable.
This example makes the boundary visible:
Match exec "/usr/local/bin/on-corporate-network"
ProxyJump corp-bastion
The condition might be reasonable if the helper is a fixed, root-owned program with a simple result. It becomes fragile when it calls a shell, reads a writable project file, contacts a network service, or relies on PATH. Do not test unknown Match exec rules on a workstation full of active credentials just because you planned to run ssh -G; inspect the command first and use an isolated account or machine when needed.
The OpenSSH manual also documents token expansion in matching and command-related options. That means a Match exec condition can depend on requested host or user text. Keep agent-selected values out of those conditions. If the condition needs contextual data, derive it from a trusted local state file with strict ownership, not from a string that crossed an agent boundary.
The incident usually starts with a convenience helper
A realistic failure does not need a malicious-looking configuration. Imagine a developer adds this years ago to reach a private test network:
Host test-*
ProxyCommand connect-testnet %h %p
connect-testnet was once installed by a bootstrap script in a personal bin directory. The script calls a company VPN client, then launches a relay command found on PATH. Later, a build agent runs under the developer's account. It can ask for ssh test-cache. The expected action is a read-only command on a test machine.
First, SSH matches test-*. Second, the local shell starts connect-testnet. Third, the script starts the VPN client and whatever executable currently wins the PATH lookup. Only after those local steps does SSH begin its protocol with the named host. An audit that records only the remote command loses the behavior that changed the local machine's state and selected the network route.
The defect is not that shell scripts are forbidden. The defect is treating a local convenience chain as if it were part of the remote service. The agent's ability to choose test-cache selected local code. A malicious agent might look for aliases that match broader patterns, but an ordinary agent can trigger the same issue by guessing a name that happens to exist.
Fix the actual path. Replace the wildcard with named destinations where practical. Put the relay helper at an absolute path. Remove PATH lookups inside it. Make the helper accept only the expected host and port values. Move VPN connection management out of a per-request proxy command if it does not need to run for every SSH action. Then test the final configuration as the account and environment that the agent will use.
Approve the connection method, not only the destination
An approval flow must show enough information for a person to recognize the action. ssh deploy@build-private is incomplete context when the alias launches a proxy helper or routes through a bastion. The reviewer needs the resolved destination, account, port, proxy method, and jump path. Otherwise they approve a label and hope the local configuration still means what it meant last week.
Separate three decisions that teams often blur. First, may this agent process request any SSH action? Second, may it use this specific SSH identity? Third, may this destination use this particular local connection method? A yes to the first two does not automatically answer the third. The proxy helper might reach a different trust zone or cause local side effects that the direct connection would not.
Sallyport keeps SSH credentials out of the agent and uses its bundled sp-ssh helper for SSH actions, but that does not turn a local SSH configuration into a safe policy surface. Keep the agent's destination interface narrow, and inspect any client configuration or helper process that participates before the credentialed action begins.
For sensitive paths, require per-use approval for the SSH identity and make the approval description include the destination and route. That catches a changed alias, an unexpected bastion, or an attempted use outside the normal maintenance path. It will not make a vague approval card useful. The information has to be there.
Test the resolved path with disposable credentials
A configuration review needs an execution test, but run it with credentials that cannot damage production. Use a test host, a temporary account, and a controlled environment. Capture the client-side process tree and network destinations if your operating system allows it. The aim is to confirm which executable launches, which arguments it receives, and whether it creates connections beyond the expected route.
A compact review sequence is enough for most host aliases:
- Run
ssh -G aliasand save the resolvedhostname,user,port,proxycommand,proxyjump, and identity settings. - Trace every
Includeand every matchingHostorMatchblock that supplied those values. - Read each local executable in the proxy or match path, including scripts and their configuration files.
- Run the connection with disposable credentials and verify the actual jump hosts, host keys, and local processes.
- Revoke the test credential after the test and record the expected route beside the alias.
Do not rely only on a successful connection. Success proves that bytes reached an SSH server. It does not prove that the right program carried them, that the intended host received them, or that no local side effect occurred first.
A little friction is appropriate here. If nobody can explain the executable behind a ProxyCommand, nobody should authorize an agent to invoke the alias. Replace it, remove it, or keep the route outside the agent's reach until someone owns it.
A small SSH surface beats clever client configuration
The safest agent-facing SSH setup has few named destinations, fixed identities, explicit jump hosts, and no arbitrary configuration flags. It does not need a general-purpose policy language to achieve that. It needs a limited interface and a configuration that remains boring under inspection.
Review again when any of these change: an SSH client update, a new bootstrap script, a configuration-management rollout, an added include directory, a new agent runtime, or a new jump host. These changes often land in separate repositories, which is exactly why the connection path decays without anyone noticing.
Keep the final standard simple: before a remote command runs, you should be able to name every local executable SSH starts, every host it contacts, every identity it uses, and the person who approved that route. If you cannot do that for an alias, it is not ready for autonomous use.
FAQ
Does ProxyCommand run on the local machine?
No. SSH starts ProxyCommand on the client before it has a transport to the destination. Treat the command and every program it reaches as local execution in the identity of the person or agent running SSH.
How can I see the ProxyCommand SSH will use?
Use ssh -G alias to inspect the resolved setting for a named host, then read every matching config file yourself. Run the inspection from a controlled test account when the configuration uses Match exec, because that condition can execute a local command while SSH reads configuration.
Is ProxyJump safer than ProxyCommand?
Usually no. ProxyJump expresses an SSH hop without handing a shell command to your local shell, so it has fewer quoting and expansion hazards. It still creates a trust boundary at the jump host and needs the same host-key and account review.
Can an SSH host alias cause command injection?
Yes, when the shell sees attacker-controlled text as part of the ProxyCommand command line. Host aliases, canonical hostnames, usernames, port values, environment variables, and included configuration can influence the final command unless you constrain them deliberately.
What security risk does a jump host add to SSH?
A jump host can observe and relay the byte stream, and it can become the place where its own SSH credentials, configuration, or forwarding rules matter. It should not silently become a general administration host simply because it is convenient.
Why is Match exec risky in ssh_config?
Match exec lets SSH run a local command to decide whether later configuration applies. It is useful for narrow, trusted conditions, but it turns configuration reading into a local code-execution path and deserves the same review as a helper script.
Should I use absolute paths in ProxyCommand?
Absolute paths prevent a changed PATH from selecting a different helper, but they do not make that helper safe. You still need to check its permissions, arguments, configuration files, and the identities it contacts.
Can I safely let an AI agent use SSH?
Do not let the agent provide arbitrary SSH arguments, host aliases, or configuration paths. Give it a small set of reviewed destinations and keep local connection mechanics outside its input surface.
Does an SSH credential vault make ProxyCommand safe?
Agent authorization and secret isolation control who may request an action and who sees credentials. They do not inspect a local SSH configuration for shell expansion, Include, or executable helpers, so configuration review remains necessary.
What is the first thing to audit in an SSH client configuration?
First, enumerate the host aliases an agent can reach and run ssh -G for each in a controlled environment. Remove custom ProxyCommand entries you cannot explain, then replace the remaining ones with reviewed absolute-path helpers or ProxyJump where it fits.