# Can forced SSH commands contain an AI service account?

An AI agent should never get an SSH credential that means "do whatever the account can do." That is not a permission boundary. It is an invitation to discover one, usually through an argument you did not expect, a forwarded connection you forgot to disable, or a deployment script that trusts its caller too much.

Forced SSH commands give the remote server the final say over what starts after authentication. They work well for deployment and diagnostic accounts because they replace a vague capability, remote shell access, with one named operation that you own and can inspect. They do not replace human approval of credential use. Keep those two controls separate: a person decides whether an agent may use the credential, and the server decides the narrow action that credential can perform.

## A forced command limits execution, not authentication

A forced command tells sshd to run a program chosen by the server even when the client asks for a shell or supplies a different command. The client still authenticates first. That distinction sounds obvious until a service account appears in an agent configuration and people start treating a successful login as if it were an approved deployment.

OpenSSH supports this control in two places. You can attach `command="/path/to/wrapper"` to one public key in `authorized_keys`, or set `ForceCommand` in `sshd_config` for a user or group. In both cases, sshd records the command requested by the client in the `SSH_ORIGINAL_COMMAND` environment variable and starts the forced program instead.

The OpenSSH `sshd(8)` manual is blunt about the first part: a `command` option forces execution of the specified command after authentication. It also documents that the original command remains available to that forced program. That second detail is where many weak designs fail. The wrapper receives a string from an untrusted client. It must parse that string as a request, not feed it into a shell.

Use the per-key form when one account has several carefully separated credentials. A release credential can start the deployment wrapper, while an operations credential can start a read-only diagnostic wrapper. This keeps intent visible in `authorized_keys` and lets you remove one credential without changing the account's other access.

Use `ForceCommand` when the account itself must never provide a general shell, regardless of how it authenticates. That includes a password you forgot to disable, a future certificate authority, or an administrator who adds another public key without copying the required options. A `Match User deploy` block makes the rule hard to miss during review.

Do not use either form as a way to turn a human administrator account into an automation account. People eventually need a real shell for repair work. Give automation a separate Unix account, a separate credential, a separate command wrapper, and ownership boundaries that fit the job.

## The server must own the deployment entry point

A deployment account should enter one script that you control, not a generic command interpreter. The script can accept a small request vocabulary, but it should choose the repository path, target directory, service unit, and executable itself.

This `authorized_keys` entry restricts a single credential to a wrapper and denies connection features that have no place in a deployment account:

```text
restrict,command="/usr/local/libexec/release-gate" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... release-agent
```

The `restrict` option is useful because OpenSSH documents it as shorthand that disables port forwarding, agent forwarding, X11 forwarding, and pseudo-terminal allocation. Its exact behavior follows the server's supported OpenSSH options, so test it on the version you run. If your estate requires explicit options for review or compatibility, write them out:

```text
command="/usr/local/libexec/release-gate",no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-pty ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... release-agent
```

The wrapper should not take a free-form deployment command. Give callers fixed verbs and a constrained value. For example, callers may request a release only by immutable revision:

```text
ssh release@deploy.example "release 9f2a7c6d1e4b8a03"
```

A safe wrapper can allow that grammar and nothing else:

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

request=${SSH_ORIGINAL_COMMAND-}
case "$request" in
  "release "[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]* )
    revision=${request#release }
    case "$revision" in
      *" "*|*[!0-9a-f]*)
        echo "invalid revision" >&2
        exit 64
        ;;
    esac
    exec /usr/local/libexec/run-release "$revision"
    ;;
  *)
    echo "unsupported remote request" >&2
    exit 64
    ;;
esac
```

This example still needs a length check if your revision format requires one. A production wrapper should accept a full immutable object ID or a release identifier whose format you define. Do not accept a branch name such as `main` if the caller can move it between approval and deployment. A branch is a pointer. An immutable revision lets the approval record, deployment log, and resulting artifact refer to the same thing.

The `run-release` script should use absolute paths and set its own environment. Do not rely on a caller-supplied `PATH`, working directory, locale, `GIT_DIR`, `GIT_SSH_COMMAND`, or `LD_PRELOAD`. A minimal beginning looks like this:

```sh
#!/bin/sh
set -eu
PATH=/usr/sbin:/usr/bin:/sbin:/bin
export PATH
unset CDPATH ENV BASH_ENV GIT_DIR GIT_WORK_TREE GIT_SSH_COMMAND
cd /srv/release-repo

revision=$1
/usr/bin/git cat-file -e "$revision^{commit}"
/usr/local/libexec/build-and-activate "$revision"
```

The account should own only the files it must change. If the account needs to restart a service, grant one narrow `sudoers` command with fixed arguments rather than passwordless access to a general package manager or shell. A deployment account that can write its own wrapper, modify its own `authorized_keys`, or edit the unit that runs its code can usually recover broad control. Check those paths, not just the SSH configuration.

## `SSH_ORIGINAL_COMMAND` is input, not a command line

The most frequent forced-command mistake is this line:

```sh
sh -c "$SSH_ORIGINAL_COMMAND"
```

That line cancels the control you just installed. The client can request `release goodrev; curl ... | sh`, command substitution, redirected output, or a carefully quoted argument that reaches a privileged tool. A wrapper that invokes `eval`, `sh -c`, `bash -c`, or an unquoted expansion has recreated remote shell access under a different filename.

Do not attempt to build a full shell parser. You do not need one. Define an intentionally small protocol and reject everything outside it. For a deployment account, a request can be one verb plus one identifier. For a diagnostic account, a request can be one exact word such as `health` or `version`.

A dispatch wrapper for diagnostics can avoid parsing altogether:

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

case "${SSH_ORIGINAL_COMMAND-}" in
  health)
    exec /usr/local/libexec/report-health
    ;;
  queue-depth)
    exec /usr/local/libexec/report-queue-depth
    ;;
  version)
    exec /usr/local/libexec/report-version
    ;;
  "")
    echo "a diagnostic name is required" >&2
    exit 64
    ;;
  *)
    echo "diagnostic is not allowed" >&2
    exit 64
    ;;
esac
```

Those diagnostic scripts must also own their arguments. `report-health` should call fixed binaries against fixed local sockets or known service names. It should not accept a host parameter and run `curl "$host"`, nor accept a journal filter and pass it to a shell. A read-only command can still leak database credentials, internal topology, environment values, or customer data.

People often argue that a carefully quoted shell command is sufficient because the calling agent is trusted. That claim collapses when the agent follows hostile repository instructions, confuses a value with an instruction, or makes a plain mistake. The remote server cannot tell whether a dangerous request came from malice or an overenthusiastic tool call. It only sees input. Make its decision deterministic.

If you need structured inputs, send a bounded format and parse it with a parser that rejects extra fields. JSON is not automatically safer because a shell wrapper may still mishandle it. A small request like `release <64 lowercase hex characters>` is easier to validate, document, test, and audit than a JSON blob with optional fields.

## Forwarding can bypass the spirit of the restriction

A forced command does not automatically prevent an authenticated client from using SSH as a tunnel. The OpenSSH manual treats command execution and forwarding as separate controls. If you add `command="..."` alone, a client may still ask sshd to forward a local port to an internal service, depending on the rest of your server configuration.

That matters because a restricted account may have network access that the agent does not. An agent that cannot run `/usr/bin/ps` on the remote host may still reach a database port through that host if forwarding remains open. In that case, the account has stopped being a deployment identity and has become a network pivot.

For an account that needs no interactive session, deny all of these unless you can state why the account needs one:

- TCP forwarding
- agent forwarding
- X11 forwarding
- pseudo-terminal allocation
- user-controlled environment variables

`restrict` handles the first four categories on modern OpenSSH deployments. If your account legitimately needs one exception, avoid dropping the entire restriction set. OpenSSH supports options such as `permitopen="host:port"` to limit destination forwarding. Treat that as a separate access design and test both allowed and denied destinations.

Also inspect the wrapper's outbound network access. A deployment script that can fetch arbitrary URLs, clone arbitrary repositories, or send arbitrary data outward has a broad channel even if SSH forwarding is disabled. Fixed artifact sources and pinned revisions reduce that exposure. Firewall rules or service-specific credentials may need to carry the rest of the burden.

## Approval belongs before the connection opens

A forced command reduces the damage an approved SSH use can cause. It does not answer whether the current agent process should use the credential at all. That decision belongs at the credential boundary, before the agent creates an SSH connection.

This is particularly important for autonomous coding agents. A repository can instruct the agent to run a deployment command. A tool output can request it. A compromised dependency can steer it toward it. If the credential sits in the agent's environment or filesystem, the agent can use it without a person seeing the moment of use.

Keep SSH private keys outside the agent process and ask for an approval when a new agent run first requests access. For high-consequence accounts, ask every time the credential is used. The remote forced command then puts a hard ceiling on the action that approval authorizes.

Sallyport applies this split by keeping SSH keys in its encrypted vault, authorizing new agent processes per session by default, and executing SSH through its `sp-ssh` helper rather than handing the key to the agent.

Do not confuse an approval card with server authorization. Approval answers, "May this process use this credential now?" The server answers, "What can this credential do after login?" You need both answers because they fail differently. Approval can stop a surprising process. Forced commands can stop an approved process from turning a release credential into a shell.

Make the approval description useful. Name the environment and action in the credential label, such as `production release` or `staging diagnostics`. A label called `deploy-key-2` asks the reviewer to remember history during an interruption. That is how routine approvals turn into automatic clicks.

## Separate deployment from diagnosis before the allowlist grows

Deployment and diagnosis look similar because both need SSH, but they have different data flows and different failure modes. Put them behind separate accounts or separate forced-command credentials whenever you can.

A deployment account changes state. It may fetch a fixed revision, build an artifact, replace a release directory, and restart one service. Its output should report the revision, target, exit status, and a short failure message. It does not need arbitrary log access, process inspection, or database queries.

A diagnostic account reads state. It may report a health endpoint result, a bounded queue count, a service version, or the tail of a carefully filtered local log. It should not restart services, rotate files, query every process, or read arbitrary paths. Once a diagnostic wrapper accepts a user-provided file name, unit name, host, or command option, review its input model again.

One combined account starts with an innocent list:

```text
release <revision>
health
logs <service>
restart <service>
```

Then someone needs `logs api --since`, another person needs an emergency restart, and the wrapper begins passing arguments through to `journalctl` or `systemctl`. Soon the code contains special cases that nobody can explain. Split the accounts before that happens. Separate credentials let you require stricter approval for production changes while allowing a lower-risk diagnostic workflow.

Each action should produce a record that states what the wrapper accepted, not merely the opaque SSH command string. For a release, log the immutable revision and the target name. For diagnosis, log the named diagnostic and whether it succeeded. Keep secret values out of both command arguments and logs. If an action needs a secret, the remote script should retrieve it through its own controlled mechanism rather than accept it from the SSH client.

## Test denial paths from a disposable client

A restricted account is only restricted after you test the requests it must reject. Run these checks from a disposable account or test host before trusting the configuration in a production environment. The examples assume the credential is already installed on the server.

```sh
ssh release@deploy.example "release 9f2a7c6d1e4b8a03"
ssh release@deploy.example
ssh release@deploy.example "id"
ssh release@deploy.example "release 9f2a; id"
ssh -N -L 15432:db.internal:5432 release@deploy.example
ssh -tt release@deploy.example "health"
```

The first command should reach the release wrapper only if the revision satisfies its rules. The next three should fail with the wrapper's denial message and a nonzero exit status. The port-forwarding attempt should fail before it can establish a listener. The terminal request should fail or run without a terminal, depending on how your client reports the denied allocation.

Then test the less obvious cases. Try leading and trailing spaces, tabs, an empty quoted command, newline characters, a very long argument, Unicode whitespace, command substitutions, redirections, and duplicate arguments. If your shell or wrapper normalizes any of these into an accepted request, tighten the grammar.

Check the account's file permissions and ownership as part of the test. An attacker who can replace `/usr/local/libexec/release-gate` does not need to bypass SSH. An account that can alter its own deployment source may also alter the code that runs with more privilege. Inspect the full chain: `authorized_keys`, sshd configuration, wrapper, deployment scripts, service definitions, writable directories, and any `sudoers` entry.

## Audit the request on both sides of the boundary

Remote logs explain what the server accepted. Credential-boundary logs explain which local process requested the ability to connect. Keep both because neither can answer the other's question.

On the remote side, log authentication success, the forced wrapper's accepted operation, the immutable revision or diagnostic name, a request identifier, and the final status. Send those records to a location the service account cannot rewrite. Do not log raw `SSH_ORIGINAL_COMMAND` if callers might put secret material in it, and do not let your protocol accept secret material in the first place.

On the local side, retain the session identity and the individual SSH action request. Sallyport records agent runs and individual calls in separate journals from one encrypted, hash-chained audit log, and `sp audit verify` can verify that chain offline without a vault key.

A hash chain does not turn a bad permission into a good one. It does make later alteration of recorded history easier to detect. That is useful after a failed deployment, a disputed approval, or a surprising command request. It also forces a welcome discipline: define the action vocabulary early so that the records say something a human can understand.

The first implementation should be boring. Create a dedicated Unix account, one forced wrapper, one operation with a fixed input grammar, forwarding disabled, and a test that proves `ssh account@host` does not yield a shell. Add capabilities only when you can name their input, their output, their file access, their network access, and the person who should approve them.
