SSH config Match blocks and agent approval
Review SSH config Match blocks so agent approvals match the real host, user, key, ProxyJump route, and canonicalized destination.

An SSH approval is only meaningful when the approved connection is the connection SSH will make. That sounds obvious until an agent runs ssh prod, a host alias selects a different HostName, a Match block changes the remote user, and ProxyJump sends the session through a bastion nobody mentioned in the request.
Most SSH configuration mistakes are survivable when a person is watching a terminal. The person sees an unfamiliar host-key prompt, notices root@..., or remembers that prod means something different on the office network. An autonomous agent does not bring that kind of suspicion. It uses the alias it was given and follows the config exactly.
This is why SSH config Match blocks deserve a security review before an agent receives SSH access. The point is not to ban aliases, bastions, or conditional config. The point is to make the requested action, the effective SSH configuration, and the connection a human approves describe the same thing.
A host alias is an input, not an identity
ssh app-prod does not tell you where SSH will connect, which account it will use, which key it will offer, or whether traffic will first pass through another machine. It tells OpenSSH which configuration argument to begin with.
That distinction gets blurred because aliases make ordinary command-line work pleasant. A short name such as app-prod is easier to type than a fully qualified hostname with a specific user and nonstandard port. It is also easier to hand to an agent. But the alias is only a handle. Its meaning comes from every matching setting in the config files that SSH reads.
OpenSSH reads command-line options first, then the user's ~/.ssh/config, then the system-wide config. For most single-value options, the first value SSH obtains is the one it uses. The OpenSSH ssh_config(5) manual makes the practical consequence plain: place specific declarations early and general defaults later. A broad Host * block near the top can quietly defeat a later, more careful conditional rule.
Start with an inventory that records each alias an agent may use in terms a reviewer can check:
| Alias | Resolved destination | Remote user | Route | Identity intent |
|---|---|---|---|---|
staging-api | api-01.staging.example.net | deploy | direct | staging deploy identity |
prod-api | api-01.prod.example.net | deploy | prod-bastion | production deploy identity |
prod-breakfix | api-01.prod.example.net | ops | prod-bastion | incident-only identity |
Do not write production in the destination column. Write the actual target that SSH uses. Do not write default user. Write deploy, ubuntu, ec2-user, or whatever account the server receives. If the route has a jump host, name it. If the alias behaves differently on different networks, that needs its own row, because it is a different effective connection.
The useful distinction is this: an alias identifies a configuration entry; a destination identifies the remote endpoint. Conflating them causes bad approvals. A reviewer may agree to an agent opening a session to staging-api and still be wrong about the actual endpoint because the alias contains stale or conditional behavior.
This is also why aliases should describe an operational purpose, not hide it. prod-readonly, prod-deploy, and prod-breakfix make a reviewer pause in the right place. A single alias named prod that picks users, keys, and routes through conditional blocks saves a few keystrokes and creates a permanent review problem.
Match blocks are executable connection logic
A Match block is not a label for a group of hosts. It is a conditional section of ssh_config that changes which directives apply. The conditions can include the requested host, original host, remote user, local user, canonicalization state, local network, a requested command, and an exec command that SSH runs through the local shell.
That power is useful. It also means a config can contain behavior that remains invisible if you inspect only the nearby Host alias.
Consider this configuration:
Host prod-api
HostName api-01.prod.example.net
User deploy
ProxyJump prod-bastion
Match originalhost prod-api user root
IdentityFile ~/.ssh/breakfix_ed25519
IdentitiesOnly yes
A reviewer who reads only the Host prod-api block sees a deployment connection as deploy. If a caller runs ssh -l root prod-api, the Match originalhost prod-api user root condition can apply. Whether the identity setting takes effect also depends on where earlier identity settings were obtained and whether the option supports multiple values. The important point is simpler: the connection changed because of a command-line user argument, not because the alias changed.
For agent use, avoid Match user rules that grant a more privileged identity or route. The pattern feels tidy because it groups behavior by account name. It is also easy for a calling tool to alter with -l, user@host, or a generated command. Put the intended User directly in a purpose-specific alias instead.
A safer version makes each intent explicit:
Host prod-deploy
HostName api-01.prod.example.net
User deploy
ProxyJump prod-bastion
IdentityFile ~/.ssh/prod_deploy_ed25519
IdentitiesOnly yes
Host prod-breakfix
HostName api-01.prod.example.net
User ops
ProxyJump prod-bastion
IdentityFile ~/.ssh/prod_breakfix_ed25519
IdentitiesOnly yes
This does not make privileged access harmless. It makes the requested connection legible. An agent needs separate authorization to invoke prod-breakfix; it cannot stumble into that behavior merely by varying a username.
Match exec deserves even less trust in agent-facing config. It runs a local shell command while SSH evaluates the configuration. Teams use it for network detection, inventory lookups, or credential selection. That turns an SSH connection attempt into a local code path with environment dependencies. If you need that flexibility for human work, keep those aliases outside the set an agent can call. A connection review should not require reverse-engineering an arbitrary shell command.
The first matching value can defeat your exception
The most persistent SSH config bug is not an invalid stanza. It is a valid stanza placed after a broader rule that already set the option.
Suppose a developer writes this while trying to require a bastion for production:
Host *
User deploy
ProxyJump dev-bastion
Host prod-*
ProxyJump prod-bastion
The expectation is understandable: prod-* looks more specific, so it should win. OpenSSH does not sort blocks by specificity. It processes them in file order, and for many directives the first obtained value wins. prod-api will retain dev-bastion because the earlier Host * already supplied ProxyJump.
Put specific blocks first:
Host prod-*
ProxyJump prod-bastion
Host *
User deploy
ServerAliveInterval 30
That is not merely style. A route through the wrong bastion can place the session on the wrong network path. A broad User deploy default can cause a production alias to authenticate as an account that has no business on that host. A broad IdentityFile may offer an unexpected credential before the intended one.
Do not overlearn the first-value rule. Some directives deliberately accept multiple values, and IdentityFile is a common example. Multiple configured identities can be added to the set SSH considers. That creates a different failure: a narrow alias may name the correct identity but still leave other identities available through earlier configuration or the local SSH agent.
For automated connections, make identity selection boring and explicit:
Host prod-deploy
HostName api-01.prod.example.net
User deploy
IdentityFile ~/.ssh/prod_deploy_ed25519
IdentitiesOnly yes
ProxyJump prod-bastion
IdentitiesOnly yes tells OpenSSH to use only identities configured in the SSH configuration or supplied on the command line, rather than freely trying every identity available from an agent. It does not fix a sloppy config. It does stop an unrelated key loaded into a local agent from becoming an accidental candidate.
A popular recommendation says to put all defaults in Host * and override only when needed. That is reasonable for harmless settings such as keepalive intervals. It is poor practice for user selection, routes, identity files, ports, ProxyCommand, and host rewriting. Defaults that affect authority should be sparse. A little repetition is cheaper than explaining why an agent reached the right machine through the wrong path.
ProxyJump creates another connection that needs review
ProxyJump does not decorate a destination connection. SSH first connects to the jump host, then establishes a TCP forwarding path to the destination from that jump host. Multiple proxies can be listed and traversed in sequence. The OpenSSH manual also warns that destination-host configuration does not generally apply to jump hosts.
That last detail is where reviews often fail. A config can be precise about prod-api and completely vague about prod-bastion.
Host prod-api
HostName 10.40.8.17
User deploy
ProxyJump prod-bastion
Host prod-bastion
HostName bastion.prod.example.net
User jump
IdentityFile ~/.ssh/bastion_ed25519
IdentitiesOnly yes
This is two authentication decisions and two host identities:
- SSH authenticates the local client to
bastion.prod.example.netasjump. - The bastion forwards a TCP stream to
10.40.8.17. - SSH authenticates through that stream to the destination as
deploy.
The jump host can have a different key, user, port, and host-key record. It can also be selected by a wildcard alias or conditional block that nobody checks because the agent only requested prod-api.
Review every hop with ssh -G, not only the final alias:
ssh -G prod-api | egrep '^(hostname|user|port|proxyjump|identityfile|identitiesonly) '
ssh -G prod-bastion | egrep '^(hostname|user|port|identityfile|identitiesonly) '
The output has one option per line. A healthy review might contain this shape:
hostname api-01.prod.example.net
user deploy
port 22
proxyjump prod-bastion
identityfile ~/.ssh/prod_deploy_ed25519
identitiesonly yes
Then verify the bastion separately. If prod-api uses a comma-separated chain such as edge-bastion,prod-bastion, run the command for both aliases. A chain is not one opaque route. It is several separate SSH client configurations.
Avoid defining a generic jump route in Host * or a wide pattern such as Host *.internal. It tends to catch temporary hosts, staging environments, and aliases added months later. Define the jump route where it belongs, in the aliases that require it. If many production aliases need it, use a narrow pattern that is reserved for production aliases and never reuse that pattern casually.
Also check for ProxyCommand. OpenSSH treats ProxyJump and ProxyCommand as competing options: whichever one is specified first prevents later instances of the other from taking effect. A config that appears to use a bastion may instead be running an earlier proxy command. A reviewer should flag either setting, because both change where the network connection originates and how it reaches the destination.
User selection changes the authority being approved
The remote account is part of the requested action. [email protected] and [email protected] may reach the same server, but they do not have the same authority, shell profile, forced commands, sudo rights, or audit trail.
SSH can obtain the remote user from several places: user@host in the command, ssh -l user host, a User directive, or the local username if nothing else supplies one. A config review that only asks "Which host?" is incomplete.
Use aliases that pin a user whenever an agent has a defined job:
Host inventory-read
HostName inventory.prod.example.net
User inventory_ro
IdentityFile ~/.ssh/inventory_ro_ed25519
IdentitiesOnly yes
Host inventory-deploy
HostName inventory.prod.example.net
User deploy
IdentityFile ~/.ssh/inventory_deploy_ed25519
IdentitiesOnly yes
Do not give an agent a generic hostname and assume a prompt or a wrapper will keep it on the right account. A command generator can emit [email protected] just as easily as [email protected]. The config should make the authorized path the easy path and make privileged paths visibly distinct.
Test the alternate forms that a tool can generate:
ssh -G inventory-read | grep '^user '
ssh -G -l ops inventory-read | grep '^user '
ssh -G ops@inventory-read | grep '^user '
If the second or third command produces an account you did not intend an agent to use, do not call the configuration reviewed. Fix the calling interface or isolate that alias. A Match user block may also activate under one of these variants, which is why it needs explicit testing rather than visual inspection.
For teams, reserve root access for a separately named emergency alias, and keep it outside ordinary agent permissions. Hiding User root behind a Match condition is worse than writing it plainly. The condition becomes a scavenger hunt during an incident, and a caller can sometimes satisfy it by changing a command-line parameter.
IdentityFile controls more than the key path
IdentityFile looks like a file-selection setting. In practice, it decides which credential SSH may present, and that determines which remote authorization rules the server evaluates.
A familiar failure looks like this:
Host *
IdentityFile ~/.ssh/id_ed25519
Host prod-*
IdentityFile ~/.ssh/prod_ed25519
The operator thinks production uses prod_ed25519. SSH may have both identity files in its candidate list because IdentityFile supports multiple entries. If an SSH agent holds additional keys and IdentitiesOnly is absent, it may offer those too. Some servers reject repeated offers early, and some accept an unintended identity that happens to grant access. Neither result is a clean expression of intent.
An agent-facing alias should state a single credential purpose and constrain offers:
Host reports-export
HostName reports.prod.example.net
User exporter
IdentityFile ~/.ssh/reports_export_ed25519
IdentitiesOnly yes
Then examine the effective configuration rather than trusting the stanza:
ssh -G reports-export | grep '^identityfile '
ssh -G reports-export | grep '^identitiesonly '
More than one identityfile line is not automatically wrong. Certificate-based setups and planned key rotation can justify it. But every listed identity should belong to the same authority boundary. If one alias can offer a personal administrator key, a legacy deployment key, and a production automation key, the alias has no clear authorization story.
Do not solve this by storing private keys in an agent's files, environment, prompt, or generated script. That merely changes a configuration ambiguity into credential exposure. Sallyport keeps SSH keys in its encrypted vault and executes SSH actions through its helper, but it cannot make an ambiguous SSH config honest. The alias, route, user, and identity intent still need to be clear before an operator approves the agent process.
The same rule applies to key names. A path such as ~/.ssh/id_ed25519 conveys nothing about intended use. prod_deploy_ed25519 is better, but the config needs the full explanation: which host group, which user, and which route use it. File names support review; they do not replace it.
Canonicalization can make one alias match twice
Hostname canonicalization is one of the least visible ways SSH configuration changes. When CanonicalizeHostname yes is enabled, OpenSSH can take an unqualified name, append configured domain suffixes, resolve it, and then reprocess the configuration using the new target name. Match canonical applies on that later pass. Match final requests a final parse and matches during that pass; when canonicalization is enabled, the canonical and final conditions match together.
That behavior can be useful in large internal networks. It can also turn a short alias into a conditional configuration trap.
CanonicalizeHostname yes
CanonicalDomains corp.example.net
Host build
User ci
Match canonical host *.prod.example.net
ProxyJump prod-bastion
A caller enters ssh build. The first pass sees build. If canonicalization resolves that name as build.prod.example.net, SSH re-parses the configuration and the Match canonical host *.prod.example.net block can set a production route. The connection did not change because the caller asked for a different alias. It changed because DNS and a second parsing pass changed the host that later rules saw.
The OpenSSH manual distinguishes two conditions that people often treat as interchangeable:
Match originalhosttests the host token as the caller supplied it.Match hosttests the target afterHostNamesubstitution or canonicalization.
Use originalhost when you must bind behavior to a deliberately named alias. Use host when the behavior must depend on the actual resolved destination. Do not use either casually for privilege changes.
Canonicalization has another important wrinkle with bastions. CanonicalizeHostname yes does not normally apply to connections using ProxyCommand or ProxyJump; CanonicalizeHostname always extends it to proxied connections. That means two aliases that look structurally similar can follow different rewrite rules based only on whether one has a jump host.
For agent permissions, the easiest policy is usually the best one: disable canonicalization for aliases handed to an agent, and use explicit fully qualified HostName values. If your environment needs canonicalization, test every allowed alias in the exact network context where the agent runs. Do not assume that a short hostname resolves the same way on a home network, a corporate network, a VPN, and an office Wi-Fi connection.
Match localnetwork raises the same concern. OpenSSH documents that local network address is not trustworthy for security-sensitive configuration, especially on DHCP-configured networks. It is fine for convenience settings. Do not use it to decide whether an agent receives a more privileged identity, skips a bastion, or reaches production.
Render the connection before you authorize it
ssh -G is the fastest way to turn SSH config from prose into something testable. It prints the configuration SSH will use after processing host and Match rules, then exits instead of opening a connection.
Run it with the exact alias and arguments the agent will use. Do not test only a hand-cleaned version of the command.
ssh -G prod-deploy | egrep '^(hostname|user|port|proxyjump|proxycommand|identityfile|identitiesonly|canonicalizehostname) '
For a serious review, save the full output as a fixture in the repository that owns the automation. Use an intentionally named config file so the test does not silently inherit a developer's personal settings:
ssh -F ./agent-ssh-config -G prod-deploy > ./testdata/prod-deploy.effective
Review the fixture when the config changes. A useful diff catches a changed hostname, user, proxyjump, or identity list before it reaches an approval flow. A noisy full-config diff is still better than trusting a block someone pasted into a pull request.
Use ssh -vvv only after ssh -G shows the expected values. Verbose connection logs help confirm which host keys and authentication methods SSH actually tries, but they mix configuration decisions with network noise. -G answers "what does this config say?" first. That is the question you need to settle before troubleshooting reachability.
Test variations deliberately:
ssh -F ./agent-ssh-config -G prod-deploy
ssh -F ./agent-ssh-config -G -l ops prod-deploy
ssh -F ./agent-ssh-config -G ops@prod-deploy
ssh -F ./agent-ssh-config -G prod-deploy.prod.example.net
The results should either remain within the expected authority boundary or fail. If a user override changes the account, if a fully qualified form skips the bastion, or if a short name gains a different identity after canonicalization, you found a configuration path worth closing.
Check included files as well. Include can make the visible ~/.ssh/config only the front door to a directory full of machine-generated, corporate, or project-specific rules. Review the effective output with the same local account and config path that will execute the agent. Testing from your own shell while the agent uses another account gives a false sense of certainty.
Keep agent-facing SSH config small and purpose-built
The best SSH config for an autonomous coding agent is usually not your personal SSH config with a few comments added. Personal configs accumulate shortcuts, client exceptions, old host aliases, local-network behavior, forwarded agents, and identities that were convenient at some point. An agent needs a narrow connection catalog.
Create a dedicated config file that contains only approved aliases and their required supporting jump hosts. Point the agent or its execution wrapper at that file with -F. Give each alias one job, an explicit HostName, User, route, and identity intent. Keep conditional logic out unless you can show why a static alias cannot do the job.
A compact example looks like this:
Host prod-bastion
HostName bastion.prod.example.net
User jump
IdentityFile ~/.ssh/prod_bastion_ed25519
IdentitiesOnly yes
Host prod-deploy
HostName api-01.prod.example.net
User deploy
ProxyJump prod-bastion
IdentityFile ~/.ssh/prod_deploy_ed25519
IdentitiesOnly yes
Host staging-deploy
HostName api-01.staging.example.net
User deploy
IdentityFile ~/.ssh/staging_deploy_ed25519
IdentitiesOnly yes
This repeats itself. Good. The file tells a reviewer what each connection means without requiring them to mentally execute wildcard precedence and conditional state.
Do not mistake a dedicated config file for a policy engine. It cannot prove that a command is safe after the session opens. It can make the transport connection concrete enough to review: this alias, this endpoint, this user, this route, this identity. That is a useful boundary.
Sallyport's per-session authorization and its activity records give operators a human control point and a trail for agent actions, but SSH config still supplies the facts behind the action. If prod-deploy can morph into several different network paths or accounts, the configuration has already made approval less reliable.
Before you allow an agent to use an SSH alias, render it, inspect every jump host, and test the command-line variants the agent can produce. If the effective connection surprises you once, assume it will surprise someone at the worst possible time. Fix the alias until it reads like an approval a human can actually make.
FAQ
What does Match do in an SSH config file?
Match starts a conditional section of ssh_config; settings below it apply only when its conditions are true. It can test the typed host name, the rewritten host name, the remote user, the local user, or a command result. Treat it as code that changes a connection, not as a comment that documents intent.
How can I see the effective SSH configuration for a host?
Use ssh -G alias to print the resolved settings for the alias you plan to use. Then inspect at least hostname, user, port, proxyjump, identityfile, identitiesonly, and canonicalizehostname. Run the same command with -l user if an agent or script may select a remote user explicitly.
Can a later Match block override an earlier Host block?
Usually, no. OpenSSH takes the first value it obtains for many single-value settings, so a broad earlier block can prevent a later Match block from changing User, ProxyJump, or Hostname. Put narrow exceptions before broad defaults and verify the result with ssh -G.
Is an SSH host alias the same as the destination host?
A host alias is the token passed to SSH, while HostName is the address SSH actually connects to. An alias may look harmless but resolve to a production address, a different port, or a host reached through a bastion. Approval and audit review should account for the resolved destination, not only the alias text.
Does ProxyJump change how SSH reaches a server?
ProxyJump makes SSH connect to one or more jump hosts and forward traffic onward to the destination. The destination's settings do not generally apply to the jump host, so each hop needs its own review. A jump host can change both where credentials are used and which network path carries the session.
Why does SSH offer the wrong key?
IdentityFile selects a private-key file or public identity reference that SSH may offer. Multiple identity files can accumulate, and SSH may also offer identities from an agent unless IdentitiesOnly yes restricts that behavior. For automated work, give each trust boundary a deliberate identity choice instead of relying on whatever happens to be loaded locally.
Can CanonicalizeHostname change Match behavior?
It can. CanonicalizeHostname yes can rewrite a short name using configured domains and then make SSH parse its configuration again; always extends this behavior to proxied connections. That can activate host or Match rules that did not match the original alias, so test short aliases and full host names separately.
What is the difference between Match host and Match originalhost?
Match originalhost tests the name supplied on the command line. Match host tests the target after HostName substitution or canonicalization. Use originalhost when a rule must depend on the alias an operator or agent typed, and use host when it must depend on the resolved target.
Is it safe to let an AI agent use my existing SSH config?
No. An SSH agent can use a configuration file whose meaning depends on local network, command-line user, canonical DNS names, included files, and earlier settings. Review the rendered configuration before approving an autonomous run, and keep aliases stable enough that a human can recognize the intended destination.
How should I audit SSH config before giving it to an agent?
Start with ssh -G for every alias the agent may invoke, then compare the output against a written connection inventory. Remove wildcard defaults that select privileged users or proxy routes, isolate production aliases, and set explicit users and identities. If you cannot explain one effective connection in a minute, do not hand that alias to an agent.