8 min read

AI agents SSH access: remote commands without private keys

AI agents SSH access should use mediated remote actions, not copied private keys. Build approval, server limits, host checks, and audit trails.

AI agents SSH access: remote commands without private keys

Giving an AI coding agent an SSH private key is the wrong abstraction. The agent does not need a durable credential that can authenticate from any machine at any later time. It needs permission to perform a specific remote action while you can still see, stop, and explain that action.

That sounds like a small change in architecture. It is not. It separates a secret that can travel forever from an SSH request that has a caller, a destination, a command, a result, and an owner.

I have seen teams spend days tightening prompt rules around an agent, then leave ~/.ssh/id_ed25519 readable by the same process. The prompt rules are decoration once the private key is available. A shell command can copy the file, print it, archive it, or send it somewhere you will not discover for weeks.

A private key is not an API token with a shorter name. It is a reusable authority token for every SSH server that accepts its public half.

Give the agent an action, not an identity

An agent should ask for an SSH operation, while a separate trusted component owns the identity and establishes the connection. That component receives structured input such as a destination, remote account, command, arguments, and timeout. It checks the request, authenticates to the server, captures the result, and returns only the result to the agent.

The secret stays on one machine. More importantly, the right to use it no longer follows the agent process around.

That distinction changes the failure mode. If an agent gets compromised or follows hostile instructions from a repository, it may still request a bad command. It cannot quietly export the private key and reuse it from a disposable VM next month. You have reduced an open-ended credential theft problem to an authorization and command-control problem.

The second problem still deserves respect. It is simply a problem you can operate.

A useful request shape is deliberately boring:

{
  "host": "deploy-01.internal.example",
  "user": "release",
  "command": "/usr/local/libexec/release-service",
  "args": ["api", "2025.03.08-4f2c1a7"],
  "timeout_seconds": 120
}

Do not send a shell string such as ssh deploy-01 'cd /srv/api && git pull && sudo systemctl restart api' through five layers and call that control. The shell parsing rules become part of your security boundary, and almost nobody reviews them that way. Keep the command path fixed, pass arguments as separate values, and make the remote wrapper reject values outside its expected grammar.

I prefer an action contract that is slightly irritating to use over a flexible remote terminal that nobody can bound. The irritation appears during setup. The damage from an unrestricted terminal appears later.

The broker must return a result with enough shape for the agent to continue work without guessing:

{
  "exit_code": 0,
  "stdout": "released api version 2025.03.08-4f2c1a7\n",
  "stderr": "",
  "duration_ms": 1842
}

Do not return credentials, agent sockets, known_hosts contents, or an interactive TTY. Those are implementation details of the trusted side.

Possession, signing, and execution are different powers

Teams often use "SSH access" as though it described one thing. It describes at least three materially different powers: possessing a private key, asking an agent to create a signature, and causing a broker to execute a named remote command.

Private-key possession is the broadest. Whoever reads the file can copy it indefinitely and try it against every reachable server. File permissions help, disk encryption helps, and passphrases help, but none of them answer the central question: why did an AI process need a portable credential in the first place?

An ssh-agent removes the need for every client to read a private-key file. The process can request a signature through SSH_AUTH_SOCK. That is an improvement for local workstation hygiene, but signing still grants authentication power. The OpenSSH ssh-agent(1) manual says the agent keeps private keys out of network transit during forwarding, while the forwarded requester receives the result of identity operations. That is a useful property, not a complete authorization model.

Action execution is narrower when you build it narrowly. A broker can refuse an unknown host, disallow a different remote account, require a human decision, cap execution time, refuse a PTY, and keep a record of the call. It can also use a different SSH identity for every class of operation.

That last detail matters more than teams expect. One identity that can read logs, deploy code, edit /etc/sudoers, and open tunnels creates an authorization boundary the size of your whole fleet. Four identities with narrow server-side restrictions create four smaller problems.

I would rather rotate four narrowly scoped identities after a mistake than spend a weekend proving that one administrator identity did not reach every host.

This pattern does not make a harmful command harmless. It gives you places to catch it: before the connection, at server authentication, inside the forced remote command, and in the record afterward.

SSH agent forwarding solves a different problem

SSH agent forwarding avoids copying a private key to a jump host, but it does not make a remote machine safe to hand to an agent. The remote host receives access to a socket that can request operations from your local authentication agent.

OpenSSH says this plainly in ssh_config(5): agent forwarding should be enabled with caution because a user who can bypass permissions on the remote host can use the local agent through the forwarded connection. The attacker cannot extract the private-key bytes through that interface, but they can ask the loaded identities to authenticate.

That caveat becomes sharper with autonomous tools. An agent may connect to a host because a task asked it to inspect a log. A malicious repository instruction, a compromised remote account, or a poorly contained command can then find SSH_AUTH_SOCK and use the forwarded signing capability. The private key remains technically secret while the authentication authority gets abused. The victim will not feel much comfort in that distinction.

Do not enable ForwardAgent yes globally. Put ForwardAgent no in your baseline client configuration and add an explicit exception only for a named human workflow that truly needs it.

Host *
    ForwardAgent no
    AddKeysToAgent no
    IdentitiesOnly yes
    StrictHostKeyChecking yes

Host legacy-bastion
    HostName bastion.internal.example
    User ops
    ForwardAgent yes

The exception above still needs a reason, an owner, and a removal date. A permanent forwarding exception tends to become invisible because SSH makes it work so smoothly.

Destination-constrained identities improve this situation, but they are not a substitute for an action boundary. The OpenSSH ssh-add(1) manual explains that destination constraints test the entire connection path when a cooperating client and server forward the agent. It also warns that someone with a remote SSH_AUTH_SOCK can forward that socket again, though use remains limited to permitted destinations.

Use destination constraints where they fit. Do not mistake them for a command policy. They say where an identity can authenticate, not whether rm -rf /srv/release-cache should run after it arrives.

Follow the failure through the command path

A plausible incident starts with a well-meaning shortcut: a developer places a deployment private key in an environment variable because the coding agent needs to run one release command. The agent reads a repository issue that includes a suggested diagnostic command. The command prints the environment for troubleshooting, and the execution transcript lands in a local session log.

The first loss happened before SSH. The private key entered a process that did not need to hold it.

The agent now has several ways to leak or reuse it. It can write the value to a temporary file. It can send it to a remote host with scp. It can place it in a Git commit. It can hand it to a subprocess outside the boundaries you assumed. A private key copied once does not expire merely because you stopped that agent run.

Suppose the team used agent forwarding instead. The repository instruction tells the agent to connect to build-02, then runs a command that exposes the forwarded socket to a local process on that remote machine. The attacker cannot print the private key, but can ask the socket to authenticate to another host with an identity loaded in the developer's agent. The forward was never meant as a general delegation mechanism, yet it became one.

Then consider the brokered design. The agent sends a request for release-service api 2025.03.08-4f2c1a7 on deploy-01. The broker sees that this is the first request from a new agent process and asks for authorization. The person approving sees the code-signing authority of the calling process, the remote identity, and the target. The broker connects only to the named host. The server accepts only a restricted deployment identity and invokes one server-side wrapper.

The hostile instruction can still request a release. It cannot turn that request into a login shell, a port forward, a copied private key, or a trip through unrelated servers.

That is a major reduction in blast radius, but it costs you flexibility. A generic agent can improvise around odd production states. A constrained action interface cannot. You will need to add deliberate operations for logs, service status, rollback, migration checks, and artifact cleanup. Someone must own those interfaces. (That work is not optional; unrestricted SSH merely hides it.)

The right response is to make the safe path cover the jobs people actually do, not to reopen a raw shell every time the safe path lacks one feature.

Put the SSH client behind a local decision point

Return results, not keys
Your agent receives SSH output, while the app keeps the private identity out of its process.

A local decision point should own three things: the encrypted private identity, the permission to use it, and the evidence produced when it does. The agent should own none of those.

Sallyport follows this pattern for macOS agents: its bundled sp mcp shim accepts regular MCP calls, while the menu-bar app holds SSH identities in its encrypted vault and uses sp-ssh to perform the connection. The agent receives the command result rather than the credential.

The shape is more important than the particular implementation:

AI agent process
    -> local action request
        -> authorization decision
            -> SSH helper using protected identity
                -> remote sshd and restricted account
                    -> stdout, stderr, exit status

Keep the decision point on the developer's managed machine when a human needs to supervise the work. Do not turn it into a network proxy that silently signs every request from every process. The local process boundary lets you attribute the caller and reject an unexpected executable before any network connection begins.

Process identity is not a decorative field in an approval card. A request from the signed application you expected is different from a request from an unsigned binary launched from /tmp, even if both claim to be "the coding agent." On macOS, code-signing authority gives the approver something concrete to evaluate.

I want a new process to earn a session before it can use an SSH identity. I also want that session to vanish when the process exits. Long-lived grants are easy to operate and hard to explain after a compromise.

A vault lock should deny every SSH action. There should be no partial fallback to a cached private key, exported PEM file, or helper process with its own hidden copy. If your emergency path bypasses the protection, it will eventually become the normal path.

Make the remote account boring on purpose

The remote server must limit what a successful SSH authentication can do. A protected private key is only one half of the control; the server must treat that identity as purpose-built rather than as a human administrator account.

Create a separate Unix account for the action, such as release, diagnostics, or backup. Do not give it a personal shell profile, a broad home directory, or password login. Do not put its public identity beside an engineer's unrestricted personal identity in the same authorized_keys file and call the accounts separate.

For a release operation, an authorized_keys entry can look like this:

restrict,command="/usr/local/libexec/release-service" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleOnlyReplaceThis release-broker

The restrict option disables port, agent, and X11 forwarding, PTY allocation, and execution of ~/.ssh/rc. The OpenSSH sshd(8) manual documents restrict specifically for this purpose and shows it used with a forced command.

The forced command must not hand the original command line to sh -c. It should ignore SSH_ORIGINAL_COMMAND unless you have a narrow parser with tests. The wrapper should accept a fixed protocol, validate every field, write an audit entry, and invoke the real operation with explicit argument boundaries.

A small shell wrapper is acceptable if it stays small:

#!/bin/sh
set -eu

service=${1:-}
version=${2:-}

case "$service" in
  api|worker) ;;
  *) echo "unsupported service" >&2; exit 64 ;;
esac

case "$version" in
  *[!0-9A-Za-z._-]*|"") echo "invalid version" >&2; exit 64 ;;
esac

exec /usr/bin/sudo /usr/local/sbin/deploy-approved-release "$service" "$version"

Call that wrapper with fixed arguments from the broker. Do not let the client choose its path. If you need more operations, create more wrappers or one small command dispatcher that accepts named subcommands and rejects everything else.

This is where I draw a hard boundary: do not attach an autonomous agent to a shared ops account with a normal shell and compensate with a better prompt. A shared shell account makes every server-side safeguard voluntary.

For stateful work, use idempotent operations. A release wrapper should check whether the requested artifact is already active, whether the service is healthy, and whether a rollback point exists before it changes anything. The agent may repeat a request after a timeout; your remote operation should not interpret repetition as permission to perform the action twice.

Host verification and command grammar deserve equal care

Skip policy-language rules
Use the fixed three-control decision ladder instead of writing rules for every agent request.

A private identity protects client authentication. It does not prove that the agent reached the intended server. If your automation accepts any new host fingerprint, a DNS mistake, a poisoned hosts file, or an active network attacker can redirect the connection to a server that will receive your command and learn its output.

Use StrictHostKeyChecking yes for automation identities. Ship reviewed host fingerprints through configuration management, or maintain a carefully controlled known_hosts file. When a host rotates its SSH host key, perform an explicit change with an out-of-band verification step. Do not teach the agent to answer "yes" to the host-key prompt.

A minimal client profile for a noninteractive action often looks like this:

Host deploy-01.internal.example
    HostName deploy-01.internal.example
    User release
    IdentityAgent /path/to/broker.sock
    IdentitiesOnly yes
    StrictHostKeyChecking yes
    UserKnownHostsFile /Library/Application Support/agent-ssh/known_hosts
    BatchMode yes
    RequestTTY no
    ForwardAgent no

IdentitiesOnly yes prevents SSH from spraying every identity available through an agent at the destination. This reduces noisy authentication attempts and avoids accidental use of a personal identity when the intended action identity fails. The OpenSSH ssh_config(5) manual describes it as a way to use configured identity files rather than extra identities offered by an agent or provider.

Do not make host names agent-selected if you can avoid it. A request should refer to an approved logical target, such as production-api-release, and the trusted side should map that name to a hostname, remote account, host fingerprint, identity, and command family. The map is policy in the ordinary sense, but it should be data a human can inspect rather than a clever language that encourages loopholes.

Command grammar needs the same restraint. Allowing arbitrary arguments to a fixed binary can still be arbitrary execution if the binary accepts file paths, plugin names, configuration imports, or --exec flags. Inspect the actual CLI. Write down the allowed tokens. Test malformed input, whitespace, shell metacharacters, Unicode surprises, and repeated flags.

Twenty minutes spent abusing your own wrapper with ugly arguments will find more than another approval prompt.

Approve authority at the moment it becomes useful

Human approval belongs at the boundary where a process first gains an SSH capability, not after every harmless command and not only after a destructive command has already reached the server.

A per-session approval is a practical default for an agent run. The first attempted SSH action from a new process triggers a decision. The approval should state which signed process asked, which identity it wants to use, and what class of remote access it will receive. If approved, that exact process can make allowed requests until it exits.

This avoids turning routine diagnostics into a pile of clicks. It also avoids the worse alternative: one approval that applies to every future process named node, python, or claude on the machine.

Some identities deserve a second gate on each use. Production deploy, emergency restart, database export, infrastructure mutation, and commands that can change network reachability are reasonable candidates. The cost is interruption. The benefit is that a compromised session cannot quietly use the most dangerous identity after you approved a low-risk status check.

Sallyport can place a per-call approval requirement on an individual SSH identity, while its normal session authorization associates the grant with a particular agent process. Its vault gate also denies actions while locked, rather than allowing a background agent to keep operating after the person walks away.

The approval prompt must not ask users to read a paragraph of serialized JSON. Show a concise action summary, then make details available: destination, remote account, fixed command name, supplied arguments, and timeout. Humans can spot production-db versus staging-db quickly. They cannot reliably spot danger in a full shell pipeline after the tenth prompt.

A denial should be first-class output. The agent needs an explicit refusal result so it can report that it lacked permission rather than retrying with another account, another host, or a workaround. Repeated attempts after denial deserve a visible record.

An audit trail must survive the compromised caller

Use sp-ssh for connections
The stateless sp-ssh helper makes the connection while the encrypted vault holds the key.

Agent-generated logs are not enough. If the agent process can write or delete the record of its own SSH calls, it can leave the pleasant version of history behind.

Record activity outside the agent process. At minimum, retain the calling process identity, session identifier, time, destination, remote account, action name, arguments after redaction rules, authorization result, exit code, duration, and a bounded capture of stdout and stderr. Capture the first and last 50 lines for noisy commands, or store a content hash plus a protected full artifact when output may contain secrets.

Do not log raw private keys, bearer tokens, or arbitrary environment variables. An audit system that creates a second credential leak is worse than a missing audit system because people will rely on it.

Tamper evidence changes how you investigate. A chained log can reveal that someone removed or reordered records after the fact, while a normal append-only text file usually proves only that a file exists. Verification should work without requiring the vault to be unlocked; otherwise you cannot examine the record when the system is in the very state where you most need evidence.

CISA's red-team assessment guidance offers a concrete reason to care about credential containment. Its published assessment describes a team obtaining dozens of private SSH keys from accessible files and using privileged identities for movement across Linux systems. The lesson is not that every agent will become an intruder. The lesson is that reusable SSH material accumulates into a lateral-movement inventory when it reaches places that do not need it.

When an unexpected command appears, revoke the agent session first. Then disable the affected identity at the server, inspect the verified activity record, and review the target host's sshd and system logs. Rotate the private identity if there is any chance it left the protected boundary. A brokered model makes this sequence faster because revoking a session stops future action requests without waiting for a copied private key to be found.

Use four narrow paths instead of one general shell

The practical rollout is to inventory the remote actions your agents already attempt and sort them by consequence. Do not begin by asking which private keys they need. That question starts at the wrong end.

Most teams find a small set of recurring operations:

  • Read a bounded service status or recent log excerpt.
  • Deploy an already built artifact to one environment.
  • Run a migration check that does not alter data.
  • Restart a named service after a release.
  • Collect diagnostics for a failed job.

Build one action path for each. Assign a dedicated remote account or identity where the servers differ. Force the command server-side. Pin the host identity. Decide whether the action needs session approval or every-use approval. Record both allowed and refused calls.

Keep a human SSH path for unusual repair work. Humans sometimes need an interactive shell during an incident, and pretending otherwise pushes them toward unsafe escapes. That path should use a separate personal identity, MFA where available, a jump host where appropriate, and normal incident controls. It should not share the automation identity just because sharing saves a line in authorized_keys.

The safer pattern does require more initial engineering: wrappers need tests, host fingerprints need ownership, and your action catalog needs maintenance. Take that cost. The alternative is granting a probabilistic text generator a credential that can outlive every guardrail you put around its current task.

Start with the SSH private key closest to production. Remove it from the agent's reach, replace one broad shell command with one constrained action, and inspect the resulting record after a real run. That first boundary will show you exactly where the rest of the work is hiding.

FAQ

Can an AI agent use SSH without accessing a private key?

An agent can run SSH commands without receiving a private key if a separate local broker holds the identity and performs the SSH handshake itself. The agent submits a host, account, command, and arguments; the broker decides whether to authorize the request and returns stdout, stderr, and an exit code. The distinction matters because a copied private key can be reused anywhere, while a mediated action can be stopped, limited, and recorded.

Is SSH agent forwarding safe for AI agents?

SSH agent forwarding keeps the private key off the remote host, but it exposes a signing capability through a forwarded Unix socket. A process that can reach that socket may authenticate with identities loaded in your local agent. OpenSSH warns that users able to bypass permissions on the remote host can use the forwarded agent for authentication operations.

Should an AI agent have its own SSH identity?

Use a separate SSH identity for each automation boundary: production deploys, read-only diagnostics, backups, and repository maintenance should not share one broad account. Give each identity only the server access and forced command it needs. A general administrator identity is convenient, but it makes every agent mistake much more expensive.

How do I restrict an SSH identity to one command?

A forced command works when the task has a narrow shape, such as deploying one service, collecting a health report, or rotating a controlled artifact. Put restrict,command="..." before the public identity in authorized_keys, then validate any arguments in the server-side wrapper. Do not rely on a forced command that passes an unquoted SSH_ORIGINAL_COMMAND into a shell.

Should an AI agent ever SSH as root?

Usually, no. Put a separate service account on the host, prohibit direct root login, and grant only the sudo subcommands that the wrapper requires. If a task truly needs root, make that privilege visible in one small server-side program rather than giving an agent an interactive root shell.

When should SSH actions require human approval?

Per-session approval is appropriate when a known agent process needs several low-risk calls during one bounded run. Per-call approval fits identities that can deploy, change firewall rules, access production databases, or run destructive maintenance commands. Approval is useful only when the prompt tells you which signed process is asking and what account and host it will use.

How should an agent verify SSH host identities?

Use pinned or reviewed host fingerprints in known_hosts, set StrictHostKeyChecking yes, and make unknown hosts fail rather than adding them automatically. Do not let an agent accept a changed host fingerprint just to finish a task. A changed host identity is an incident until someone proves otherwise.

What should I log for AI-driven SSH commands?

Useful records include the requesting process identity, session identifier, destination host, remote account, exact command and arguments, approval decision, start and finish time, exit status, and captured output policy. Record denials too. A successful command without a caller identity is weak evidence during an investigation.

What should I do if an AI agent was given an SSH private key?

Rotate the affected identity, remove its public half from every authorized location, terminate active sessions, and review command history and target logs. If the private key reached a model prompt, a repository, a chat transcript, or a build artifact, treat every copy path as exposed. Then redesign the path so the agent requests actions rather than receiving reusable credentials.

Is a local Mac SSH gateway suitable for CI or headless automation?

A Mac-only local broker is a good fit when developers run coding agents on managed Macs and need a human present for sensitive actions. It is a poor fit for unattended CI runners, Linux build hosts, or server-side jobs that cannot use a local desktop approval boundary. Those cases need a different execution boundary, such as short-lived workload identities and a controlled runner.

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