ssh-cli
0.5.5 — target-designation security release. Fixes GAP-SSH-EXEC-ARGC-001:
exec/sudo-exec/su-execdecided the target host by positional count, soexec <HOST> --step <CMD>executed the host name as a remote binary on whatever hostconnectlast selected. BREAKING: a lone positional is now a usage error; reach the active marker with the new--use-active <COMMAND>. The single-host envelope gainedtarget_resolvedandtarget_source— bind new consumers to those;host_resolved/host_sourceare the 0.5.5 read aliases — plusactive_fallback, anddoctornow reportslocal.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--bindrequires--i-accept-network-exposure. Newtunnel_closedevent.
- Historical note: 0.4.2 closed TUN-003 / IO-010; 0.5.0 was the EN/API rename + secrets force-init reencrypt; current product line is 0.5.5 (tunnel
--reverse/--socks5/--remote-socket, eight payload-shaping flags,tunnel_closed, partial multi-host exit 1, security fixes A1–A3). 0.5.3 brought export/import agent roundtrip, wire schema v3 dual-read, secrets CLI flags and tunnel--bind, and also closed G1–G19: SFTP integrity (no zero-byte truncate), crate-scoped graduated verbosity-v/-vv/-vvv(no russh password dump), SETSTATatime+mtime, batch cancel cardinality, single-stepexecJSON, SCPsync_data, E2E SFTP checksum E17/E18.
Give any LLM remote SSH power in one memory-safe one-shot binary.
- Read this document in Portuguese (pt-BR).
- Install with
cargo install ssh-cli --lockedfor a lockfile-aligned graph. - Upgrade with
cargo install ssh-cli --locked --force. - Verify with
ssh-cli --version. - Read full history in CHANGELOG.md.
- Integrate agents via docs/AGENTS.md and INTEGRATIONS.md.
- Follow first use in docs/HOW_TO_USE.md.
- Copy recipes from docs/COOKBOOK.md.
- Check platforms in docs/CROSS_PLATFORM.md.
- Migrate from 0.3.3+ in docs/MIGRATION.md (target line 0.5.5).
- Run tests via docs/TESTING.md.
- Consume JSON contracts under docs/schemas/README.md.
- Teach LLMs with skills/ssh-cli-en/SKILL.md.
What is it?
One-shot multi-host SSH CLI for agents
- Ship a single Rust binary with zero Node runtime and zero daemon.
- Operate N VPS hosts from XDG storage without
.envfiles. - Authenticate with password or private key per host.
- Execute
exec,sudo-exec, andsu-execas pure one-shot processes. - Capture stdout and stderr with structured JSON for orchestration.
- Auto-detect locale between
en-USandpt-BR. - Disable telemetry completely in every build.
Why ssh-cli?
Replace long-lived SSH processes with a die-after-run binary
- Avoid resident Node processes that hold sockets open between tasks.
- Cut RAM and CPU waste from long-lived SSH sessions.
- Register multi-host credentials once under XDG with atomic writes.
- Align command packing and dual maxChars semantics with the one-shot agent contract.
- Trust host keys via TOFU known_hosts instead of always-trust.
- Route errors with sysexits codes that agents classify reliably.
Superpowers
Capabilities that make agents productive
- Multi-host CRUD with
vps add|list|show|edit|remove|path|doctor|export|import - One-shot remote execution with
exec,sudo-exec,su-exec - Safe
sudopacking viash -cand shell escape - Private key auth with optional passphrase
- Dual limits
max_command_charsandmax_output_chars - Timeout with best-effort remote abort
- Bounded tunnel via mandatory
--timeout-ms; optional--bind(default127.0.0.1) - Wire schema v3: serialize English TOML keys; dual-read EN + legacy PT aliases on load
vps exportbody follows the resolved output format: JSON whenever stdout is not a TTY, which is every agent invocation and holds even when-onames a.tomlfile; a TOML body requires--output-format text; redacted by default; empty secrets stay""; non-empty redacted secrets mask as***(FIXED_MASK);--include-secretsto pipe/non-TTY needs-o/--outputor--i-understand-secrets-on-stdoutvps importaccepts TOML (EN keys + PT aliases) or JSONvps-exportenvelopes; redacted skeletons need--allow-incomplete- SCP upload and download of regular files only (no recursive directories on SCP; first solid wire fix in 0.4.0; prefer 0.5.3+ — avoid crates.io 0.3.9 SCP); SCP download propagates
sync_datafailure before atomic rename (G9) - SFTP subsystem (
ssh-cli sftp, prefer 0.5.3+ for integrity): upload/download (optional--recursivetrees, symlink no-follow),ls/mkdir/rmdir/rm/stat/rename; JSON eventssftp-transfer/sftp-list/sftp-fs-op/sftp-batch - Graduated verbosity
-v/-vv/-vvv(info/debug/trace) always crate-scoped (warn,ssh_cli=*) so russh never dumps passwords (G2/G14) - SFTP upload preserves payload bytes (G1); SETSTAT
atime+mtime(G3); fail-closed metadata (G4); permission maskSFTP_PERM_MASK(G12) - Batch cancel pads cancelled remainder so results length matches input (G5/G17)
exec --jsonsingle-step emits exactly one NDJSON success object (G8)- SCP flag parity with exec:
--timeout,--password-stdin,--key,--key-passphrase/--key-passphrase-stdin,--json(contractdocs/schemas/scp-transfer.schema.json) - SCP download writes
{path}.ssh-cli.partialthen atomic rename; upload streams 32 KiB chunks; remote mtime/mode preservation is best-effort and reported asmtime_preserved/durable - SCP JSON success requires
event: "scp-transfer"(0.4.1 IO-009); missing remote →file not found: <path>exit 66 tunnel --jsonemits structuredtunnel_listeningafter local bind- Tunnel post-bind deadline exits 0 after
tunnel_listening(pre-bind timeout still 74) (0.4.1 TUN-002) - Tunnel and health-check auth parity with exec/scp:
--password-stdin,--key,--key-passphrase/--key-passphrase-stdinas applicable (0.4.1 CLI-005/006) - Redacted
vps exportnever emitssshcli-enc:for empty password — empty secrets serialize as empty strings (0.4.1 EXP-001) - Health-check latency probe with optional
--timeout - Shell completions for bash zsh fish powershell
- Secrets via stdin flags to avoid argv leaks
- Default at-rest encryption (ChaCha20-Poly1305) with auto XDG
secrets.key; CLI flags--allow-plaintext-secrets,--secrets-key-file,--use-keyringpreferred over env - Master-key UX:
secrets status|init|reencryptwith--jsoneventssecrets-init/secrets-reencrypt; first secret write foldssecrets_key_auto_created: trueinto the samevps-addedJSON (one document) - TOFU
known_hostsand atomic config writes with flock - Key-only hosts: empty password serializes as JSON
null(not"***") invps list/show - Default tracing filter is
error(agent-first clean stderr); ambientRUST_LOGis ignored (use-v/-vv/-vvv) - Install with russh 0.62.5 for clean
cargo install --locked
Quick Start
Install and run the first remote command
|
Installation
Choose the install path that matches your environment
- Prefer crates.io with lockfile:
cargo install ssh-cli --locked(0.5.3+ preferred; avoid 0.3.9 for SCP). - Rebuild from a checkout:
cargo install --path . --locked - Do not use install without
--lockedunless you verified the crypto pins resolve cleanly. - Force upgrade after a release:
cargo install ssh-cli --locked --force - Build musl with allocator feature when targeting Alpine:
--features musl-allocator - Require Rust MSRV 1.85.0 or newer
Features
Cargo feature flags
| Feature | Default | Description |
|---|---|---|
ssh-real |
yes | Real SSH via russh + aws-lc-rs (compression none only) |
tls |
yes | rustls ≥0.23.18 + aws_lc_rs: SSH-over-TLS, mTLS, ACME |
musl-allocator |
no | mimalloc as global allocator (binary; useful on musl/Alpine) |
- Install path always enables defaults (
ssh-real+tls). - Disable real SSH / TLS only for dependency diagnosis:
cargo build --no-default-features. - docs.rs builds with
all-features = trueand--cfg docsrs(seeCargo.toml[package.metadata.docs.rs]).
Crypto policy (G-TLS)
- Default transport is SSH-2 on plain TCP (
russh+ aws-lc-rs). Host keys use TOFU under XDG (known_hosts). - Optional SSH-over-TLS (
vps add --tls, record fieldstls/tls_sni/ mTLS paths): rustls handshake then SSH on the TLS stream. Binary installsCryptoProvider(aws_lc_rs) once inmain. - mTLS / ACME:
ssh-cli tls mtls …andssh-cli tls acme …store material under XDGtls/(no product env for cert storage). - No OpenSSL /
native-tls/ dualringprovider. SSH channel compression forced tonone. - ACME validation /
invalidContact/ 4xx problem types → exit 64 non-retryable (G-E2E-01); rate limits remain transient exit 74. - Details: SECURITY.md.
Targets
Platforms covered by docs.rs metadata
x86_64-unknown-linux-gnu(default)x86_64-apple-darwin,aarch64-apple-darwinx86_64-pc-windows-msvcaarch64-unknown-linux-musl- See docs/CROSS_PLATFORM.md for runtime notes.
Usage
Register hosts then execute one-shot commands
- Default at-rest encryption (ChaCha20-Poly1305): auto
secrets.keyon first secret write; prefer CLI flags--secrets-key-file,--use-keyring,--allow-plaintext-secrets(or XDGsecrets.key); manage withssh-cli secrets status|init|reencrypt.SSH_CLI_SECRETS_KEY/SSH_CLI_SECRETS_KEY_FILEare rejected fail-closed (not a store). Opt-out for tests only via--allow-plaintext-secrets. - Prefer
--password-stdin/--keyover argv secrets. - Add password hosts with
vps add --passwordor--password-stdin. - Add key hosts with
vps add --key ~/.ssh/id_ed25519. - On key-only hosts, empty password fields serialize as JSON
nullinvps list/show(non-empty secrets mask as"***"). - Mark active host with
connect <name>. - Run remote shells with
exec <vps> "<cmd>". - Elevate with
sudo-execorsu-execwhen configured. - Diagnose XDG paths with
doctor --json(orvps doctor --json). - Discover contracts with
ssh-cli schema/ssh-cli commands. - Export redacted inventory with
vps export(JSON envelope on any non-TTY stdout; add--output-format textfor a TOML body).
Commands
Product surface for humans and agents
| Command | Purpose |
|---|---|
ssh-cli vps add |
Register a host (password or key or --use-agent / --agent-socket) |
ssh-cli vps list [--json] |
List hosts with secrets masked |
ssh-cli vps show <name> [--json] |
Show one host masked |
ssh-cli vps edit <name> |
Patch host fields |
ssh-cli vps remove <name> |
Delete host |
ssh-cli vps path |
Print config.toml path |
ssh-cli vps doctor [--json] |
Show XDG layer schema and paths |
ssh-cli doctor [--json] |
Root alias of vps doctor (G-E2E-03) |
ssh-cli schema [NAME] |
Emit embedded JSON Schema catalog or one schema body (G-E2E-02) |
ssh-cli commands |
Emit full command tree as JSON (agent discovery) |
ssh-cli vps export |
Export hosts in the resolved output format: JSON on any non-TTY stdout, even into a .toml filename; --output-format text for a TOML body; secrets redacted by default; empty secrets stay "" (never fake sshcli-enc:); --include-secrets to pipe/non-TTY requires -o/--output or --i-understand-secrets-on-stdout |
ssh-cli vps import --file |
Import hosts from TOML (EN keys + PT aliases) or JSON vps-export envelope; redacted skeletons need --allow-incomplete |
ssh-cli connect <name> |
Write sibling active file (a marker only — it opens no session) |
| Target designation (0.5.5) | Every remote surface demands an explicit target: <VPS> <COMMAND>, a selector (--all / --hosts a,b / --tags prod), or the marker on purpose via --use-active. A lone positional is a usage error (exit 64) — it used to run the host name as a remote binary on whichever host connect last selected (GAP-SSH-EXEC-ARGC-001, CWE-441). Read target_resolved and target_source back before trusting a mutation |
ssh-cli exec <vps> <cmd> |
One-shot remote command |
ssh-cli exec --hosts a,b '<cmd>' / --tags prod |
Explicit fleet subset; --tags matches any host carrying any listed tag |
ssh-cli exec --use-active '<cmd>' |
Deliberate opt-in to the connect marker; exit 66 when no marker exists |
ssh-cli exec --all '<cmd>' |
Bounded concurrent remote command on every registered host (exec-batch JSON) |
ssh-cli sudo-exec <vps> <cmd> / --all |
One-shot sudo with safe packing (fleet with --all) |
ssh-cli su-exec <vps> <cmd> / --all |
One-shot su - elevation (fleet with --all) |
ssh-cli scp upload / ssh-cli scp download |
Regular files only (no -r on SCP); flags --timeout, --password-stdin, --key, --key-passphrase[-stdin], --use-agent, --json → scp-transfer schema; missing remote → exit 66; --all → scp-batch |
ssh-cli sftp upload / ssh-cli sftp download |
SFTP v3 transfers (0.5.3 integrity: no zero-byte truncate); --recursive trees (no symlink follow); auth parity with scp; JSON sftp-transfer / sftp-batch |
ssh-cli sftp ls / ssh-cli sftp stat |
Read remote filesystem state; JSON sftp-list / sftp-fs-op |
ssh-cli sftp mkdir / ssh-cli sftp rmdir / ssh-cli sftp rm / ssh-cli sftp rename |
Mutate remote filesystem; rmdir needs an empty directory; rm and rmdir preview under --dry-run; JSON sftp-fs-op |
ssh-cli tunnel ... --timeout-ms N [--bind ADDR] [--json] |
Bounded local port forward; --bind default 127.0.0.1; auth --password-stdin / --key / --key-passphrase[-stdin]; --json emits tunnel_listening after bind; post-bind deadline exits 0 (pre-bind timeout still 74); concurrent accepts gated by --max-concurrency |
ssh-cli tunnel <vps> <port> --socks5 --timeout-ms N |
SOCKS5 proxy (RFC 1928, no-auth CONNECT); each connection opens one SSH channel; host names resolve on the remote side; BIND/UDP ASSOCIATE answered 0x07 |
ssh-cli tunnel <vps> <port> --remote-socket PATH --timeout-ms N |
Forwards a local port to a Unix domain socket on the remote host; PATH must be absolute (it names the server's filesystem) |
ssh-cli tunnel <vps> <port> <bind> <rport> --reverse --timeout-ms N |
Server listens on <bind>:<rport> and delivers back to local <port>; <rport> 0 lets the server allocate and report it in local_port; a non-loopback <bind> requires --i-accept-network-exposure |
ssh-cli --dry-run <destructive command> |
Prints the plan and exits without executing (vps remove, vps import, sftp rm, sftp rmdir, secrets init, secrets reencrypt); rejected with exit 64 on any other command |
ssh-cli health-check [<vps>] [--timeout N] / --all |
Connectivity probe; optional --timeout ms; auth --password-stdin / --key / --key-passphrase[-stdin]; --all → fleet probe (health-check-batch) |
ssh-cli secrets status / ssh-cli secrets init / ssh-cli secrets reencrypt |
Master-key and at-rest encryption (never prints key); --json emits secrets-init / secrets-reencrypt; 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 |
ssh-cli completions <shell> |
Shell completion scripts |
ssh-cli locale show / ssh-cli locale set / ssh-cli locale clear |
UI language resolution (show default; set persists XDG lang; clear removes preference) |
ssh-cli tls provider |
rustls CryptoProvider status (aws_lc_rs) |
ssh-cli tls paths |
XDG TLS directory layout |
ssh-cli tls mtls list / ssh-cli tls mtls import / ssh-cli tls mtls show / ssh-cli tls mtls remove |
mTLS identity store under XDG tls/mtls/ |
ssh-cli tls acme account create / ssh-cli tls acme account show |
ACME account lifecycle |
ssh-cli tls acme issue / ssh-cli tls acme complete / ssh-cli tls acme status / ssh-cli tls acme list |
ACME DNS-01 cert lifecycle; issue --print-challenge then publish DNS TXT, then complete |
ssh-cli --max-concurrency N … |
Global cap (1..=64) for multi-host fan-out and tunnel forwards (auto CPUs×RAM formula when omitted) |
ssh-cli -v / -vv / -vvv |
Graduated verbosity: info / debug / trace; always crate-scoped (warn,ssh_cli=*); ambient RUST_LOG ignored (G2/G14) |
Configuration (CLI-only product store)
Product knobs are flags and XDG — not SSH_CLI_* env stores
| Control | How | Example |
|---|---|---|
| Config directory | --config-dir (else XDG/directories) |
ssh-cli --config-dir /tmp/ssh-cli-test vps list |
| Language | --lang or ssh-cli locale set <code> (XDG lang) |
ssh-cli --lang pt-BR … |
| Output format | --json / --output-format json|text |
ssh-cli exec h uptime --json |
| Concurrency | --max-concurrency N (1..=64; auto formula when omitted) |
ssh-cli --max-concurrency 8 exec --all id --json |
| Verbosity | -v / -vv / -vvv (info / debug / trace; crate-scoped) |
ssh-cli -vv exec h id --json |
| Primary-key | --secrets-key-file, --use-keyring, or XDG secrets.key |
ssh-cli --secrets-key-file ./k secrets status |
| Plaintext opt-out | --allow-plaintext-secrets (tests only) |
ssh-cli --allow-plaintext-secrets … |
Fail-closed secrets env (not a store)
| Variable | Behavior |
|---|---|
SSH_CLI_SECRETS_KEY / SSH_CLI_SECRETS_KEY_FILE |
Rejected if present — use XDG secrets.key, --secrets-key-file, or --use-keyring |
OS / host boundary (detection only — not product config store)
| Variable | Role |
|---|---|
HOME |
OS home for XDG path resolution |
TERM / NO_COLOR / CLICOLOR_FORCE |
Terminal / color capability |
CI / Flatpak-related markers |
Runtime detection (vps doctor runtime.*) |
RUST_LOG |
Ignored by product (not a config store); use -v/-vv/-vvv |
- Default tracing filter is
errorso agent stderr stays clean; pass-v/-vv/-vvvfor info/debug/trace (crate-scoped; ambientRUST_LOGis ignored). - Never put host passwords in environment variables; use registry + stdin.
- Product does not read
SSH_CLI_HOME,SSH_CLI_LANG,SSH_CLI_FORCE_TEXT, orSSH_CLI_MAX_CONCURRENCYas config stores.
Integration Patterns
Wire agents with one-shot subprocesses only
- Invoke
ssh-clias a subprocess with explicit argv. - Prefer
--jsonor--output-format jsonfor machine parsing. - Parse stdout only; default log level is
errorso stderr stays silent for JSON pipelines — pass-v/-vv/-vvvwhen diagnosing (crate-scoped; ambientRUST_LOGis ignored). - Map non-zero exits with sysexits semantics before retry.
- Store hosts once via
vps addthen callexecper task. - Pass secrets through
--password-stdinwhen argv history is risky. - Read INTEGRATIONS.md for agent-specific notes.
Exit Codes
Sysexits-style codes agents must map before retry
| Code | Meaning |
|---|---|
0 |
Success |
1 |
General runtime error |
64 |
Usage / invalid arguments |
65 |
Data error (JSON/TOML/schema) |
66 |
VPS or input file not found |
73 |
Cannot create config/output |
74 |
IO or SSH connection/timeout |
77 |
Authentication rejected or host-key / sudo policy |
130 |
SIGINT |
143 |
SIGTERM |
- Prefer
--jsonor auto JSON when stdout is not a TTY (--output-formatoverrides). - Default tracing is
error, so exit handling and JSON stdout stay free of INFO noise; use-v/-vv/-vvvonly when diagnosing (crate-scoped; ambientRUST_LOGis ignored). - Retry only on transient IO/timeout (
74), never on auth (77) or usage (64).
Performance
Cold start and memory goals
- Target cold start under 100 ms on modern Linux hosts.
- Keep process memory far below a resident long-lived Node SSH process.
- Die after each command so RAM returns to the OS immediately.
- Avoid long-lived tunnels without
--timeout-ms.
Memory Requirements
Plan capacity for multi-host registries
- Config TOML size grows with host count and path lengths.
- Output buffers respect
max_output_charsper stream. - Known_hosts file grows slowly with unique host:port pairs.
- No embedding model and no Node heap are required.
Troubleshooting FAQ
Fix common install and runtime failures
- Install fails on crypto RC drift: rerun with
--lockedor use release 0.5.3+ (russh 0.62.5) (scripts/verify_install_resolve.sh). - Auth fails on key-only hosts: set
--keyonvps addor pass--key/--password-stdintoexec(rejected auth exits 77). - Auth fails with passphrase keys: use
--key-passphrase-stdin(exit 77 on reject). - Host key changed: confirm legitimacy then rerun with
--replace-host-key. - Command rejected as too long: raise
max_command_charsor shorten the command. - Config has encrypted secrets but no key: run
ssh-cli secrets initor restoresecrets.key/ env master-key /--secrets-key-file. - sudo-exec disabled: remove
--disable-sudoand setdisable_sudo=falseon the host. - Unexpected stderr noise in JSON pipelines: default log level is already
error; pass-v/-vv/-vvvwhen diagnosing (crate-scoped; ambientRUST_LOGis ignored). - Need more logs without password leak: use
-v/-vv/-vvv(crate-scopedwarn,ssh_cli=*; G2/G14) — never rely on ambientRUST_LOG(ignored). - Ambient
RUST_LOGignored: product filter is CLI-only; settingRUST_LOG=debughas no effect. - SFTP upload wrote 0-byte remote on pre-0.5.3: upgrade to 0.5.3+ (G1 integrity fix).
- SCP from crates.io 0.3.9 fails or writes 0-byte remotes: upgrade to 0.5.3+ (wire fix since 0.4.0; SFTP integrity fix in 0.5.3); only regular files, not directories.
- SCP remote missing: message is
file not found: <path>and exit 66 (prefer 0.5.3+; 0.4.2 IO-010). - SCP download fails mid-transfer: destination stays absent or previous file intact (partial uses
.ssh-cli.partial). - Redacted
vps exporton 0.4.0 wrote fakesshcli-enc:blobs for empty secrets: upgrade to 0.5.3+ (empty secrets stay empty strings since 0.4.1). - Tunnel emitted
ok: true/tunnel_listeningthen process exit 74 when the post-bind deadline hit on 0.4.0: upgrade to 0.5.3+ (post-bind deadline exits 0 since 0.4.1; pre-bind timeout still 74). - Import bad TOML: parse errors map to exit 65 (
TomlDe/ data error). - Import redacted skeleton without secrets: pass
--allow-incomplete. - macOS Gatekeeper blocks binary: run
xattr -d com.apple.quarantine /path/to/ssh-cli. - Permission denied on config: ensure
chmod 600on the XDGconfig.tomlandsecrets.key.
Contributing
- Read CONTRIBUTING.md before opening a pull request.
- Follow the bilingual documentation framework for every public doc change.
Security
- Read SECURITY.md for private vulnerability reporting.
- Prefer stdin secret flags and key files over argv passwords.
Changelog
- Read version history in CHANGELOG.md.
- The banner above and the one-line product-line note are the only history this README carries, and
tests/docs_conformance.rsrequires the latter so a reader landing on 0.5.x knows which line they are on. - Do not paste per-release notes beyond that: the changelog is the one place they age correctly, and a second copy here drifts from it silently.
License
- Dual-licensed under MIT or Apache-2.0.
- See LICENSE, LICENSE-MIT, and LICENSE-APACHE.