# Can SSH environment forwarding leak local context?

SSH environment forwarding can leak far more local context than a remote job needs. The channel is encrypted, but encryption only protects the trip. It does not make `AWS_PROFILE`, `GIT_AUTHOR_EMAIL`, a tenant name, or an internal feature flag appropriate input for a process on another machine.

I treat every forwarded variable as an argument to the remote command. That means it needs a name, a reason, an owner, and a test. A wildcard copied from a laptop-friendly SSH configuration fails that standard because its meaning changes whenever someone adds a new local variable that matches.

The safe target is not an empty remote environment. `sshd`, the login account, the shell, and wrappers create their own baseline. The target is narrower: the client contributes exactly the workflow variables in a written contract, the server accepts no broader set, and a test proves both the required values and the rejected canaries.

## Forwarding happens only when both SSH endpoints agree

OpenSSH environment forwarding has two gates. On the client, `SendEnv` selects names from the local process environment, while client-side `SetEnv` supplies literal `NAME=VALUE` pairs. On the server, `AcceptEnv` decides which client-supplied names enter the session environment. A name normally crosses only when the client offers it and the server accepts it.

RFC 4254 describes the mechanism underneath those settings. Before the shell or command starts, the client may send an SSH channel request of type `env` containing one variable name and one value. The RFC also warns that uncontrolled environment settings can be dangerous in a privileged process and recommends an allowlist or delaying them until privileges have dropped. That warning is part of the protocol specification, not a later hardening fashion.

The OpenSSH manuals add an exception that often confuses audits: when the client requests a pseudo-terminal, `TERM` is always sent and accepted because the protocol needs it. Run automation with `ssh -T` when it does not need a terminal. That removes terminal behavior, interactive startup surprises, and `TERM` from this particular path.

Two observations follow from this handshake.

- A broad `SendEnv` is dormant against a server that accepts nothing, but it becomes live as soon as an administrator broadens `AcceptEnv`.
- A broad `AcceptEnv` is dormant for a careful client, but any permitted user can connect with a different client configuration and supply a matching value.
- Neither side can enforce the whole contract alone when people control both configurations.
- A successful command says nothing about which environment requests the server rejected.

That last point matters. OpenSSH can ignore an offered variable and continue the session. If a deployment works because the remote shell already defines the same name, a test that checks only the final value can credit forwarding for a value it never delivered.

## Wildcards turn future local state into remote input

`SendEnv WORKFLOW_*` does not mean "the variables I reviewed today." It means every matching name present in the environment of each future SSH client process. Six weeks later, a developer may add `WORKFLOW_DEBUG_DUMP`, `WORKFLOW_CUSTOMER`, or `WORKFLOW_TOKEN_FILE` to a shell profile. The old SSH rule silently acquires new behavior.

Locale forwarding shows how this spreads. Many workstation configurations send `LANG` and `LC_*` so an interactive shell renders text correctly. That choice may be reasonable for a human login, yet it does not belong automatically in a noninteractive build or deploy account. Locale values can change sorting, character classes, date rendering, and diagnostic text. A job that parses output can fail without any secret crossing the wire.

The higher-risk patterns are the ones that look like convenient namespaces:

- `AWS_*` may include profiles, regions, credential-related values, and config paths.
- `GIT_*` can carry identity, tracing settings, alternate object directories, or askpass behavior.
- `CI_*` often mixes harmless build labels with provider context and temporary paths.
- `LC_*` looks cosmetic until a script depends on stable collation or messages.
- `APP_*` tends to grow with the application and rarely has one security meaning.

Do not classify a namespace as safe. Classify individual names. `DEPLOY_REGION` may be required while `AWS_PROFILE` is workstation context. `BUILD_REF` may be required while `GIT_CONFIG_COUNT` changes Git's runtime configuration. The common prefix provides organization, not a trust boundary.

Values also disclose context without being credentials. A path reveals a user name and repository layout. A profile name identifies an account or environment. A trace flag can make a remote tool print command data into a shared log. An agent can then use that context in later actions even if the initial command merely echoed it. Leakage is about unintended influence and disclosure, not just secret strings.

## A safe-looking exception can widen months later

Configuration drift usually creates this leak without one obviously reckless change. A developer first adds `SendEnv APP_*` to a host block because a test command needs `APP_COLOR=0`. The server rejects it, so nothing happens. Later, an administrator adds `AcceptEnv APP_*` for a different team that needs two application settings on the same shared host. Both changes pass their local reviews because each reviewer sees only half of the handshake.

The next connection joins those dormant rules. The developer's shell now contains `APP_CUSTOMER=acme-lab`, `APP_TRACE=1`, and `APP_CONFIG=/Users/lee/work/private/config`. The SSH client offers all four variables. The daemon accepts all four. A diagnostic wrapper runs `env` when the release fails and stores the output in a group-readable job log. Nobody copied a token, yet local identity, customer context, a path, and a trace switch crossed a boundary without approval.

Removing the server wildcard fixes future sessions but does not remove the old log or tell you which commands observed the values. Treat discovery as a small incident:

1. Stop acceptance of the broad pattern and validate the daemon configuration.
2. Identify the accounts, clients, and time window in which both patterns overlapped.
3. Search approved remote logs for the exposed names, not their sensitive values.
4. Decide whether each value changed command behavior or disclosed context to another user.
5. Replace the wildcard with exact names and add canaries for the rejected classes.

This failure is why "the server currently rejects it" is not a sound defense for a broad client rule. Dormant configuration has no owner at the moment it becomes active. Remove unnecessary offers on the client even when the server is strict, and remove unnecessary acceptance on the server even when today's clients are careful.

The reverse drift happens too. A server has long accepted `LC_*` for interactive users. An automation image update then installs a system client configuration that sends `LC_*`. The deploy job starts inheriting a developer-selected `LC_COLLATE` and produces a different file order. There is no disclosure worth escalating, but the same handshake created an integrity failure. Confidentiality and command behavior belong in the same review.

## Inspect the configuration SSH will actually use

Read the evaluated client configuration before editing a dotfile. OpenSSH's `ssh -G` applies `Host` and `Match` blocks, includes, user configuration, and system configuration, then prints the result it would use for the named destination. The source file that looks authoritative may lose to an earlier value or gain entries from an included file.

Run this from the same account and execution context as the workflow:

```sh
ssh -G deploy-prod |
  awk '$1 == "sendenv" || $1 == "setenv" { print }'
```

A representative unsafe result looks like this:

```text
sendenv LANG
sendenv LC_*
sendenv AWS_*
setenv WORKFLOW_KIND=deploy
```

`ssh -G` does not print the current values selected by `SendEnv`; it prints the effective selection rules. That is safer for review because the audit output does not dump credentials. It also means the review is incomplete until you compare the names or patterns with the environment of the process that launches SSH.

Capture those names without values:

```sh
env | sed 's/=.*//' | LC_ALL=C sort > local-env.names
grep -E '^(LANG|LC_|AWS_|WORKFLOW_)' local-env.names
```

The regular expression above is an inspection aid, not a reusable policy. Change it to match every pattern from the `ssh -G` output. If an automation runner launches SSH through a supervisor, IDE, agent, or scheduled job, inspect that process environment rather than your interactive shell. Environment inheritance follows the process tree, so testing from the wrong parent gives a clean but irrelevant answer.

Review the server independently. On an OpenSSH server, `sshd -T` or the extended `sshd -T` mode checks and prints effective daemon settings. Supply connection criteria when `Match` blocks affect the account:

```sh
sudo sshd -T \
  -C user=deploybot,host=deploy.example,addr=192.0.2.44 |
  grep '^acceptenv'
```

Use the actual user, host, and client address for the connection under review. Keep the documentation address above only in examples. Also run `sudo sshd -t` before reloading the daemon; syntax validation is cheap, and an environment hardening change does not justify breaking remote access.

## A dedicated client file makes the allowlist legible

The most reliable client setup for automation is a small dedicated SSH configuration passed with `-F`. The `ssh` manual states that an explicitly supplied configuration file replaces the normal per-user file and causes the system-wide client file to be ignored. That prevents a later workstation package or fleet setting from adding locale or namespace wildcards to the workflow.

```sshconfig
Host deploy-prod
    HostName deploy.example
    User deploybot
    IdentityFile ~/.ssh/deploy_ed25519
    IdentitiesOnly yes
    RequestTTY no
    SendEnv DEPLOY_REGION
    SendEnv BUILD_REF
    SetEnv WORKFLOW_KIND=deploy
```

Invoke it explicitly:

```sh
ssh -F ./deploy-ssh.conf deploy-prod /usr/local/bin/release
```

`SendEnv` reads the value from the environment of the local `ssh` process. Use it when the value legitimately changes per run. Client `SetEnv` puts a literal value in the SSH configuration. Use that for a nonsecret constant such as a workflow type, not for a credential and not for a value that operators will forget to update.

OpenSSH also lets a `SendEnv` pattern beginning with `-` clear names selected earlier. That is useful while repairing an existing layered configuration:

```sshconfig
Host deploy-prod
    SendEnv -*
    SendEnv DEPLOY_REGION BUILD_REF
```

The cleanup applies to selections already accumulated at that point. A later included or system rule can add names again, which is why I do not use subtraction as the final automation boundary. A dedicated file passed with `-F` has fewer moving parts and produces an effective configuration another engineer can understand in one screen.

Keep credentials out of this contract. A forwarded token becomes ordinary remote process state and may reach child processes, debug output, crash reports, `/proc` inspection under applicable permissions, or a command accidentally run with `env`. SSH protects the transport, not the lifetime of the value after delivery.

## The server should accept exact names for a narrow account

The server is the last place to reject a client-supplied variable before the session starts. OpenSSH defaults to accepting none, apart from the pseudo-terminal handling of `TERM`. Preserve that default globally and add exact names only for the account that owns the workflow.

```sshdconfig
Match User deploybot
    AcceptEnv DEPLOY_REGION
    AcceptEnv BUILD_REF
    AcceptEnv WORKFLOW_KIND
```

Do not use `AcceptEnv APP_*`, `AcceptEnv AWS_*`, or a bare wildcard. The OpenSSH `sshd_config` manual explicitly warns that some environment variables can bypass restricted user environments. That warning is easy to wave away when the current names look harmless, but the wildcard grants future names without another server change.

A broad global `AcceptEnv` weakens every matching account. Adding a narrower `Match User` block does not erase names already accepted globally; `AcceptEnv` entries can accumulate. If interactive users need locale forwarding and a deploy account must not receive it, do not begin with a global locale rule. Scope the interactive allowance to the intended users or groups, then verify each relevant connection with `sshd -T -C`.

Server-side `SetEnv` is a separate control. It defines values in child sessions and overrides default values as well as values supplied through `AcceptEnv` or `PermitUserEnvironment`. Use it when the server owns a constant. Do not accept a client value merely because the remote command needs a variable-shaped input; if every run must use the same server-approved value, set it on the server.

`PermitUserEnvironment` is separate again. It controls values read from `~/.ssh/environment` and `environment=` options in authorized keys, and OpenSSH disables it by default. An audit that checks only `AcceptEnv` can miss this alternate source. Keep it off unless a specific account design requires it, then include its accepted patterns in the same contract review.

An exact-name allowlist controls names, not values. A permitted `DEPLOY_REGION` can still contain whitespace, shell metacharacters, a newline, or a region your workflow must never target. The SSH protocol carries the value as data, but the receiving script may later turn it into syntax through `eval`, an unquoted expansion, a generated configuration, or a command string.

Validate each accepted value at the remote entry point before it reaches another tool:

```sh
case ${DEPLOY_REGION-} in
    us-east-1|us-west-2) ;;
    *)
        printf 'invalid DEPLOY_REGION\n' >&2
        exit 64
        ;;
esac

case ${BUILD_REF-} in
    [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]*)
        ;;
    *)
        printf 'invalid BUILD_REF\n' >&2
        exit 64
        ;;
esac
```

Use validation that matches the real contract. The short shell pattern above illustrates shape checking; if a release requires a full commit identifier, enforce its exact length and verify that the repository contains it. Do not make the validator so permissive that it merely rejects empty strings.

After editing, inspect the effective result rather than trusting indentation:

```sh
sudo sshd -t
sudo sshd -T \
  -C user=deploybot,host=deploy.example,addr=192.0.2.44 |
  grep -E '^(acceptenv|permituserenvironment|setenv)'
```

Save this output with the configuration review. It records what the daemon parsed for the tested connection, which is more useful than a screenshot of one fragment in a directory full of includes.

## Prove required values and rejected canaries remotely

A useful test checks positive and negative cases in the remote process itself. Set every required value to an unmistakable marker, set canaries that would match the old broad patterns, run a noninteractive session with the dedicated client file, and make the remote shell fail if the contract is wrong.

This sequence expects only `DEPLOY_REGION`, `BUILD_REF`, and the fixed `WORKFLOW_KIND` from the client:

```sh
export DEPLOY_REGION='env-audit-region'
export BUILD_REF='env-audit-ref'
export AWS_PROFILE='must-not-cross'
export LC_AUDIT_CANARY='must-not-cross'
export APP_PRIVATE_PATH='must-not-cross'

ssh -F ./deploy-ssh.conf -T deploy-prod 'sh -s' <<'REMOTE'
set -eu

test "$(printenv DEPLOY_REGION)" = 'env-audit-region'
test "$(printenv BUILD_REF)" = 'env-audit-ref'
test "$(printenv WORKFLOW_KIND)" = 'deploy'

for name in AWS_PROFILE LC_AUDIT_CANARY APP_PRIVATE_PATH; do
    if printenv "$name" >/dev/null 2>&1; then
        printf 'unexpected forwarded variable: %s\n' "$name" >&2
        exit 1
    fi
done

printf '%s\n' 'SSH environment contract passed'
REMOTE
```

The expected output is one line:

```text
SSH environment contract passed
```

Run a second negative test by unsetting one required local value. `SendEnv` cannot send a name that is absent from the client process, so the remote assertion should fail:

```sh
unset BUILD_REF
if ssh -F ./deploy-ssh.conf -T deploy-prod 'test -n "$BUILD_REF"'; then
    printf '%s\n' 'test failed: BUILD_REF appeared unexpectedly' >&2
    exit 1
fi
```

This test distinguishes an optional forwarded value from a required workflow input. If the job should fail without `BUILD_REF`, fail before SSH as well, with `${BUILD_REF:?BUILD_REF is required}` in the launcher. The remote check still matters because it verifies the transport contract and catches a server rejection.

Canaries should cover every wildcard or questionable name class found during the audit. Keep them obviously fake. Never place a real credential into a leakage test, because a failure may print the value into logs while proving exactly what you feared.

One successful canary run does not prove that no imaginable variable can cross. It proves the named negative cases under one evaluated configuration and one process environment. Combine it with two static checks: `ssh -G` must contain only exact `SendEnv` names, and `sshd -T -C` must contain only the expected `AcceptEnv` names for that connection. If either output contains `*` or `?`, the test matrix cannot enumerate what a future environment may add.

Run the contract test from every distinct launcher. A developer shell, CI worker, editor task, and autonomous agent can all invoke the same binary with different configuration paths and environments. Record the path to the dedicated file in the launcher itself rather than relying on an alias that exists only in an interactive shell.

There is also a difference between observing the final environment and proving the SSH contribution. Suppose `/etc/profile` defines `DEPLOY_REGION=us-east-1` and the client sends the same value. The positive assertion passes even if the server rejects the client request. Use unique audit markers that the remote baseline cannot already contain, then run the missing-input negative case. Together they expose both a rejected required value and an accidental server fallback.

The test does not claim that the remote environment contains only three variables. A normal OpenSSH session also receives server-created values such as `HOME`, `USER`, `SHELL`, `PATH`, and SSH connection metadata, and startup files or wrappers may add more. The assertion proves that the client-contributed portion matches the workflow contract. Audit the server baseline separately if the command also requires a minimal total environment.

## Command construction can bypass SendEnv entirely

Clearing `SendEnv` does not stop a shell from expanding local variables into the remote command string. This is a different data path:

```sh
ssh deploy-prod "release '$TENANT' '$TOKEN'"
```

The local shell substitutes both values before `ssh` runs. They travel inside the encrypted command request, not an `env` request, so `AcceptEnv` cannot reject them. They may also appear in process inspection, shell history, CI logs, or error messages depending on how the launcher constructs and records the command.

Likewise, this prefix affects the local SSH process but crosses only if the configuration selects the name:

```sh
DEPLOY_REGION=west ssh -F ./deploy-ssh.conf deploy-prod /usr/local/bin/release
```

Teams routinely blur these two cases. "It was in the environment when SSH ran" does not prove environment forwarding sent it, while "we disabled `SendEnv`" does not prove a wrapper stopped interpolating it into arguments or standard input. Trace the actual channel.

Remote startup code creates another source. A login shell, noninteractive shell rules, `/etc/environment`, PAM modules, a forced command, `sudo`, a service manager, or a container launcher may remove, replace, or add values after SSH accepts them. When a marker does not appear, use `ssh -vvv` to inspect the client's sending decisions, server logs where permitted, and a tiny remote command that runs before the application wrapper. Do not immediately broaden `AcceptEnv`; that often hides the layer that changed the value.

`sudo` deserves its own check because teams often mistake its filtering for an SSH control. Depending on sudoers policy, `sudo` may reset most of the environment, retain selected names, or allow a caller to request preservation. That only changes what reaches the privileged child. The value already arrived in the unprivileged SSH session, where shell code, audit hooks, and wrappers could observe it. Configure sudoers for the privileged command's needs, but keep the SSH allowlist narrow before that layer.

The remote host is also not a passive pipe. Anyone who controls the remote account can usually print its process environment, and an administrator controls the machine. Forwarding a local value therefore discloses it to the remote trust domain by design. If the security argument assumes the remote host must never learn a value, do not send that value through SSH in any form.

Avoid `env` dumps in routine logs. During an approved audit, capture names first and reveal values only for fake markers. A complete dump turns every unrelated variable into audit material and may create the incident the review was meant to prevent.

## Agent-run SSH needs a smaller process boundary

An autonomous agent often inherits the environment of the terminal, editor, or orchestrator that launched it. That environment was assembled for a human who works across repositories and accounts. Passing its namespaces to a remote host gives the agent extra context and gives remote tools inputs nobody included in the action review.

Launch the workflow with an explicit local environment as well as an explicit SSH configuration. A minimal wrapper can require the two changing values, discard unrelated inherited state, and restore only what the SSH client needs:

```sh
#!/bin/sh
set -eu
: "${DEPLOY_REGION:?DEPLOY_REGION is required}"
: "${BUILD_REF:?BUILD_REF is required}"

exec env -i \
  HOME="$HOME" \
  PATH='/usr/bin:/bin:/usr/sbin:/sbin' \
  DEPLOY_REGION="$DEPLOY_REGION" \
  BUILD_REF="$BUILD_REF" \
  ssh -F ./deploy-ssh.conf deploy-prod /usr/local/bin/release
```

Adjust `PATH` and required client inputs for the operating system, and keep the list explicit. `HOME` is present because OpenSSH may need user files such as known hosts and the identity path in this example. If a managed runner supplies those through other options, remove `HOME` too.

For agent-operated SSH on a Mac, Sallyport can keep the SSH key in its encrypted vault and execute through its bundled `sp-ssh` helper, so the agent does not receive the credential. That changes credential custody, but it does not excuse a vague remote environment contract; keep the exact-name test around the command the agent is allowed to run.

Do not forward local cloud credentials as a shortcut around remote identity design. If the remote action needs access to another service, give the remote workload its own narrowly scoped identity or route that action through a gateway that holds the credential. Copying the operator's ambient credential into a remote process couples two trust boundaries and makes revocation hard to reason about.

## Keep the contract executable when configurations change

Environment rules drift because client packages add defaults, administrators consolidate daemon snippets, and workflows acquire new inputs. A prose standard will not catch that drift. Put the dedicated client file, the server account configuration, and the canary assertion under review together.

Run the assertion after changes to any of these surfaces:

- SSH client or operating-system packages
- user and system SSH configuration includes
- `sshd_config`, PAM, shell startup files, or forced commands
- the agent launcher, CI runner, service manager, or container image
- the workflow's required input list

Treat a new variable as an interface change. Document why the remote command needs it, whether the client or server owns the value, whether it contains sensitive context, and which test proves absence elsewhere. If nobody can answer those questions, pass the value as a validated command input or redesign the remote action instead of widening a wildcard.

Keep the failure loud. A deployment that falls back to a remote default after forwarding breaks is harder to diagnose and easier to misuse than one that stops on a missing marker. Required inputs should fail at the local launcher and at the remote entry point.

Finally, review the account from the remote side. A perfect `SendEnv` list cannot protect a deploy account that runs arbitrary shells, reads other users' process environments, or rewrites its own startup files. Environment forwarding is one input channel. Make it exact, prove its behavior, and keep the remote command's authority narrow enough that an unexpected value cannot turn into an unrelated action.
