7 min read

Bastion host access for AI coding agents, done properly

Bastion host access for AI coding agents needs enforced routes, controlled credentials, SSH command evidence, and reliable target logging.

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:

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

Keep SSH keys outside agents
Sallyport runs SSH through sp-ssh while the private key stays encrypted in its vault.

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:

{
  "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:

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:

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

The output should have the same shape as this:

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

Keep SSH actions attributable
Activity Journal records individual actions from the same encrypted audit log as session events.

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:

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

Lock the action boundary
A locked vault denies every action, so an agent cannot use stored SSH keys while access is closed.

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.

FAQ

Does a bastion host make an AI agent safe by itself?

No. A bastion only puts a host in the network path. If an agent can still reach target addresses directly, can choose another proxy, or holds reusable private keys, it can bypass the intended route.

Can a jump host see every command an agent runs?

Usually not. With ProxyJump, the bastion forwards the SSH transport and the final host sees the actual SSH session; the bastion normally cannot read the remote command inside that encrypted session.

What should an audit record for agent SSH access include?

Record the requested alias, resolved host and port, bastion used, final host fingerprint, remote account, exact command when available, exit status, and a transcript or output digest under a stated retention policy. Do not label an alias as proof of the machine reached.

How do I stop an agent from bypassing ProxyJump?

Use network controls as the enforcement point. Permit outbound SSH from the agent environment only to the bastion, then permit bastion egress only to approved target addresses and ports.

Should an AI coding agent have an SSH private key?

The agent should not possess a long-lived private key. Keep the credential in a gateway or issue short-lived certificates after an approval, then restrict the target account to the smallest set of actions it needs.

Are SSH certificates better than static keys for agents?

SSH certificates reduce the blast radius and make issuance traceable, but they do not inspect commands or prevent forwarding by themselves. You still need account restrictions, network boundaries, and command evidence where the command becomes visible.

Which SSH features should be disabled for autonomous agents?

Treat port forwarding, agent forwarding, X11 forwarding, and interactive shells as separate capabilities. Disable them by default, then add a narrowly tested exception when a real task requires one.

Why should agents avoid interactive SSH shells?

An interactive shell gives the agent an open-ended session and makes command level audit much weaker. Prefer a single explicit command with no TTY, a constrained wrapper, or a remote API when the job permits it.

Where is the best place to capture the remote command?

A final-host wrapper using ForceCommand can receive SSH_ORIGINAL_COMMAND for ordinary exec requests, while a client-side action gateway can record the requested action before transport starts. Neither record alone proves every effect, so correlate it with the target host's local logs and result.

What should I do if an agent makes a suspicious SSH connection?

Revoke the active session, remove or stop issuing its credentials, and preserve the relevant audit records before making broad configuration changes. Then compare the requested destination, the connection destination, and the target host's local evidence to find where control failed.

Sallyport

Sallyport runs API calls and SSH commands for your AI agent. The keys stay in a local vault on your Mac; you approve each run and every action lands in a sealed journal.

© 2026 Sallyport · Open source under Apache-2.0 · Oleg Sotnikov