# Bastion host access for AI coding agents, done properly

A bastion host can give AI coding agents a safer route into private infrastructure, but only if you treat it as one part of an access boundary. A jump box that merely appears in an SSH config file is not a control. The agent can bypass it, borrow a credential, open a tunnel, or leave you with an audit record that proves only that something connected to the jump box.

For autonomous work, the useful design is stricter: the agent reaches one controlled entry point, the entry point reaches only known destinations, credentials stay outside the agent's context, and your records distinguish the host the agent asked for from the host that actually accepted the connection. That distinction sounds fussy until the first incident review. Then it is the difference between evidence and a comforting story.

## A bastion changes the route, not the agent's authority

A bastion host access design limits network reachability, but it does not reduce what an agent can do after it reaches a privileged account. Teams often confuse those jobs because both involve SSH. They are separate controls and should fail separately.

A jump host helps when private targets accept administration traffic only from that host or subnet. It gives operators one place to apply egress limits, collect connection evidence, and cut off access during an investigation. It also keeps every workstation, build runner, and agent sandbox from needing direct routes into sensitive networks.

It does not answer four questions that matter more with an autonomous client:

- Can the agent reach the target directly through another route?
- Does the target account have wider permissions than the requested job needs?
- Can the agent create a tunnel that gives it an unreviewed path?
- Can you identify the final machine and remote command after the fact?

If the answer to the first question is yes, ProxyJump is a convenience setting, not enforcement. If the answer to the second is yes, the bastion has only moved the point where an overpowered credential enters the network.

I have seen teams build a carefully maintained jump box, then allow a CI subnet broad SSH egress "for troubleshooting." An agent running in that subnet needs no exploit and no clever trick. It can call the final address directly. The architecture failed because the network allowed the bypass, not because SSH behaved badly.

Keep the roles plain. The agent environment should connect only to the entry host. The entry host should connect only to intended target networks and ports. The final host should authorize an account with a narrow purpose. Each layer should produce records that answer a different question.

## ProxyJump does not expose the final command

An SSH jump host usually cannot inspect the command that the agent runs on the final host. This is the point teams miss when they promise "full command logs from the bastion."

OpenSSH documents `ProxyJump` in `ssh_config(5)` as connecting to the jump host first and setting up forwarding to the ultimate destination. In common OpenSSH use, the client effectively asks the jump host to make a TCP connection to the target, then runs its end to end SSH session through that byte stream. The final SSH transport remains encrypted between client and target.

The bastion can often know that it connected to `10.42.8.19:22`. It may log the account used on the bastion, the source, timing, and the destination of a forwarding request. It does not automatically know whether the encrypted session later ran `systemctl restart api`, `cat /etc/shadow`, or an interactive shell.

This leaves three different observations that people routinely collapse into one:

1. The **requested destination** is what the agent supplied, such as `prod-api-01`.
2. The **connection destination** is the address and port that the proxy actually opened.
3. The **execution destination** is the server identity and account that accepted the final SSH authentication.

They can differ. An alias can resolve differently after a DNS change. A compromised or stale known-host entry can make a connection suspect. A proxy rule can send a friendly alias to an unexpected address. Logging only `prod-api-01` does not prove which server acted on a command.

The same separation applies to commands. A client side gateway can record the requested command before transport. A final host wrapper can record the command that OpenSSH passes for an exec request. The bastion can record the TCP target. Correlate those observations by session identifier and time window. Do not pretend that any one layer sees all of them.

If you need a single place that can inspect and authorize every remote command, ordinary end to end SSH through a jump box is the wrong primitive. Use a purpose built remote execution service, a restricted command endpoint, or a target side wrapper that accepts only the operation you intend to support. That choice costs flexibility. It also stops you from claiming visibility you do not have.

## Network rules must make the approved path unavoidable

A controlled entry point works only when the surrounding network makes alternative paths unavailable. Start with reachability before debating approval dialogs or log formats.

The agent runtime needs outbound SSH only to the bastion's address and port. It should not have a broad route to production subnets, public management interfaces, or a second jump host that nobody considered part of the design. DNS matters here too. If the agent can resolve and connect to raw target addresses, a friendly hostname convention does not protect you.

The bastion needs a short egress allowlist. If it administers hosts on TCP port 22 in two private ranges, allow those ranges and that port. Do not give it arbitrary outbound access and call the host a bastion. An entry host that can reach every service, database, and public endpoint becomes a general relay when the agent account is abused.

The final targets should accept SSH administration only from the bastion path. Cloud security groups, host firewalls, and network ACLs can each help. Use more than one only when you can keep their ownership clear. A rule nobody maintains is not defense.

Test the route with denial cases, not only the successful command. Run these checks from the same user, container, or VM that runs the agent:

```sh
nc -vz bastion.internal.example 22
nc -vz 10.42.8.19 22
ssh -o ConnectTimeout=5 prod-api-01 true
```

The first connection should work. The direct connection to the final host should fail. The SSH command should work only because its configured route uses the bastion. If `nc` is absent, use your environment's normal TCP test command. The point is to test the network path without allowing SSH configuration to hide a direct route.

Repeat after firewall changes, new subnets, and changes to the agent runner. This is a small test with an unusually high payoff. It catches the common case where the SSH config looks correct while the network still permits a straight connection.

## The agent needs an identity you can revoke

Do not grant an agent a shared administrator key and hope the bastion makes that acceptable. A copied private key is an authorization decision with a very long tail.

Give each agent process or run an identity that you can attribute and revoke. The mechanism can be a short-lived SSH certificate, an ephemeral key registered for a bounded job, or a credential held by an action gateway that executes SSH on the agent's behalf. The right choice depends on your environment, but the agent should not print a reusable secret into its transcript, workspace, shell history, or tool output.

SSH certificates help when you already operate an SSH certificate authority. A certificate can carry a short validity period, a principal for the target account, and critical options such as source address restrictions. The target can trust the CA rather than maintaining a growing list of individual public keys. That makes issuance and expiry easier to inspect.

Certificates do not make a broad account narrow. A certificate for `root` remains a root credential until it expires. Nor do certificates prove a command's intent. Treat them as an issuance and lifetime mechanism.

A gateway changes the exposure pattern more sharply. Sallyport can execute SSH through its `sp-ssh` helper while the SSH key remains in its encrypted vault rather than reaching the agent process. That protects the private key from an agent transcript, but teams should still test how their intended jump route, target identity, and command evidence are recorded.

Tie approval to an identifiable process, not to the sentence the agent wrote in a chat window. A process can be signed, started at a time, tied to a local user, and revoked as a running session. A natural language explanation is useful context, but it is not an access control boundary.

Avoid one credential that all automated work shares. When a target-side log says `deploy`, you need a second record that identifies the invoking agent run. If every run uses the same key and the same account, an incident review turns into archaeology.

## Record an execution claim, a route fact, and a result

An agent SSH audit record should state exactly what each field means. That prevents the audit system from making claims it cannot support.

At action request time, record the agent run identity, the requested host alias, requested remote account, command text or structured operation, and whether the caller asked for a TTY or forwarding. This is the execution claim. It tells you what the caller attempted to do.

At the route boundary, record the entry host identity, resolved destination address, destination port, and connection outcome. This is the route fact. If the proxy opens a connection, it can say where it opened it. It cannot truthfully infer the final command from encrypted bytes.

At the final host, record the target host's stable identity, authenticated account, authentication fingerprint or certificate serial, command if the server received an exec request, exit status, and relevant local service logs. This is the execution evidence. The final host is the only normal SSH endpoint that can see the remote command without breaking SSH's end to end design.

Use an event shape that makes missing evidence visible:

```json
{
  "run_id": "run_7c31",
  "requested": {
    "host": "prod-api-01",
    "user": "deploy",
    "command": "sudo systemctl restart api",
    "tty": false
  },
  "route": {
    "bastion": "bastion.internal.example",
    "destination_ip": "10.42.8.19",
    "destination_port": 22
  },
  "final_host": {
    "host_fingerprint": "SHA256:example",
    "authenticated_user": "deploy",
    "command_observed": true,
    "exit_status": 0
  }
}
```

The example deliberately separates `requested.command` from an observation at the final host. If the session was interactive, set `command_observed` to false and say why. A blank field with no explanation invites someone to assume the tool captured it.

For ordinary command execution, `sshd_config(5)` documents `ForceCommand`, which forces a command for a matching account or match block. A target side wrapper can read `SSH_ORIGINAL_COMMAND` for an exec request, validate an allowed operation, and write an audit record before invoking the approved program. It needs careful shell handling. Do not pass an untrusted command string through `eval`, and do not assume `SSH_ORIGINAL_COMMAND` exists for an interactive shell.

Hash chaining and append-only storage help detect later alteration of audit events. They do not fix weak event content. A perfectly preserved record that says only "SSH connected" remains weak evidence.

## Make the SSH configuration inspectable

Keep host aliases for people, but inspect what OpenSSH will actually do before handing a configuration to an agent. `ssh -G` prints the effective client configuration after OpenSSH processes matching `Host` entries.

This example routes one named target through a named bastion and disables options that often surprise teams:

```sshconfig
Host agent-bastion
    HostName bastion.internal.example
    User agent-gateway
    IdentityFile ~/.ssh/agent_gateway
    IdentitiesOnly yes

Host prod-api-01
    HostName 10.42.8.19
    User deploy
    ProxyJump agent-bastion
    StrictHostKeyChecking yes
    UserKnownHostsFile ~/.ssh/agent_known_hosts
    ForwardAgent no
    RequestTTY no
```

Then check the rendered settings:

```sh
ssh -G prod-api-01 | grep -E '^(hostname|user|proxyjump|forwardagent|requesttty) '
```

The output should have the same shape as this:

```text
user deploy
hostname 10.42.8.19
requesttty no
forwardagent no
proxyjump agent-bastion
```

This proves how the client plans to behave. It does not prove the network blocks direct SSH, the target presents the expected host key, or the bastion restricts its egress. Test those independently.

Do not let the agent freely provide arbitrary `ssh` flags. An agent can override configuration with command line options, specify a raw IP address, replace `ProxyJump`, or add forwarding options if the execution layer simply passes through a shell string. Put the destination, account, and permitted SSH options in structured action inputs where possible. If you accept a raw command, regard it as code with the same suspicion you apply to a shell script from an untrusted source.

Host key verification deserves attention because agents retry automatically and will often treat an error as a task obstacle. Preload trusted host fingerprints through a controlled process. Keep strict checking enabled. Do not teach the agent to remove known-host entries or accept changed keys to get a deployment unstuck. A host key change requires an operator to verify why it changed.

## Forwarding and shells create paths around your review

Disable forwarding and interactive shells for autonomous agents unless a specific task needs them. Both features turn a bounded remote command into a broader access channel.

Local forwarding lets a client open a local port that reaches an internal service through SSH. Remote forwarding lets a remote endpoint expose a path back toward the client side. Dynamic forwarding creates a SOCKS proxy. Agent forwarding makes credentials available to another host. Each one can be legitimate for a human administrator. Each one also weakens the claim that the bastion is the only controlled route.

OpenSSH provides server restrictions that belong on the final target or bastion account, not only in the agent's client configuration. `sshd_config(5)` documents `DisableForwarding`, and `authorized_keys` supports options such as `no-port-forwarding`, `no-agent-forwarding`, `no-X11-forwarding`, and `no-pty`. Use the controls your OpenSSH version supports, then verify them with a real connection attempt.

A restricted account might use an authorized key entry shaped like this:

```text
no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-pty ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... agent-run
```

That line limits features for that public key. It does not restrict the commands that the account can execute. Pair it with a constrained account, a `ForceCommand` wrapper, or a service specific interface if command scope matters.

Interactive shells deserve a blunt rule: do not give one to an autonomous agent by default. A shell accepts a sequence of commands, pipelines, redirections, background processes, and copied credentials. The command field in your initial action request will not describe the full session. A forced wrapper may block a shell, but test it because shell behavior changes with account configuration and SSH subsystem requests.

Some maintenance jobs genuinely require a tunnel or a shell. Handle those as explicit exception paths with a named operator approval and a short expiry. Trying to hide an exception inside a general agent credential is how temporary access becomes permanent.

## Target restrictions beat command string filters

A simple allowlist of shell command prefixes is easy to bypass and difficult to maintain. It is popular because it feels precise: permit `systemctl restart api`, reject everything else. Shell grammar makes that confidence brittle.

Consider a filter that accepts text beginning with `systemctl restart api`. A caller may append a shell operator, use a different executable path, trigger behavior through an environment variable, or exploit a wrapper that parses the string inconsistently. Even a careful parser cannot know every effect of a command that invokes another program.

Use structured operations when the work has a stable shape. A deployment runner can accept an application name and release identifier. A maintenance endpoint can accept a service name from a fixed list. A restricted wrapper can map a small operation name to a fixed argv array. In each case, avoid a shell entirely.

When you must support free-form commands, admit what you are allowing: remote shell authority for that account. Record it, put a time limit on the credential, constrain the target environment, and require a separate approval level. Do not sell a string filter as command authorization.

The target account should also have an operating system permission boundary. A `deploy` account that can run unrestricted `sudo` is effectively an administrator account with extra typing. Give it only the service actions, files, and directories its automation requires. Review its sudo rules as code, including every command path and argument pattern.

The practical test is whether an agent tasked with restarting a service can read production secrets, create a reverse tunnel, change SSH authorization, or alter the audit collector. If it can, the account scope is wrong even if every connection used the correct bastion.

## Incident response starts with stopping the live route

When an agent behaves suspiciously, revoke its live access before you start reconstructing intent. Disable its active session at the action boundary if you have one, block the source from the bastion, and prevent the associated credential from authenticating again. Preserve logs before someone rotates files or restarts the host that holds the evidence.

Then build a timeline from the separate records. Start with the agent's requested action. Match it to the bastion connection destination and time. Match that to final host authentication and command evidence. Check whether a TTY, forwarding request, or alternate destination appeared. A discrepancy is not automatically malicious, but it tells you which control needs investigation.

Do not respond by permanently granting more access "so the agent can repair the issue." That is a common panic move. If the agent has already behaved outside expectations, broadening its account or opening a direct route removes the evidence you need and creates another incident.

Run a small drill before production needs it. Revoke a test agent during a harmless SSH command, confirm that new requests fail, and verify that your records show the requested host, actual route, target identity, and exit outcome. If that drill requires a group chat to determine what happened, your access design is still too vague.

A bastion earns its place when it enforces a route, supports fast revocation, and contributes a precise fact to the audit trail. Configure the route first, keep credentials out of the agent, and collect final-host evidence where final commands are visible. Anything less is an SSH convenience with a security-themed name.
