Playbook: SSH Agent Pattern — silent-unless-error

Purpose

All Claude agent SSH commands MUST capture output and only surface it on failure. This prevents SSH warnings (post-quantum key exchange, banners, MOTD) from polluting chat transcripts, reduces token cost, and prevents accidental leakage of command output that may contain config or partial credential data.

1. SSH LogLevel configuration (suppress PQ warning)

Every environment must have LogLevel ERROR in its SSH config.

Add to the END of ~/.ssh/config (after all per-host stanzas):

Host *
    LogLevel ERROR

This is a catch-all fallback — per-host settings take precedence. LogLevel ERROR allows FATAL and ERROR only. Suppresses: WARNING (PQ key exchange), INFO, DEBUG, banner messages.

Environments that need this:

  • Local Windows developer machine: C:\Users\konar\.ssh\config
  • vps-i1: /root/.ssh/config and /home/claude-runner/.ssh/config
  • bms-4: /root/.ssh/config and /home/ubuntu/.ssh/config
  • vps-h1: /root/.ssh/config
  • dev laptop: /root/.ssh/config

2. Silent-unless-error pattern

PowerShell (local Windows + Windows agents)

$sshOut = ssh -i C:\Users\konar\.ssh\id_ed25519 -o BatchMode=yes root@server "command" 2>&1
if ($LASTEXITCODE -ne 0) {
    Write-Host "SSH FAILED (exit $LASTEXITCODE) on server:"
    Write-Host $sshOut
    # Follow p24-infra error-notification standard: GH issue + Discord alert
    exit 1
}
# On success: report only the specific fact needed — NEVER echo $sshOut raw

Bash (Linux workers — vps-i1, bms-4, vps-h1)

ssh_out=$(ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes root@server "command" 2>&1)
ssh_exit=$?
if [ $ssh_exit -ne 0 ]; then
    echo "SSH FAILED (exit $ssh_exit) on server:"
    echo "$ssh_out"
    exit $ssh_exit
fi
# On success: echo only the specific fact needed, not $ssh_out

3. When to deviate

  • Interactive SSH (ssh root@server with no command): no capture needed — user is watching output live
  • SSH returning a single safe value (e.g. hostname, uptime, docker ps --format): can echo if the value is not credential-related

4. On SSH failure — error notification standard

When an SSH command fails in an automated agent, follow the p24-infra error-notification standard:

# Discord
curl -s -X POST "$P24_DISCORD_INFRA_SCRIPTS_ERRORS_WEBHOOK_URL" \
  -H "Content-Type: application/json" \
  -d '{"embeds":[{"title":"SSH ERROR — <server>","color":15158332,"description":"'"$ssh_out"'"}]}'
 
# GH Issue
gh issue create --repo radieu/p24-infra \
  --title "[Infra] <server> — SSH command failed" --label "bug" --body "## Error\n$ssh_out"

5. NEVER pipe a multi-line credential-bearing command string into ssh via PowerShell |

Incident (2026-07-11, 3714): a read-only OVH DBaaS Redis check built a multi-line redis-cli command string ("AUTH $passwordn INFO clientsn ...") and piped it into a remote redis-cli over SSH the same way scripts\sops-reset-redis.ps1 already does for local Docker containers ($redisCmds | & ssh ... "docker exec -i $Container redis-cli ..."). Against a remote/external Redis endpoint (no docker exec -i in between), PowerShell’s pipe-to-native- process stdin serialization prepended a UTF-8 BOM byte before AUTH. redis-cli failed to parse AUTH as a command name and echoed the unparsed argument — the password itself — back in its ERR unknown command '<BOM>AUTH', with args beginning with: '<value>' error text, which then landed directly in the invoking session’s own tool output. This is a credential exposure via the session transcript, not a network leak — see docs/secrets-rotation-log.md (2026-07-11 entry) and issue #3714 for the full incident writeup.

Do NOT:

# DANGEROUS against a remote/external redis-cli target — BOM can corrupt AUTH and
# cause the password to be echoed back in redis-cli's own error text.
$redisCmds = "AUTH $password`nINFO clients`n..."
$redisCmds | & ssh @sshOpts "root@$ip" "redis-cli -h $host -p $port --no-auth-warning"

Safer alternatives, in order of preference:

  1. Check reachability without AUTH first. An unauthenticated PING/INFO against a password-protected instance returns NOAUTH Authentication required. — this alone confirms the instance is up and enforcing auth, with zero credential material sent or at risk. Often this is all a read-only “is it healthy” check actually needs (see redis-v32 verification in #3712).
  2. If AUTH is genuinely required (e.g. INFO/DBSIZE against an external DBaaS instance), avoid PowerShell’s object-pipeline serialization entirely — write the command bytes to a local temp file with [System.IO.File]::WriteAllText($path, $content, [System.Text.UTF8Encoding]::new($false)) (no-BOM, the same pattern already mandatory for SOPS writes) and redirect it into ssh via cmd /c shell redirection (cmd /c "ssh ... < ”$path"") instead of a PowerShell | pipe — < redirection reads the file’s raw bytes without PowerShell’s pipeline object-to-stream conversion. Delete the temp file immediately after.
  3. sops-reset-redis.ps1’s own pattern ($cmds | & ssh ... "docker exec -i $Container ...") is lower-risk because it targets a local Docker containerdocker exec -i has its own stdin handling and this exact BOM/echo failure mode has not been observed there — but it is still the same fundamentally fragile pattern. Treat any future report of that script’s Redis phase silently failing/erroring as a possible instance of this same class of bug, not just a wrong- password or missing-container issue.
  4. Never capture and print the raw result of a redis-cli invocation that included an AUTH argument unless you have first confirmed (by code-reading the exact bytes sent) that the command was parsed as intended. If in doubt, redirect to $null and check exit code / presence of +OK/PONG via a boolean match only, never Out-String/Write-Host the raw response text.
  • .claude/task-playbooks/server-operation.md — server SSH pre-flight checklist (PLAYBOOK annotation requirement)
  • docs/playbooks/credential-rotation.md — handling credentials during server operations
  • docs/playbooks/sops-windows-crlf.md — Windows file write safety
  • docs/playbooks/static-api-key-incident-rotation.md — what to do when a secret value appears in chat output