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

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.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), SETSTAT atime+mtime, batch cancel cardinality, single-step exec JSON, SCP sync_data, E2E SFTP checksum E17/E18.

docs.rs crates.io License MSRV Rust Contributor Covenant

Give any LLM remote SSH power in one memory-safe one-shot binary.

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 .env files.
  • Authenticate with password or private key per host.
  • Execute exec, sudo-exec, and su-exec as pure one-shot processes.
  • Capture stdout and stderr with structured JSON for orchestration.
  • Auto-detect locale between en-US and pt-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 sudo packing via sh -c and shell escape
  • Private key auth with optional passphrase
  • Dual limits max_command_chars and max_output_chars
  • Timeout with best-effort remote abort
  • Bounded tunnel via mandatory --timeout-ms; optional --bind (default 127.0.0.1)
  • Wire schema v3: serialize English TOML keys; dual-read EN + legacy PT aliases on load
  • vps export body follows the resolved output format: JSON whenever stdout is not a TTY, which is every agent invocation and holds even when -o names a .toml file; a TOML body requires --output-format text; redacted by default; empty secrets stay ""; non-empty redacted secrets mask as *** (FIXED_MASK); --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
  • 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_data failure before atomic rename (G9)
  • SFTP subsystem (ssh-cli sftp, prefer 0.5.3+ for integrity): upload/download (optional --recursive trees, symlink no-follow), ls/mkdir/rmdir/rm/stat/rename; JSON events sftp-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 mask SFTP_PERM_MASK (G12)
  • Batch cancel pads cancelled remainder so results length matches input (G5/G17)
  • exec --json single-step emits exactly one NDJSON success object (G8)
  • SCP flag parity with exec: --timeout, --password-stdin, --key, --key-passphrase / --key-passphrase-stdin, --json (contract docs/schemas/scp-transfer.schema.json)
  • SCP download writes {path}.ssh-cli.partial then atomic rename; upload streams 32 KiB chunks; remote mtime/mode preservation is best-effort and reported as mtime_preserved / durable
  • SCP JSON success requires event: "scp-transfer" (0.4.1 IO-009); missing remote → file not found: <path> exit 66
  • tunnel --json emits structured tunnel_listening after 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-stdin as applicable (0.4.1 CLI-005/006)
  • Redacted vps export never emits sshcli-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-keyring preferred over env
  • Master-key UX: secrets status|init|reencrypt with --json events secrets-init / secrets-reencrypt; first secret write folds secrets_key_auto_created: true into the same vps-added JSON (one document)
  • TOFU known_hosts and atomic config writes with flock
  • Key-only hosts: empty password serializes as JSON null (not "***") in vps list / show
  • Default tracing filter is error (agent-first clean stderr); ambient RUST_LOG is 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

cargo install ssh-cli --locked
ssh-cli secrets init   # optional explicit master-key; auto-created on first secret write
printf %s 'demo-password-not-real' | ssh-cli vps add \
  --name prod \
  --host prod.example.com \
  --port 22 \
  --user admin \
  --password-stdin
ssh-cli connect prod
ssh-cli exec prod "hostname" --json

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 --locked unless 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 = true and --cfg docsrs (see Cargo.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 fields tls / tls_sni / mTLS paths): rustls handshake then SSH on the TLS stream. Binary installs CryptoProvider (aws_lc_rs) once in main.
  • mTLS / ACME: ssh-cli tls mtls … and ssh-cli tls acme … store material under XDG tls/ (no product env for cert storage).
  • No OpenSSL / native-tls / dual ring provider. SSH channel compression forced to none.
  • 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-darwin
  • x86_64-pc-windows-msvc
  • aarch64-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.key on first secret write; prefer CLI flags --secrets-key-file, --use-keyring, --allow-plaintext-secrets (or XDG secrets.key); manage with ssh-cli secrets status|init|reencrypt. SSH_CLI_SECRETS_KEY / SSH_CLI_SECRETS_KEY_FILE are rejected fail-closed (not a store). Opt-out for tests only via --allow-plaintext-secrets.
  • Prefer --password-stdin / --key over argv secrets.
  • Add password hosts with vps add --password or --password-stdin.
  • Add key hosts with vps add --key ~/.ssh/id_ed25519.
  • On key-only hosts, empty password fields serialize as JSON null in vps list / show (non-empty secrets mask as "***").
  • Mark active host with connect <name>.
  • Run remote shells with exec <vps> "<cmd>".
  • Elevate with sudo-exec or su-exec when configured.
  • Diagnose XDG paths with doctor --json (or vps 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 text for 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 error so agent stderr stays clean; pass -v/-vv/-vvv for info/debug/trace (crate-scoped; ambient RUST_LOG is 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, or SSH_CLI_MAX_CONCURRENCY as config stores.

Integration Patterns

Wire agents with one-shot subprocesses only

  • Invoke ssh-cli as a subprocess with explicit argv.
  • Prefer --json or --output-format json for machine parsing.
  • Parse stdout only; default log level is error so stderr stays silent for JSON pipelines — pass -v/-vv/-vvv when diagnosing (crate-scoped; ambient RUST_LOG is ignored).
  • Map non-zero exits with sysexits semantics before retry.
  • Store hosts once via vps add then call exec per task.
  • Pass secrets through --password-stdin when 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 --json or auto JSON when stdout is not a TTY (--output-format overrides).
  • Default tracing is error, so exit handling and JSON stdout stay free of INFO noise; use -v/-vv/-vvv only when diagnosing (crate-scoped; ambient RUST_LOG is 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_chars per 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 --locked or use release 0.5.3+ (russh 0.62.5) (scripts/verify_install_resolve.sh).
  • Auth fails on key-only hosts: set --key on vps add or pass --key / --password-stdin to exec (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_chars or shorten the command.
  • Config has encrypted secrets but no key: run ssh-cli secrets init or restore secrets.key / env master-key / --secrets-key-file.
  • sudo-exec disabled: remove --disable-sudo and set disable_sudo=false on the host.
  • Unexpected stderr noise in JSON pipelines: default log level is already error; pass -v/-vv/-vvv when diagnosing (crate-scoped; ambient RUST_LOG is ignored).
  • Need more logs without password leak: use -v/-vv/-vvv (crate-scoped warn,ssh_cli=*; G2/G14) — never rely on ambient RUST_LOG (ignored).
  • Ambient RUST_LOG ignored: product filter is CLI-only; setting RUST_LOG=debug has 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 export on 0.4.0 wrote fake sshcli-enc: blobs for empty secrets: upgrade to 0.5.3+ (empty secrets stay empty strings since 0.4.1).
  • Tunnel emitted ok: true / tunnel_listening then 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 600 on the XDG config.toml and secrets.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.rs requires 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