# SSH config Match blocks and agent approval

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:

```sshconfig
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:

```sshconfig
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:

```sshconfig
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:

```sshconfig
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:

```sshconfig
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`.

```sshconfig
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:

1. SSH authenticates the local client to `bastion.prod.example.net` as `jump`.
2. The bastion forwards a TCP stream to `10.40.8.17`.
3. 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:

```sh
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:

```text
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. `deploy@api-01.prod.example.net` and `ops@api-01.prod.example.net` 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:

```sshconfig
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 `ops@inventory.prod.example.net` just as easily as `deploy@inventory.prod.example.net`. 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:

```sh
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:

```sshconfig
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:

```sshconfig
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:

```sh
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.

```sshconfig
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 originalhost` tests the host token as the caller supplied it.
- `Match host` tests the target after `HostName` substitution 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.

```sh
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:

```sh
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:

```sh
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:

```sshconfig
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.
