ssh-cli 0.5.5

Native Rust CLI that gives LLMs (Claude Code, Cursor, Windsurf) the ability to operate remote servers via SSH over stdin/stdout
Documentation
# ssh-cli

> One-shot multi-host SSH CLI for AI agents with XDG storage and no Node daemon

> **0.5.5** — target-designation security release. Fixes GAP-SSH-EXEC-ARGC-001: `exec`/`sudo-exec`/`su-exec` decided the target host by positional *count*, so `exec <HOST> --step <CMD>` executed the host name as a remote binary on whatever host `connect` last selected. BREAKING: a lone positional is now a usage error; reach the active marker with the new `--use-active <COMMAND>`. The single-host envelope gained `target_resolved` and `target_source` — bind new consumers to those; `host_resolved`/`host_source` are the 0.5.5 read aliases — plus `active_fallback`, and `doctor` now reports `local.active_vps`.
>
> **0.5.4** — security and agent-native release. Fixes a remote pre-auth DoS in the SSH banner path (A1), stops server-sent setuid bits landing on downloaded files (A3), closes the world-readable window on ACME/mTLS private keys (A2), and adds payload-shaping flags (`--select`, `--filter`, `--limit`, `--sort`, `--dedupe-by`, `--count-only`, `--truncate-content`, `--max-output-bytes`) applied before serialization. BREAKING: partial multi-host failure now exits **1** (was 65); a non-loopback `--bind` requires `--i-accept-network-exposure`. New `tunnel_closed` event.


- Historical note: **0.4.1** closed AUD-POST (export empty honesty, tunnel deadline, auth parity, scp-transfer event); **0.4.2** closed TUN-003 / IO-010; **0.5.0** was EN/API rename + secrets force-init reencrypt; **0.5.2** closed residual G-E2E + wire v3 dual-read; **0.5.3** closed G1–G19 (SFTP integrity, graduated crate-scoped verbosity, batch cancel cardinality; see [CHANGELOG 0.5.3](https://github.com/danilo-aguiar-br/ssh-cli/blob/main/CHANGELOG.md#053---2026-07-30)); current product line is **0.5.5**, which closed GAP-SSH-EXEC-ARGC-001 (explicit target designation, CWE-441) and GAP-SSH-EXEC-ENVELOPE-002 (`target_resolved` / `target_source` / `active_fallback` on success and error envelopes; `host_resolved` and `host_source` are 0.5.5 read aliases), on top of the 0.5.4 work (A1–A3, G-TUN-R01/R02/R03, G-ERR-R02).

ssh-cli is a memory-safe Rust binary that lets LLMs operate remote servers through stdin/stdout. It replaces persistent long-lived Node SSH daemons processes with birth-execute-die invocations, multi-host XDG registries, password or key auth, safe sudo packing, su-exec, bounded tunnels, TOFU known_hosts, dual command/output char limits, and **default at-rest secret encryption** (ChaCha20-Poly1305 + `secrets.key`). **Transport is SSH-2** (`russh` + **aws-lc-rs**), not TLS/HTTPS — no product `rustls`/`native-tls`/OpenSSL; SSH compression is `none` only. Telemetry is forbidden. Prefer `cargo install ssh-cli --locked`. Current product line: **0.5.5** (47 leaf commands; `tunnel` serves four modes — local, `--reverse`, `--socks5`, `--remote-socket`; eight payload-shaping flags applied before serialization; `--dry-run` and `--no-input` global; partial multi-host failure exits **1**; prefer **0.5.3+** for SFTP; russh 0.62.5; wire schema v3; graduated `-v`/`-vv`/`-vvv` crate-scoped; see CHANGELOG 0.5.5).

- Read this document in [Portuguese (pt-BR)](llms.pt-BR.txt).
- Expanded index: [llms-full.txt](llms-full.txt)

## Primary Documentation
### Canonical English sources for LLM ingestion
- [README](https://github.com/danilo-aguiar-br/ssh-cli/blob/main/README.md): install, commands, env, FAQ
- [HOW_TO_USE](https://github.com/danilo-aguiar-br/ssh-cli/blob/main/docs/HOW_TO_USE.md): first command in 60 seconds
- [COOKBOOK](https://github.com/danilo-aguiar-br/ssh-cli/blob/main/docs/COOKBOOK.md): executable recipes for hosts and remote ops
- [AGENTS](https://github.com/danilo-aguiar-br/ssh-cli/blob/main/docs/AGENTS.md): agent economics plus JSON contract
- [MIGRATION](https://github.com/danilo-aguiar-br/ssh-cli/blob/main/docs/MIGRATION.md): upgrade path from 0.3.3 to **0.5.5**
- [INTEGRATIONS](https://github.com/danilo-aguiar-br/ssh-cli/blob/main/INTEGRATIONS.md): agent and IDE catalog
- [CHANGELOG](https://github.com/danilo-aguiar-br/ssh-cli/blob/main/CHANGELOG.md): release history (**0.5.5** = explicit target designation + target provenance on every envelope; **0.5.4** = security A1–A3 + tunnel modes + payload shaping; **0.5.3** = G1–G19)
- [CONTRIBUTING](https://github.com/danilo-aguiar-br/ssh-cli/blob/main/CONTRIBUTING.md): contribution workflow
- [SECURITY](https://github.com/danilo-aguiar-br/ssh-cli/blob/main/SECURITY.md): vulnerability disclosure and secrets at rest
- [CODE_OF_CONDUCT](https://github.com/danilo-aguiar-br/ssh-cli/blob/main/CODE_OF_CONDUCT.md): community standards
- [schemas index](https://github.com/danilo-aguiar-br/ssh-cli/blob/main/docs/schemas/README.md): machine-readable JSON contracts
- [skill en](https://github.com/danilo-aguiar-br/ssh-cli/blob/main/skills/ssh-cli-en/SKILL.md): imperative agent skill package

## Core Commands
### Subcommands grouped by lifecycle (`ssh-cli commands`)
- `vps add|list|show|edit|remove|path|doctor|export|import` manages multi-host XDG registry
- `schema [NAME]` emits embedded JSON Schema catalog/body (G-E2E-02); `doctor` is root alias of `vps doctor` (G-E2E-03); `commands` emits command tree
- `connect` writes sibling active host marker
- `exec` runs one-shot remote command; **fleet:** `exec --all '<CMD>' --json` (bounded concurrent sessions); `exec --json` emits a **single** NDJSON object (G8)
- `sudo-exec` elevates with safe `sh -c` packing; **fleet:** `sudo-exec --all …`
- `su-exec` elevates with one-shot `su -`; **fleet:** `su-exec --all …`
- `scp upload|download` transfers **regular files only** (no `-r`; crates.io **0.3.9** SCP wire was broken — use **0.5.3+**); flags `--timeout`, `--password-stdin`, `--key`, `--key-passphrase` / `--key-passphrase-stdin`, `--json` → contract `docs/schemas/scp-transfer.schema.json` with required `event: "scp-transfer"`; download uses `.ssh-cli.partial`, `sync_data`, then rename (G9); mtime/mode preservation best-effort, reported as `mtime_preserved` / `durable`; upload streams 32 KiB; missing remote → `file not found: <path>` exit **66**; **fleet:** `scp upload|download --all …` → `scp-batch` schema
- **SFTP (prefer 0.5.3+):** `sftp upload|download [--recursive]` + `ls|mkdir|rmdir|rm|stat|rename` (subsystem v3; no symlink follow on trees; JSON `sftp-transfer` / `sftp-list` / `sftp-fs-op` / `sftp-batch`). G1–G19 closed: upload no longer truncates to 0 bytes; SETSTAT atime+mtime; fail-closed metadata; `SFTP_PERM_MASK` 0o7777 on upload, and since 0.5.4 (A3) `SFTP_PERM_MASK_UNTRUSTED` 0o0777 on download, so server-sent setuid/setgid/sticky never reach the local file
- `tunnel` opens bounded forwarding with required `--timeout-ms`; optional `--bind` (default `127.0.0.1`); auth `--password-stdin` / `--key` / `--key-passphrase[-stdin]`; optional `--json` emits `tunnel_listening` after bind; post-bind deadline exits **0** after listening (pre-bind timeout still **74**); concurrent accepts gated by `--max-concurrency`
- `tunnel` modes are mutually exclusive: default local forward, `--socks5` (RFC 1928 no-auth `CONNECT`), `--remote-socket <PATH>` (remote Unix socket), `--reverse` (server listens, delivers back to `<local_port>`); `remote_host`/`remote_port` are omitted by the first two and name the **server's** bind under `--reverse`, where `0` lets the server allocate; both tunnel events carry `mode`
- `health-check [--timeout]` probes connectivity and latency; auth `--password-stdin` / `--key` / `--key-passphrase[-stdin]`; **fleet:** `health-check --all --json` → `health-check-batch`
- `vps export` body follows the **resolved output format**: JSON on any non-TTY stdout, even into a `.toml` filename; TOML needs `--output-format text`; redacted: empty password is `""`, never an `sshcli-enc:` blob of empty; `--include-secrets` to pipe/non-TTY needs `-o`/`--output` or `--i-understand-secrets-on-stdout`
- `vps import` accepts TOML (EN keys + PT aliases) **or** JSON `vps-export` envelopes; redacted skeletons need `--allow-incomplete`; bad TOML → exit **65**
- `secrets status|init|reencrypt` manages master-key and at-rest encryption (never prints key); `--json` → `secrets-init` / `secrets-reencrypt` schemas; first secret write folds `secrets_key_auto_created: true` into the same `vps-added` JSON (one document); flags `--allow-plaintext-secrets`, `--secrets-key-file`, `--use-keyring`
- `completions` emits shell completion scripts
- `locale show|set|clear` manages UI locale preference under XDG (not product env)
- `tls provider|paths|mtls {list,import,show,remove}|acme {account {create,show}, issue, complete, status, list}` optional SSH-over-TLS material under XDG `tls/`

## Defaults and Limits
- Default SSH port is 22
- Default timeout is 60000 ms
- Default max_command_chars is 1000
- Default max_output_chars is 100000
- Schema version for new hosts is **3** (dual-read EN serialize + legacy PT aliases)
- Default tracing filter is **error**; ambient `RUST_LOG` is **ignored**; graduated verbosity: `-v` info / `-vv` debug / `-vvv` trace (`ArgAction::Count`), always crate-scoped `warn,ssh_cli=*` (G2/G14 — never bare global debug; no russh password dump even at `-vvv`)
- Empty password fields are JSON `null` on key-only hosts (`vps list` / `show`); non-empty secrets mask as `"***"`
- `health-check` accepts `--timeout <ms>` plus auth flags above
- Secrets at rest: **encrypted by default** (auto `secrets.key`); prefer CLI flags `--allow-plaintext-secrets`, `--secrets-key-file`, `--use-keyring` over env; opt-out for tests only; no `.env` product store — XDG + CLI flags only; `SSH_CLI_SECRETS_*` fail-closed if present
- Redacted export: empty secrets serialize as empty strings, never `sshcli-enc:` ciphertext of empty
- **Multi-host modus operandi:** prefer `exec|sudo-exec|su-exec|scp|sftp|health-check --all` **or** `--hosts a,b` (one process, bounded concurrent SSH) over N single-host spawns; batch JSON schemas `health-check-batch` / `exec-batch` / `scp-batch` / `sftp-batch` include `max_concurrency`; on cancel, remainder is filled cancelled so `results.len() == input.len()` (G5/G17)
- Global `--max-concurrency N` (1..=64) caps fan-out and tunnel forwards (auto = CPUs×4 vs free RAM/2 / 16 MiB)
- `vps doctor --probe-ssh [--hosts a,b] --json` = single root `event: vps-doctor` (`local` + `ssh_probe`); `tunnel` is single-host by contract
- Multi-file SCP (single-host): `scp upload VPS f1 f2 … REMOTE_DIR` bounded per file. Fleet multi-file uses the named slots `--all --src f1 --src f2 --dest REMOTE_DIR`; a selector with three or more positionals is exit 64 (Explicit Target Designation)
- Telemetry is always false
- Install: always prefer `--locked`; product line **0.5.3+**

## Security rules for agents
- Prefer `--password-stdin` / `--key` over argv passwords
- Never log host passwords, master-key, or decrypted secrets
- Never set ambient `RUST_LOG` for product debug — use `-v`/`-vv`/`-vvv` (crate-scoped allowlist)
- On auth failure try `--key`, `--password-stdin`, `--key-passphrase-stdin` (auth reject → exit **77**)
- Review TOFU host-key errors before `--replace-host-key`
- Parse stdout only; default stderr is silent at tracing level error
- Prefer **0.5.3+** for SFTP integrity (G1 closed truncation bug)

## Exit Codes
- `0` success
- `1` general runtime error
- `64` usage error
- `65` data error
- `66` VPS or file not found
- `73` cannot create config output
- `74` IO or SSH connection error
- `77` authentication rejected
- `130` SIGINT
- `143` SIGTERM