8 min read

Stateless SSH helpers for safer AI agent workflows

Stateless SSH helpers give AI agents fresh connections, clearer audit trails, and less cross-run authority carryover without hiding remote risk.

Stateless SSH helpers for safer AI agent workflows

Autonomous coding agents should not carry an SSH session from one run into the next. A fresh local execution context for every action makes it harder for an earlier run to lend authority, identity, or unexplained side effects to a later one. It also gives reviewers a clean unit of evidence: this process requested this command against this host, and the helper returned this result.

That does not make SSH harmless. A new connection does not undo a deployment, stop a background process, or protect a server account with far too much access. Stateless execution solves a narrower, very practical problem: it removes local carryover that agents are unusually good at exploiting by accident.

Stateless SSH helpers remove local carryover, not remote state

A stateless SSH helper creates the connection needed for one action, runs that action, returns its result, and exits without preserving an authenticated transport for later calls. The next action starts again with a new helper process and a new connection attempt.

The distinction matters because people often say "stateless SSH" when they mean "nothing persists anywhere." That is false. SSH has several places where state can live:

  • The client can keep a master connection, multiplexing socket, known-hosts entry, agent socket, configuration, or temporary files.
  • The remote host can keep a working directory, shell history, uploaded artifact, lock file, service process, package cache, and changed database.
  • The authorization system can keep an account, an SSH certificate, a public key, and permissions.

A stateless helper concerns the first category. It refuses to let a later agent call quietly inherit an earlier call's live connection or local credential context. The remote side remains a real computer with real consequences.

This is why "fresh connection" is a better operational phrase than "fresh server." The helper is fresh. The server is not.

Suppose an agent runs a deployment check, then exits after a reviewer rejects its next proposed change. If the first run left a multiplexing socket behind, another process on the same machine may attach to an already authenticated channel. The next process has skipped a security boundary that operators thought applied per run. With a stateless helper, that second process must request a new action and pass through the intended authorization path again.

The benefit is partly security and partly diagnosis. When an incident reviewer sees a shared connection that lived for hours, they must reconstruct which of several processes used it. When every request owns a connection lifecycle, the record has a natural beginning and end.

Connection sharing conflicts with run-level attribution

OpenSSH deliberately supports connection sharing. Its ssh_config manual describes ControlMaster as allowing multiple sessions to share a single network connection. ControlPersist can keep the master connection open in the background after clients exit. Those options are useful for an administrator issuing several commands from one terminal. They are a poor default for agent runs that need separate authority and separate records.

Consider this common configuration:

Host build-box
  HostName 10.0.0.24
  User deploy
  ControlMaster auto
  ControlPath ~/.ssh/cm-%r@%h:%p
  ControlPersist 15m

The first SSH invocation creates an authenticated master socket under ~/.ssh. Later invocations that can access that socket may reuse its connection during the fifteen-minute persistence window. An agent process launched after the original process exits can look like it made an independent connection in its own transcript while it actually rode a transport established earlier.

This creates four separate problems.

First, approval meaning drifts. If a person approved a particular agent process to run a check, that approval should not automatically cover a new process that happens to find the same socket.

Second, destination identity becomes harder to inspect at the right time. SSH host verification occurs when the master connection forms. Later clients inherit that outcome. A reviewer examining a later call has to locate an older connection event to know what host verification occurred.

Third, logs lose a simple relationship between a request and a transport. A traditional SSH server log may record one login while application-level records show many commands. Both may be accurate, but correlating them becomes work during the moment when nobody has spare time.

Fourth, the socket itself becomes an asset. OpenSSH warns in ssh_config that anyone who can access the control socket can access the connection. File permissions help, but they do not make a shared authenticated channel fit a hostile or merely confused workspace.

Disable multiplexing for calls that need clean attribution. A direct invocation can make that decision explicit:

ssh -o ControlMaster=no -o ControlPath=none deploy@build-box 'id; hostname'

The expected output has the command's own shape, for example:

uid=1004(deploy) gid=1004(deploy) groups=1004(deploy)
build-box

The command proves only which account and host answered. It does not prove that the account had appropriate rights or that the requested task was safe. Still, it gives a reviewer one action to match with one connection attempt.

Do not mistake an absent ControlMaster line for a complete fix. A global SSH configuration can set it, a wrapper can add it, and a process can point ControlPath somewhere unexpected. The execution boundary should set its own SSH options rather than trusting the repository's dotfiles.

Each agent run needs its own authority boundary

An agent run is not a human terminal session with a faster typist. It may receive new instructions from issues, pull requests, generated test output, copied log text, and source files it did not author. Any of that content can steer it toward commands that were not part of the original intent.

That changes the meaning of convenience features. A person who retains a session normally understands they are retaining it. An agent may start a later task without any awareness that an old channel remains available. Its tool interface often presents only "run command," while the underlying client silently finds a socket and obtains prior authority.

Treat the agent process as the unit that receives approval. When it exits, its access should end with it. If a new process starts, even one launched by the same editor or orchestrator, require a new authorization decision before it reaches a protected host.

This approach is stricter than many developers want at first. They see repeated approvals and reach for a broad allowlist. That fixes an annoyance by removing the useful boundary. Better design groups related actions into an explicit run, grants that run access after a reviewer sees its identity, and drops access when the run ends.

Code-signing identity matters here, but it does not answer every question. It can tell a reviewer which signed program launched the process. It cannot tell them whether the program is acting on trustworthy instructions or whether its current repository contains a malicious prompt. Approval should bind a known process to a limited period of authority, not bless every instruction that process will ever read.

A clean boundary also handles retries properly. A network failure can prompt a new request. The gateway should log that a first action failed before execution or during transport, then log the retry as another action. Collapsing retries into one vague success record removes information that incident responders need.

Agent forwarding defeats the credential boundary

SSH agent forwarding gives a remote host a path to ask your local authentication agent to sign challenges. It does not copy the private key file to that host, but that distinction can sound safer than it is.

The OpenSSH ssh manual warns that users with the ability to bypass file permissions on the remote host can access the forwarded agent. A root user on that host may use the forwarded socket to authenticate elsewhere while the connection remains open. They still do not receive the private key material, but they can use the authority behind it. For an autonomous agent, that is usually the risk you meant to avoid.

Avoid calls such as:

ssh -A deploy@build-box 'git fetch && ./deploy.sh'

The -A flag forwards the local authentication agent. A deployment script that reaches a second machine can then cause the remote host to request signatures through the forwarded socket. A compromised build machine, or a script changed by an untrusted contributor, gets an unexpected route into another environment.

Use a credential that belongs to the destination task instead. That may mean a deploy account with a restricted public key, a short-lived SSH certificate issued for one environment, or a remote service account that can fetch only the artifact it needs. The exact mechanism depends on your infrastructure. The discipline does not: do not turn one agent's approved action into general authentication access from a remote machine.

Some teams keep forwarding because nested SSH is convenient. They have a bastion host, then jump onward to private hosts. Use ProxyJump or a tightly controlled gateway route where possible. Those patterns keep the client in control of connection establishment rather than placing a powerful local agent socket on the intermediate host.

Forwarding ports deserves the same suspicion. Local, remote, and dynamic forwarding can make an approved command an open tunnel that outlives the action's useful work. A stateless helper should reject or separately approve forwarding features unless the requested task specifically needs them.

A precise remote account limits the damage of bad instructions

Use MCP without exposing keys
Give agents an MCP route for SSH actions while credentials remain encrypted inside the macOS app.

Fresh local state does not help when the remote login can do anything. The account an agent reaches should have a stated job and permissions that match it.

For a deployment host, that might mean an account that can switch a release symlink, restart one service, and read one deployment directory. It should not also have blanket passwordless privilege escalation, access to every user's home directory, and permission to modify the CI runner. Those combinations show up because someone wanted an automation task working before lunch. They survive because nobody comes back to narrow them.

OpenSSH gives server operators several controls in the authorized_keys format. The manual documents options such as command=, which forces a command when the key authenticates, and options that disable port forwarding, X11 forwarding, and agent forwarding. These controls are useful when the destination has a small, stable command surface.

For example, an operations team can attach a dedicated public key to a constrained remote wrapper:

command="/usr/local/libexec/release-action",no-port-forwarding,no-X11-forwarding,no-agent-forwarding ssh-ed25519 AAAA... agent-release

The forced command should parse requested arguments defensively. Do not pass raw user text into a shell. A safe wrapper accepts a small set of verbs, validates release identifiers against an expected format, writes an audit record, and executes the matching fixed operation. If the agent needs arbitrary shell access, admit that openly and keep the account's permissions narrow enough for that risk.

A forced command is not a generic policy language. It is a deliberately small interface on one host. That constraint is its strength. You can test it, review it, and identify which actions it can perform without interpreting a pile of natural-language rules.

Be careful with sudo. A line that permits a script can still become broad access if the script accepts arbitrary paths, loads configuration from a writable directory, invokes an editor, or calls another program through a user-controlled environment variable. Read the full call chain. The permission line is the beginning of the review, not the end.

The action record should answer who, what, where, and result

A useful audit trail does more than state that SSH happened. It must let a person reconstruct an action without guessing which agent run owned it.

Record the agent run identifier, its process authority, the reviewer decision, destination, remote account, requested command or operation, time, outcome, and a reference to output. Capture the resolved host identity when your design can expose it. Record a denial too. A denied action is evidence that the boundary operated as intended, and it can reveal a bad instruction before damage occurs.

Commands need special handling. The raw command can contain passwords, access tokens, query values, and private paths. Redacting everything makes the record useless, while retaining everything can turn the log into another secret store. The practical answer is to design the action interface so sensitive values do not appear in arguments in the first place. When redaction is necessary, record both the redaction event and enough structured context to identify the operation.

Output has the same problem. An error from a deployment command may print an environment variable or a credential-bearing URL. Keep output separate from the core event record, restrict who can read it, and treat it as potentially sensitive input to the next agent run. Do not automatically feed a full production transcript back into an agent just because it asked to diagnose a failure.

There is another distinction worth keeping sharp: a log that says an agent requested an action is different from a log that proves the action reached the remote host. Transport failures, host-key rejection, remote authentication failure, command exit status, and lost connections are distinct outcomes. A good record gives each outcome its own status rather than filing all of them under "failed."

Tamper evidence changes the trust model of the record. A hash-chained log can show that entries have been altered, removed, or reordered when verification fails. It cannot tell you that the original command was wise, and it cannot make a compromised endpoint truthful. It protects the history you have; it does not replace system hardening.

A failed deployment shows why fresh state helps

Choose the right approval granularity
Keep session approval for bounded work, and turn on per-call approval for keys needing closer review.

Imagine a coding agent receives a request to deploy a branch to a staging host. Its first call opens an SSH connection, checks disk space, and starts a release command. The release command fails because a migration lock exists. The agent sees the failure, reads repository notes, and receives a copied message that tells it to "clear the lock and retry with the emergency account."

That message may be an innocent but unsafe suggestion, or it may be prompt injection hidden in a file the agent was asked to inspect. Either way, the agent now proposes a command outside the original deployment routine.

With a long-lived shared connection, several things can go wrong. The original transport may still authenticate as a broad deploy account. A new process may reuse it. A reviewer sees only the first connection approval and may not see the later escalation clearly. If the setup also forwarded an SSH agent, the staging host may have an avenue to use another identity.

With a stateless helper and per-run authorization, the agent's next call starts as a new request. The boundary identifies the new agent process, records the actual proposed command and target, and asks for approval if the run is not already approved. A reviewer can reject the emergency account request and instead approve a constrained operation that inspects the lock owner.

The connection freshness did not decide that deleting a lock was unsafe. Humans still need to judge operations. It prevented an old approved channel from quietly making the decision irrelevant.

This is why a stateless SSH design belongs next to, rather than instead of, sensible command constraints. Connection isolation limits inherited local authority. A restricted remote interface limits what a new connection can do. Audit records let people understand both decisions later.

Stateless execution needs explicit operational design

Put sensitive keys per call
Require Touch ID or one click for every use of SSH keys marked for per-call approval.

A helper that opens a connection for every request needs clear behavior around host verification, timeouts, cancellation, and failure reporting. Leaving those details to whatever the agent's environment happens to provide recreates hidden state under another name.

Verify host keys. OpenSSH's StrictHostKeyChecking=yes tells the client to reject hosts whose key is unknown or changed rather than asking interactively. That is normally the correct posture for a protected automated action. Provision trusted host keys through a controlled process, then fail closed if the destination does not match.

Use bounded timeouts. A stalled SSH connection should eventually return a recorded failure, not remain available indefinitely as a half-finished session. The helper should also terminate child processes on cancellation where the operating system permits it, and report whether it can confirm that termination. Do not report a remote command as cancelled when the transport dropped after the server had already started it.

Keep the request shape small and inspectable. An action boundary can represent an SSH request with structured fields like destination, account, command, working directory if supported, and timeout. It should not accept a blob of shell configuration that changes identity files, proxy behavior, control socket paths, and forwarding options without review.

A gateway can hold the SSH credential while the agent asks for an action. Sallyport follows this model for SSH through its bundled stateless sp-ssh helper: the agent does not receive the SSH key, and the app performs the action instead.

The boundary should also distinguish a session approval from a per-call approval. Session approval suits a bounded agent run where a reviewer accepts a sequence of expected work. Per-call approval suits credentials or destinations where every use deserves a separate human decision. Do not pretend those choices provide the same control. They intentionally trade interruption against granularity.

Test for hidden reuse before trusting the boundary

You can test whether your setup actually creates independent connections. Do it in a nonproduction environment with an account that has no sensitive access.

First, start two separate agent or helper processes that each run a benign command such as id. Check the SSH server's authentication log and your action log. You should see two connection attempts that you can map to two separate process records.

Then inspect the client side for multiplexing sockets. On macOS and Linux-like systems, a broad inspection may look like this:

find ~/.ssh -type s -print

If it prints a path that corresponds to a control socket, identify which configuration created it. Do not delete a socket blindly on a shared machine; first find the owner and active clients. Remove connection-sharing settings from the isolated helper's execution path rather than relying on cleanup after the fact.

Finally, run one approved action, end the agent process, and initiate a second process. The second process should not inherit the first process's authorization, authentication transport, or ability to invoke a remote shell without a new decision. Test the denied path as carefully as the approved path. Teams often discover that their happy path is isolated while an error handler falls back to a direct SSH command.

That last check catches the familiar failure: a careful gateway handles normal requests, but a retry script or diagnostic tool bypasses it when pressure rises. The bypass is usually added by someone trying to restore service quickly. It then becomes the route an agent finds when its first attempt fails.

Fresh state per SSH action is not ceremony. It gives autonomous work a boundary that people can inspect, approve, revoke, and investigate. Keep the remote account narrow, refuse forwarded credentials, record outcomes precisely, and make every new agent process earn its own access.

FAQ

What is a stateless SSH helper?

A stateless helper starts a new local execution context for each requested SSH action and discards that local context when the action ends. It should not keep an SSH master connection, reusable remote shell, forwarded agent socket, or private credential in the agent's own process. The remote machine can still retain files and processes, so statelessness does not erase server-side consequences.

Does stateless SSH make agent workflows too slow?

Usually, no. A fresh connection adds authentication and setup work, but most agent actions are bounded administrative commands, deployments, or checks rather than thousands of tiny interactive operations. If connection setup dominates your workload, reduce the number of intended actions or use a narrowly managed service-side interface instead of silently sharing a control socket.

Should AI agents use SSH ControlMaster?

No. ControlMaster lets later SSH clients reuse one existing master connection, which means those calls inherit a connection created by another process. That can be sensible for a human's short terminal session, but it weakens run-level attribution and widens the effect of a compromised or confused agent process.

Does a fresh SSH connection reset the remote server?

A remote command may leave behind a running process, a changed working tree, temporary files, altered permissions, or a modified service. Treat each call as locally isolated, then make remote state explicit: use command arguments, checked-out revisions, named deployment directories, and cleanup rules. Do not promise users that a new SSH connection rolls back the machine.

Why is SSH connection reuse risky for autonomous agents?

SSH connection reuse improves convenience because it avoids repeated authentication and handshake work. It reduces security clarity when multiple agent runs can act through the same authenticated transport, especially if the shared socket has loose permissions or survives after the run that created it. The best choice depends on whether fast interactive use or attributable automation is the priority.

How should I limit the remote account used by an AI agent?

Use an account with only the permissions that task needs, restrict which hosts it can reach, and prevent unrestricted interactive use where possible. SSH's authorized_keys options, a forced command, or a dedicated remote wrapper can narrow the account's purpose. A stateless client does not compensate for an account that can read every production secret.

Is SSH agent forwarding safe for coding agents?

Avoid forwarding it unless the task cannot work without it. OpenSSH documents that forwarded agent credentials let the remote host request signatures through your local agent, and a remote root user can often access that forwarded socket. Give the agent a purpose-specific credential on the destination side instead.

What should an AI agent SSH audit log contain?

Record the invoking agent process, the approved identity or authority, destination, account, command or request, timestamps, exit status, and any approval decision. Protect command output because output often contains the next problem, including accidental secrets. An append-only record is stronger than a mutable text log, but it does not replace review.

Can stateless SSH protect against a compromised server?

Stateless local execution helps because each call begins without a reusable local transport or hidden session setup. It does not stop the remote server from being malicious, compromised, or overprivileged. Verify host keys, restrict destination accounts, and avoid forwarding credentials into hosts you do not trust.

When should an AI agent use an SSH gateway instead of direct SSH?

Use a gateway when agents must act on systems with real credentials and you need a person to approve access or inspect a durable action record. A direct SSH command can be fine for a disposable sandbox with an expendable account. Production access deserves a boundary that the agent cannot rewrite from inside its own workspace.

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