# SSH port forwarding for autonomous agents: safer tunnels

SSH port forwarding for autonomous agents needs a tighter design than ordinary developer access. A tunnel can turn a localhost-only database, admin panel, or staging service into something an agent can reach through a single SSH command. If the agent holds broad SSH credentials, the tunnel is only one symptom. The real failure is that nobody constrained the destination, duration, or authority behind that command.

I have seen teams call a tunnel "temporary" because somebody typed `ssh -L` in a terminal. Then the terminal stayed alive for days, its port became a dependency for another process, and the person who opened it was unavailable when security asked why a production-adjacent service had an unexplained listener. Autonomous work makes that pattern worse because the agent can retry, reconnect, and use a tunnel far more consistently than a distracted human.

The safe approach is plain: give an agent a narrowly scoped connection path, approve a specific temporary exposure, record enough context to reconstruct it later, and make closure an enforceable event rather than an intention.

## A tunnel changes network reachability, not just SSH behavior

An SSH port forward creates a listener on one side of an SSH connection and carries its traffic to a destination reachable from the other side. That listener is an access path. Reviewing it as "SSH access" loses the part that creates risk: who can connect, where traffic ends up, and what can travel through it.

Consider an agent running on a build host that needs to query `db-admin.internal.example` on port 5432. A local forward can make that service appear as `127.0.0.1:15432` on the build host. The database remains private from the public internet, but every process on that host that can reach the listener may now attempt a connection. The SSH server also becomes an authorized route to the database.

That can be acceptable. It is not equivalent to letting the agent "use SSH for maintenance." The approval should describe this exact route:

- initiating workload and host
- listener address and port
- target host and port
- reason for the route
- expiry and approving person

A remote forward reverses the listener location. If an agent connects out to a bastion and asks the bastion to listen on port 18080, a process that reaches that listener can receive traffic back toward the agent host. Remote forwarding often surprises teams because it can expose a service behind a firewall without opening an inbound firewall rule.

OpenSSH documents these modes separately in `ssh(1)`: `-L` creates local forwards, `-R` creates remote forwards, and `-D` creates a SOCKS proxy. That separation is operationally useful. Do not approve "port forwarding" as one undifferentiated permission. Each mode exposes a different listener and requires different constraints.

## Local, remote, and dynamic forwarding need different decisions

A local forward usually fits an agent job when the agent needs one known service through a controlled jump host. The listener exists on the agent side, and the SSH server connects to the internal destination. The command shape is familiar:

```sh
ssh -N \
  -L 127.0.0.1:15432:db-admin.internal.example:5432 \
  agent-db-tunnel@bastion.internal.example
```

`-N` asks SSH not to run a remote command. It does not make the connection harmless. The bind address `127.0.0.1` limits the listener to the local host, while `db-admin.internal.example:5432` states the requested destination. Those are separate properties and must both appear in the approval record.

A remote forward is appropriate only when someone has explicitly chosen to make a listener on the remote side. The command below asks the bastion to listen on its own loopback interface and carry traffic back to port 8080 on the agent machine:

```sh
ssh -N \
  -R 127.0.0.1:18080:127.0.0.1:8080 \
  agent-return-path@bastion.internal.example
```

Do not treat a loopback remote listener as automatically safe. A local process on the bastion may have much more access than the agent's source machine, and another user on a shared bastion may reach it. Review the remote host's user population and local processes before allowing it.

Dynamic forwarding deserves a default denial for autonomous agents:

```sh
ssh -N -D 127.0.0.1:1080 agent@bastion.internal.example
```

This starts a SOCKS listener. The client chooses destinations later, through SOCKS requests, rather than naming one destination in the SSH command. Teams like it because it quickly makes an internal web interface work in a browser or test harness. That convenience removes the destination boundary you need for unattended work. A reviewer cannot approve a single route, and ordinary SSH destination restrictions cannot express a useful allowlist for arbitrary SOCKS traffic.

Do not solve that problem with a longer approval form. Deny dynamic forwarding to the agent account. If the task needs several destinations, define each forward separately or put a purpose-built proxy in front of the services with its own authentication and logs.

## The bind address decides who can use the listener

The bind address is part of the security decision because it determines which machines can connect to the tunnel listener. A forward that binds to loopback and a forward that binds to every interface can use the same target while creating completely different exposure.

For a local forward, use an explicit loopback address:

```sh
-L 127.0.0.1:15432:db-admin.internal.example:5432
```

Avoid relying on the default. OpenSSH normally binds local forwards to loopback when you omit a bind address, but an explicit address makes review, logs, and incident investigation less ambiguous. It also prevents a later client configuration change from quietly changing the listener scope.

This is the dangerous version:

```sh
-L 0.0.0.0:15432:db-admin.internal.example:5432
```

It exposes port 15432 on every IPv4 interface of the client host. Any host that can reach the client can attempt the tunnel. A build runner in a shared subnet can turn into a bridge to an internal database even though the database itself accepts connections only from the bastion.

IPv6 needs the same attention. `::1` is loopback, while `::` listens on all IPv6 interfaces. Check both address families. I have watched teams confirm that `127.0.0.1` was safe, then discover the automation had also opened an IPv6 listener through a separate configuration path.

For remote forwards, the SSH server controls what bind addresses it accepts. In `sshd_config`, `GatewayPorts` affects remote forward bind behavior. OpenSSH documents that remote forwards otherwise bind to loopback by default, subject to the requested address and server policy. Keep `GatewayPorts no` unless you have a reviewed reason to permit broader remote listeners. `GatewayPorts clientspecified` hands clients too much control for an agent account.

After opening a forward, inspect the listener on the machine that owns it. On macOS or Linux, this is a useful local check:

```sh
lsof -nP -iTCP:15432 -sTCP:LISTEN
```

The output should show an `ssh` process with an address like `127.0.0.1:15432` or `::1:15432`. If it shows `*:15432`, stop the job and inspect the command and client configuration. This command proves only the local listener address. It does not prove that the far end is limited to the intended destination.

## A dedicated SSH account must express the allowed route

A dedicated SSH account with server-side restrictions is the minimum sensible boundary for agent tunnels. A client configuration file cannot enforce anything against an agent that can alter its own command arguments. Put the limits where the SSH server accepts the connection.

For a local-only forwarding account, start with a match block like this in `sshd_config`:

```text
Match User agent-db-tunnel
    PasswordAuthentication no
    KbdInteractiveAuthentication no
    PubkeyAuthentication yes
    PermitTTY no
    X11Forwarding no
    AllowAgentForwarding no
    AllowStreamLocalForwarding no
    AllowTcpForwarding local
    PermitOpen db-admin.internal.example:5432
    GatewayPorts no
    PermitUserEnvironment no
```

`AllowTcpForwarding local` permits local forwards while denying remote forwards. `PermitOpen` names the destination that this account may request. The restriction matters because a local forward command can otherwise point at any host and port reachable from the bastion. Without `PermitOpen`, an agent that legitimately needs the database may also request a forward to an admin API, cache, metadata service, or another SSH server.

Use hostnames carefully. The SSH server resolves the destination host, so the name in `PermitOpen` must resolve there. Make that name stable and owned by your infrastructure. If a DNS name can change to arbitrary addresses, the configuration looks narrow while its actual destination moves underneath it. An IP address can be clearer for a fixed appliance, but a hostname works when your internal DNS control and service ownership are disciplined.

OpenSSH's `sshd_config(5)` manual describes `PermitOpen` as a destination restriction in the form `host:port`. It does not turn the account into a policy engine. It does one precise job well: it rejects a forwarding request whose destination is not listed. Keep the account design equally precise.

Do not give this account a shell because "the agent will not use it." Remove the ability instead. A dedicated account should exist for one connection role. Configure its authorized public key with a forced command that exits if the account needs a further guard against interactive use, but test that choice against forwarding behavior in your environment. Some forced-command patterns and wrappers interfere with expected SSH sessions, and a broken restriction often leads someone to remove the whole block under deadline pressure.

Also avoid agent forwarding. `AllowAgentForwarding no` prevents the connected server from asking the client-side SSH agent to sign authentication requests. An SSH tunnel account should carry traffic, not become a stepping stone to other hosts.

For a target with two legitimate services, list two explicit `PermitOpen` entries. Do not use a wildcard or replace the account with a general bastion account just to avoid creating one more account. One extra account is cheaper than explaining why a code agent could reach an entire internal subnet.

## Temporary means an enforced expiry and a clean close

A tunnel is temporary only when something besides good intentions ends it. SSH itself keeps a connection alive until a client closes it, the network fails, or a timeout interrupts it. An autonomous agent can leave a process running after a test succeeds, fails, or times out.

Give each job a fixed maximum session duration outside the agent's discretion. A job runner can enforce this with `timeout` where available:

```sh
timeout 20m ssh -N \
  -o ExitOnForwardFailure=yes \
  -o ServerAliveInterval=30 \
  -o ServerAliveCountMax=3 \
  -L 127.0.0.1:15432:db-admin.internal.example:5432 \
  agent-db-tunnel@bastion.internal.example
```

`ExitOnForwardFailure=yes` prevents the job from proceeding when SSH cannot create the requested listener. Without it, an agent may continue and report a confusing application error instead of a tunnel setup failure. The server alive settings cause SSH to exit after missed replies, which helps when a client host silently loses network connectivity. They do not replace the 20 minute ceiling.

macOS does not ship the GNU `timeout` command by default. Use the timeout facility of your runner, a supervised process with a deadline, or a small wrapper that sends `TERM` and then confirms the process exited. Do not replace a missing deadline with a cron job that kills "old SSH." That sweeps up unrelated work and leaves you unable to prove which connection it stopped.

Require the job to close its tunnel in its own cleanup path as well. For a process it started, retain its PID, send `TERM` when the task completes, wait briefly, then verify the listener disappeared with `lsof` or `ss`. The deadline catches crashes; the cleanup path handles normal completion.

Connection multiplexing needs special caution. With `ControlMaster` and a shared `ControlPath`, separate SSH invocations can reuse one master connection. That saves setup time for a human terminal session. For agent work, it muddles ownership and expiry: one job can inherit a connection approved for another, and closing one client may not close the master. Disable multiplexing for the tunnel account or generate a unique control path per approved job.

A maintenance workflow that needs recurring access should request recurring, separately bounded sessions. Do not leave a tunnel up because creating it again feels inconvenient. The few seconds saved at setup become ambiguity every time somebody investigates an unexpected connection.

## An approval must describe the connection a reviewer can reconstruct

An approval record should let a reviewer answer who authorized a route, what opened it, and whether it stayed inside its stated boundary. A generic ticket comment such as "approved agent access" is not evidence. It does not identify a listener, destination, or duration.

Record the approval before the tunnel starts. Capture the human approver's identity, timestamp, job or run identifier, agent process identity, source machine, dedicated SSH account, and public key fingerprint. Then capture the requested forward in full: direction, bind address, listener port, target host, target port, purpose, and expiry.

The result record needs its own fields. Include whether SSH created the forward, the client process identifier, start time, final exit status, close time, and the reason for closure. A timeout, normal cleanup, operator revocation, and network failure have different meanings during review.

Use structured records rather than prose that forces someone to infer fields. This shape is enough for many internal systems:

```json
{
  "run_id": "run-7c31",
  "approved_by": "oncall-engineer",
  "approved_at": "2025-04-12T14:03:00Z",
  "ssh_account": "agent-db-tunnel",
  "direction": "local",
  "listen": "127.0.0.1:15432",
  "destination": "db-admin.internal.example:5432",
  "expires_at": "2025-04-12T14:23:00Z",
  "purpose": "run migration compatibility check"
}
```

The identifiers in that example are placeholders, not a schema you must copy exactly. The discipline matters more than field names: one record must bind a person, a process, a route, and a time window together.

Do not let the same agent generate the approval and treat it as human authorization. The agent can explain why it wants a connection, but a human or separately governed workflow must make the allow decision. If the action is routine enough to approve automatically, encode that decision in a narrow job definition with an expiry and preserve the rule version that allowed it.

Approval fatigue is a design failure. Asking someone to bless every packet creates meaningless clicks. Asking for one approval per temporary, specific route gives the approver something concrete to judge. Keep the prompt short, but include the bind address and the exact destination. Those two strings catch a surprising number of bad requests.

## Keep credentials outside the agent and authority outside its prompt

An autonomous agent should request a connection, not receive a private key that can create arbitrary connections later. Once an agent has a broad SSH key in its process environment or workspace, it can copy that key, call another SSH client, use it after the approved job ends, or feed it into a tool you did not plan to trust. A prompt that says "only use this for the database" does not constrain any of those actions.

A better design holds SSH credentials in a separate execution boundary. The agent submits a requested action with the route fields. The boundary checks that the vault is available, that the session has authorization, and that the requested credential has any required approval. It performs the SSH action and returns a result without handing the private key to the agent.

Sallyport uses this model on macOS: its bundled `sp-ssh` helper performs SSH actions while SSH keys remain in the app's encrypted vault rather than reaching the MCP agent.

This distinction matters when you review a failure. Credential custody answers, "Could the agent reuse the secret elsewhere?" Route restriction answers, "Could this account reach an unapproved destination?" Approval answers, "Who allowed this particular use?" Teams often collapse these into one control and then discover that their audit record says a key was used without saying what route the key enabled.

Make the request interface narrower than raw SSH arguments. Accept fields such as `destination_host`, `destination_port`, `listen_address`, `listen_port`, and `max_duration`. Reject client supplied options that change proxying, forwarding direction, remote command execution, identity files, or control sockets. If you accept an arbitrary `ssh` command string, you have delegated policy interpretation to string parsing. That approach fails as soon as someone adds `-R`, `-D`, `ProxyCommand`, or a second forward.

Do not expose a general shell as an escape hatch for a tunnel workflow. The agent does not need to run `ssh` interactively when the action consists of creating one constrained channel. Narrow actions are easier to approve and easier to revoke.

## Audit evidence must survive a hostile review

Tunnel logs are useful only if a compromised agent or local process cannot rewrite them after the fact. Plain text logs beside the agent workspace are convenient, but they prove very little when the same principal can edit both the tunnel command and its history.

Log the approval event, request event, execution result, and closure result as separate events with a shared run identifier. Preserve the original request fields rather than only recording a rendered command line. A command line can hide values in a config file or omit defaults that affected the listener.

Collect corroborating records from more than one place. The bastion's SSH logs can show accepted authentication and forwarding failures. The job runner can show process creation and exit. The destination service can show a connection from the bastion. None of these alone explains the whole event, but together they expose contradictions.

For example, an approval may authorize `127.0.0.1:15432` to one database for 20 minutes. If the client log says it closed at 14:23, but the destination sees traffic at 15:10 from the bastion, investigate immediately. The agent may have opened another connection, reused a shared master session, or someone may be using a different path with the same account.

Sallyport projects session and individual call journals from an encrypted, hash-chained audit log, and `sp audit verify` checks that chain offline without requiring the vault key. That is a stronger property than a log file an agent process can alter, though it does not excuse weak account restrictions.

Run verification during review, not only after an incident. A tamper-evident chain tells you whether recorded history has continuity. It cannot tell you that you recorded enough fields in the first place. Decide what you will need to know during a bad day, then ensure those fields enter the event before the connection begins.

## Revoke the path before you investigate the story

When you suspect a wrong or abused tunnel, stop the connection path first. Investigation can wait a few minutes; an exposed route can move data or enable lateral access while people debate which log is authoritative.

Terminate the client process and confirm that its listener disappeared. Disable the dedicated SSH account or remove its public key on the accepting server. If the target service uses a credential that traveled through the tunnel and might have been exposed to an untrusted process, rotate that credential according to the service's own procedure. Do not rotate unrelated credentials merely because a tunnel existed.

Then preserve evidence. Save the approval record, requested route, process logs, SSH server logs, destination service connection records, and closure result. Record the times in one timezone, preferably UTC. People lose hours in incident review when a job runner uses local time, a bastion uses UTC, and an application writes timestamps without a zone.

Check for a second listener before declaring the event contained. On the agent host, inspect the requested port and nearby forwarding processes. On the bastion, inspect active SSH sessions and authentication logs for the dedicated account. At the destination, look for connections that began after the intended expiry. The goal is to find surviving access paths, not to produce a tidy narrative quickly.

If a human approval enabled the wrong route, fix the request surface first. A better review screen that still accepts arbitrary SSH flags will reproduce the same error. If a server restriction failed, test the exact rejected command after you repair it and retain the failure output. Controls earn trust when you confirm that they deny the route you meant to deny.

## Test denial paths before an agent depends on the tunnel

A tunnel design is ready only after you test both the approved connection and the forbidden variations. Happy path tests catch typos. Denial tests tell you whether the boundary exists.

Use a nonproduction account and attempt a forward that should work:

```sh
ssh -N -o ExitOnForwardFailure=yes \
  -L 127.0.0.1:15432:db-admin.internal.example:5432 \
  agent-db-tunnel@bastion.internal.example
```

Then attempt a destination that `PermitOpen` does not allow:

```sh
ssh -N -o ExitOnForwardFailure=yes \
  -L 127.0.0.1:15433:admin-api.internal.example:443 \
  agent-db-tunnel@bastion.internal.example
```

The second command should fail during forwarding setup. Capture the client output and the server log entry. Next, attempt `-R` and `-D`; both should fail for an account configured with `AllowTcpForwarding local`. Finally, ask for a nonloopback listener with `0.0.0.0` and confirm that your request boundary rejects it before SSH runs, or that the local environment prevents the exposure you intend to prevent.

Test expiry with a short duration. Start the approved tunnel, let the supervisor deadline fire, and verify three things: the SSH process exits, `lsof` no longer finds the listener, and the closure record names the deadline rather than pretending the task ended normally. This small exercise finds the orphaned process problem before production work depends on a tunnel that never closes.

Keep a simple rule when pressure rises: an autonomous agent may receive the minimum route required for its current job, for a bounded time, under a named approval. If your setup cannot state that route in one line and prove its closure afterward, the agent has more network authority than the task justifies.
