# How an SSH readiness checklist changes agent safety

Read-only HTTP is a forgiving first channel for an agent. The request has a method, a bounded target, a status code, headers, and usually a body with a documented shape. A failed GET normally leaves the service unchanged. You can still mishandle credentials or expose sensitive data, but the execution model gives reviewers something compact to reason about.

SSH changes the unit of risk. The agent does not call one defined operation. It sends text to a remote shell whose behavior depends on the login account, shell, working directory, environment, operating system, installed tools, quoting rules, and live machine state. A command can partly succeed, disconnect before reporting its result, and leave the next retry free to do the work twice.

That does not make SSH unsuitable for agents. It means "the HTTP channel worked" is weak evidence for adding SSH. The admission test has to cover five separate properties: trustworthy result parsing, verified host identity, repeat-safe commands, approvals that describe consequences, and explicit handling of unknown remote state. If any one is missing, keep the second channel off.

## Treat SSH as a different execution model

SSH readiness starts with admitting that a remote shell is not an HTTP transport with a different URL. HTTP APIs expose operations chosen by the server. SSH exposes an interpreter chosen by the remote account, then asks your client to carry an unstructured command string into it.

With a read-only HTTP call, the reviewer can often infer the effect from `GET`, the host, and the path. A response such as status 404 also has a conventional place in the protocol, even if the application gives it a domain-specific meaning. With SSH, `test -f /srv/app/release && cat /srv/app/release` can return 1 because the file is absent, while `cat /srv/app/release` can return 1 because permission is denied. If your parser reduces both to "command failed," the agent cannot decide what to do safely.

The remote environment also changes meaning. `sed -i` behaves differently across common operating systems. A login shell may source startup files that print banners to stdout. `PATH` may find a wrapper instead of the binary you expected. Locale settings can change human-readable diagnostics. A pseudo-terminal can merge behavior intended for a person into a command intended for a parser. None of those variables appear in a typical HTTP request schema.

Write a channel contract before enabling commands. It should state the remote user, accepted hosts, shell, initial directory, environment policy, whether a pseudo-terminal is forbidden, maximum runtime, output limits, and exact result envelope. Pin executable paths for sensitive actions. Set a known locale when you must consume text. Prefer machine output from the remote program, but never assume a program's JSON option makes the surrounding shell structured.

Use a non-interactive probe as the first gate:

```sh
/usr/bin/ssh \n  -o BatchMode=yes \n  -o StrictHostKeyChecking=yes \n  -o ConnectTimeout=10 \n  -T deploy@host.example \n  'umask 077; printf "%s\n" "{\"probe\":\"ssh-ready\",\"version\":1}"'
```

OpenSSH documents `BatchMode=yes` as disabling prompts, including password and host-key confirmation prompts. `-T` disables pseudo-terminal allocation. `StrictHostKeyChecking=yes` refuses unknown or changed keys. The expected stdout is one JSON object, stderr is empty, and the remote exit status is zero. Test every deviation separately. A system that cannot distinguish an unknown host from malformed JSON has not passed this gate.

## Make the result envelope unambiguous

Result parsing is ready only when transport errors, remote termination, stdout, and stderr remain separate. Collapsing them into a single text field creates dangerous false confidence.

RFC 4254 defines stdout as channel data and stderr as extended channel data. It also defines `exit-status` and `exit-signal` messages, but the wording matters: sending an exit status is recommended, not required, and the client may ignore it. OpenSSH gives a useful local convention on top of that protocol. Its `ssh` command exits with the remote command's status, or 255 when an error occurs.

Do not turn that convention into a universal truth. A remote program can itself exit 255, which collides with OpenSSH's client error value at the process boundary. A server or library may omit the exit-status message. A signal is not the same as an ordinary nonzero exit. The channel can close after producing output but before your client receives a terminal status.

Your internal result should look like a typed record, not a transcript:

```json
{
  "phase": "completed",
  "transport": "ok",
  "host": "host.example",
  "host_key_fingerprint": "SHA256:verified-value",
  "exit_status": 0,
  "exit_signal": null,
  "stdout": "{\"state\":\"present\",\"release\":\"2026.07\"}\n",
  "stderr": "",
  "truncated": false,
  "started_at": "request timestamp",
  "finished_at": "result timestamp"
}
```

Keep `exit_status` nullable. Make `phase` distinguish at least rejected, not-started, started, completed, and unknown. Record truncation as data; never silently clip output and pass the remainder to the agent as complete. Attach the verified host fingerprint used for that connection, not merely the hostname the agent requested.

Then test an outcome matrix. Run a command that succeeds with empty output, one that exits 7 after writing both streams, one killed by a signal, one that exceeds the deadline, one that emits invalid UTF-8 if your stack permits bytes, and one that exceeds each output limit. Drop the client connection while a remote command sleeps, then inspect the result. Your adapter should not manufacture `exit_status: 0`, infer success from stdout, or call a timeout "failed" when it cannot prove whether the command ran.

Shell composition deserves its own tests. POSIX says a pipeline normally reports the status of its last command unless `pipefail` is enabled. That means `generate | upload` can report success because `upload` accepted empty input after `generate` failed. Do not depend on an interactive shell's defaults. Put multi-command operations in reviewed scripts with explicit error handling and a version, then invoke one script entry point.

## Verify hosts before an agent can reach them

Host verification is an inventory problem, not a prompt that an agent should answer. A valid user credential proves who the client is to the server. The server's host key proves which server answered the client. You need both.

Never use `StrictHostKeyChecking=no` as the automation fix. Current OpenSSH documentation says that setting can add new keys automatically and may allow a connection to continue when a host key changed, subject to restrictions. `accept-new` is better because it rejects changed keys, but it still trusts the first connection. For an agent channel, provision trust before the run and use `StrictHostKeyChecking=yes`.

`ssh-keyscan` helps collect public host keys, but it does not authenticate them. Its own manual warns that a network attacker can substitute a key and says to verify the output out of band or use it only on a trusted network. Copying its live output straight into `known_hosts` turns a verification step into a record of whatever answered first.

Obtain fingerprints through an independent control plane: a cloud instance console, an image build record, a configuration repository reviewed by the host owner, or a direct administrator handoff. Store the hostname, port, allowed host-key algorithms, fingerprints, owner, environment, and rotation procedure. Review aliases and jump hosts too. The final destination can be pinned perfectly while an unpinned jump host breaks the trust path.

A useful admission check compares observed and approved keys without changing trust:

```sh
ssh-keyscan -T 5 -t ed25519 host.example > observed.keys
ssh-keygen -lf observed.keys
```

The fingerprint output has fields for bit length, fingerprint, host label, and key type. A human or trusted inventory service compares the fingerprint with the independently supplied value. Only after that match should automation install the known-hosts entry. The scan itself is evidence to compare, not evidence to trust.

Plan rotation before enforcing the pin. OpenSSH's `UpdateHostKeys` can learn additional keys after the server has authenticated with an already trusted key, which supports gradual rotation. Whether you use that extension or distribute a new known-hosts set, define an overlap period and an emergency path. A changed key should stop execution and produce a distinct identity error. It should never trigger a generic retry, an automatic deletion of the old entry, or an approval card asking a hurried reviewer to accept an unexplained fingerprint.

## Require idempotency at the effect boundary

A command is safe to retry only when repeating it after any partial execution produces the same intended state without duplicating the effect. Read-only syntax does not confer this property, and a zero exit status does not prove it.

Some commands are naturally repeatable: reading a fixed file, checking a service state, or creating a directory with `mkdir -p` under controlled permissions. Others need guards. Appending a line with `echo ... >> file`, sending a notification, creating a user with a generated identifier, charging an account through a local tool, and restarting a service are not safe merely because the shell command is short.

The usual advice to "retry transient SSH errors" is wrong at this layer. It is popular because reconnecting fixes many network failures and because HTTP client libraries normalize retries. SSH can lose the connection after the remote process commits its change but before the client receives the exit status. An automatic retry then repeats a completed action.

Move repeat safety into the remote operation. Give each mutating request a stable operation ID generated before approval. Store that ID next to the effect in the same transaction when possible. If the operation runs again, return the recorded result instead of applying the mutation again. Where no transaction spans the marker and the effect, add a reconciliation query that can determine which side completed.

A small deployment script can make the contract visible:

```sh
#!/bin/sh
set -eu

op_id=$1
release=$2
state_dir=/var/lib/agent-ops
record="$state_dir/$op_id"

test -d "$state_dir" || exit 70
if test -f "$record"; then
  cat "$record"
  exit 0
fi

current=$(/usr/bin/readlink /srv/app/current || true)
if test "$current" = "/srv/app/releases/$release"; then
  /usr/bin/printf '{"operation":"%s","state":"already-current"}\n' "$op_id"
  exit 0
fi

test -d "/srv/app/releases/$release" || exit 66
/usr/bin/ln -sfn "/srv/app/releases/$release" /srv/app/current.new
/usr/bin/mv -f /srv/app/current.new /srv/app/current
/usr/bin/printf '{"operation":"%s","state":"changed","release":"%s"}\n' \n  "$op_id" "$release" > "$record.tmp"
/usr/bin/mv -f "$record.tmp" "$record"
cat "$record"
```

This example is not universally atomic. The symlink swap and operation record are two filesystem changes, so a crash between them leaves a gap. The explicit `current` check reconciles that particular gap. Your operation needs a guard tied to its own effect, not a generic marker copied from this script.

Classify every allowed command as read-only, convergent, deduplicated, or non-repeatable. Convergent means repeated execution moves toward one declared state, such as setting a configuration value. Deduplicated means the remote side recognizes the operation ID. Non-repeatable actions require a separate status query and human decision after uncertainty. If the owner cannot classify a command, do not admit it.

## Show the approver the effect, not the shell text

An approval is useful only when a reviewer can identify the target, authority, intended effect, and worst plausible consequence before acting. Raw shell text is necessary evidence, but it is a poor summary.

Compare `systemctl restart api` with an approval that says: production host `api-03`, remote user `deploy`, restart service `api`, active connections may drop, operation ID `rel-2026-07-24-04`, command version `restart-service/v2`. The second description gives the reviewer facts they can match against a change. It also exposes missing context. If the agent cannot say which host or service it will affect, it should not get approval.

The approval payload should bind to the exact execution request. Include the canonical host and port, verified fingerprint, remote account, command or reviewed script digest, normalized arguments, working directory, environment additions, timeout, requested privilege change, operation ID, and whether the action is repeatable. Hash that payload and execute only the approved hash. Otherwise an agent can obtain approval for one command and alter an argument before dispatch.

Render shell quoting exactly, but do not make a reviewer mentally execute it. Parse only command forms you own. If arbitrary shell text remains in scope, label it arbitrary and present the whole string without elision. Flag redirections, command substitution, pipes, backgrounding, `sudo`, file deletion, permission changes, package operations, service control, and network download. A flag is not a verdict. It tells the reviewer where consequences hide.

Approval scope must become narrower as effects grow. A session approval can be reasonable for fixed, read-only probes against an approved inventory. A per-call approval fits state changes, use of a privileged remote account, or commands whose arguments choose the target. Do not let one harmless `uname` approval silently authorize a later deployment because both share an SSH key.

Sallyport's fixed controls map cleanly to this split: its session authorization identifies a new agent process, while a per-call key can require approval on every use. The important design work still belongs in the request: the card must disclose the remote effect, because possession of an approved channel does not explain what a command will do.

Test approval integrity, not just appearance. Change one byte of an approved argument and confirm execution stops. Race two requests that reuse an operation ID. Revoke the session between approval and dispatch. Lock the credential store after the card appears. Each test should end in a recorded denial or a request that must be approved again, never a best-effort continuation.

## Model remote unknown state as a first-class result

Unknown is a valid outcome whenever the client cannot prove whether the remote effect completed. Calling it failure invites retries. Calling it success hides unfinished work.

Walk through a common sequence. An agent connects, starts a script, and the script replaces a configuration file. The service reload begins. At that moment the network path drops. The client receives neither exit status nor final stdout. A local timeout fires and marks the call failed. The agent retries. The second run sees the new file, sends another reload, and perhaps overwrites the first run's diagnostic record. The original call did real work even though the client never observed completion.

RFC 4254 makes this ambiguity unsurprising. The protocol carries command output, exit status, exit signal, EOF, and channel close as separate messages. It recommends returning exit status, but does not guarantee it. Even a clean channel close only tells the client about the channel, not whether an external system reached the requested business state.

Define the state machine before shipping:

1. `not_started`: connection, identity, authentication, or approval failed before dispatch.
2. `started`: the remote side accepted the command, but no terminal result exists yet.
3. `completed`: a terminal status and all bounded output arrived.
4. `unknown`: dispatch may have occurred, but the client lost proof of completion.
5. `reconciled`: an independent query later established the resulting state.

Only `not_started` is generally safe for an automatic retry, and even that label must come from a trustworthy boundary. If bytes carrying the command may have reached the server, use `unknown`. A deadline does not cancel a remote process unless you have a confirmed cancellation protocol. Closing the client socket is not that protocol.

Every mutating command needs a reconciliation plan named before approval. The plan might query the operation record, compare a deployed release identifier, read the service manager's state, or ask the downstream system for the stable operation ID. Run reconciliation with a read-only credential where possible. Preserve the original request, its partial output, timestamps, host fingerprint, and operation ID so the follow-up answers the right question.

Set an unknown-state budget. Decide how long the system waits, who receives the alert, which actions block behind the unresolved operation, and when a human takes over. Never allow two uncertain operations against the same resource to race. Serialize by resource or use a remote lock with an owner and expiry policy that survives client disconnects.

## Restrict the remote account before expanding commands

SSH readiness depends more on remote authority than on client-side intent. A perfect approval screen cannot compensate for a login account that can rewrite the host.

Create a dedicated account for the agent channel. Give it the smallest filesystem access and service permissions needed for admitted operations. Avoid a general administrator account. If elevation is necessary, allow named commands with fixed paths and controlled arguments. Treat unrestricted `sudo`, shell escapes inside allowed programs, writable script directories, and writable executables as equivalent routes to broader access.

OpenSSH `authorized_keys` restrictions can reduce exposure for a credential. Depending on your design, a forced command can route every connection through a dispatcher, while options can disable pseudo-terminals, agent forwarding, X11 forwarding, and port forwarding. Server configuration can also restrict forwarding. Use the server's actual manual and test the effective configuration, because one permissive include or match block can undo your assumption.

A dispatcher should accept a small operation name and data, validate both, and call an executable by absolute path without reconstructing arbitrary shell text. For example, `read-release` may take no arguments, while `activate-release` accepts a release identifier matching a strict format. The SSH channel remains the carrier, but the remote surface starts to resemble a defined API.

Do not forward the developer's authentication agent into an autonomous session. Agent forwarding lets the remote side request signatures through the forwarded socket while the connection lives. A compromised remote host may not extract the private key, but it can use the signing capability. Give the channel a dedicated credential whose server-side authorization is already narrow.

Check filesystem ownership all the way to each executable and configuration file. If the restricted account can modify a parent directory, replace the dispatcher, influence a sourced startup file, or prepend a binary through `PATH`, the allowlist is decorative. Check interpreters too. Permission to run a broad interpreter often means permission to do anything the account can do.

Keep the first command set boring: fixed inventory reads, health queries with bounded output, and one convergent mutation with a tested reconciliation path. Port forwarding, interactive shells, arbitrary uploads, package management, and free-form root commands belong in later reviews, if they belong at all.

## Prove observability under truncation and disconnects

Audit evidence is ready when it can reconstruct authorization, dispatch, remote identity, and observed result without relying on the agent's own summary. Logs must preserve uncertainty instead of editing it away.

Record a stable request ID and operation ID, the agent process or session identity, approval decision, approver method, approved payload hash, canonical target, host-key fingerprint, remote account, start time, dispatch time, terminal time, exit status or signal, byte counts for each stream, truncation flags, and the final state classification. Keep stdout and stderr separate. If policy forbids retaining full output, store the permitted portion plus a digest and clear retention metadata.

Output limits need two behaviors: stop local collection and decide what happens remotely. Simply closing the channel after a megabyte may leave the process running. A remote wrapper can cap output, send it to a controlled file, and report a digest, but that wrapper also needs disk quotas and cleanup. Test a command that never closes stdout, a child that survives its parent, and a process that writes stderr forever.

Logs should also show what did not happen. A host-key mismatch, locked vault, rejected approval, expired session, malformed request, or disallowed command must create a denial record before return. Otherwise operators see a gap and cannot distinguish a quiet system from a bypass.

Sallyport records agent runs and individual calls from one encrypted, hash-chained audit log, and `sp audit verify` checks the chain offline over ciphertext without a key. That gives the channel tamper-evident local evidence, but your remote operation IDs and reconciliation results still need to appear in the request and result so an operator can connect the call to machine state.

Run failure injection while collecting the evidence an incident reviewer would get. Kill the client before dispatch, immediately after dispatch, halfway through stdout, and after the remote process exits but before local completion. Rotate the host key without updating inventory. Fill the remote filesystem before a marker write. Return a successful exit with malformed structured output. For each case, ask one question: can a reviewer tell what authority was used, what might have changed, and what must happen next?

## Admit SSH only after the gates pass

The second channel is ready when the team can demonstrate its failure behavior, not when a happy-path command reaches a test host. Use a written gate with owners and retained evidence.

The admission record should contain:

1. A channel contract naming the shell, account, directory, environment, timeouts, output caps, and result schema.
2. A verified host inventory with an independent fingerprint source, jump-host coverage, and a tested rotation process.
3. A command catalog that classifies repeat behavior and names the reconciliation query for every mutation.
4. An approval specification bound to the exact host, account, command version, arguments, privilege, timeout, and operation ID.
5. Failure-injection results proving unknown states, denials, truncation, revocation, and audit reconstruction.

Pass the read-only probes first. Then admit one convergent write in a disposable environment. Disconnect it at each boundary and reconcile the result. Repeat against a production-shaped host with a harmless resource. Review the evidence with the person who owns that host, not only the team that built the agent gateway.

Keep rollback separate from retry. A rollback is a new, explicit mutation with its own approval, operation ID, preconditions, and possible unknown state. Automatically running an inverse command after a timeout can damage a change that actually completed correctly. The system must establish current state before it changes that state again.

Set removal criteria alongside admission criteria. Disable an operation when its script digest changes without review, its host leaves inventory, reconciliation stops working, output becomes unbounded, or operators cannot explain an unknown result. Channel access is not a permanent graduation badge.

SSH earns admission one operation at a time. If you cannot pin the server, describe the effect, repeat it safely, distinguish all result states, and reconstruct the call afterward, the correct checklist result is "not ready." Keep the read-only HTTP channel and fix the missing boundary before a remote shell turns an ambiguous retry into a second production change.
