# Is SSH ProxyCommand security weaker than it looks?

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:

1. Run `ssh -G alias` and save the resolved `hostname`, `user`, `port`, `proxycommand`, `proxyjump`, and identity settings.
2. Trace every `Include` and every matching `Host` or `Match` block that supplied those values.
3. Read each local executable in the proxy or match path, including scripts and their configuration files.
4. Run the connection with disposable credentials and verify the actual jump hosts, host keys, and local processes.
5. 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.
