- Read this document in Portuguese (pt-BR)
- The current product line is 0.5.x and the latest release is 0.5.6
- The table below lists which ssh-cli versions currently receive security patches
- Users on unsupported lines must upgrade to the current release line
| Version | Status | Security Patches |
|---|---|---|
| 0.5.6 | Supported | Yes, latest release and current line |
| 0.5.x before 0.5.6 | Supported | Yes, current line, but 0.5.0 to 0.5.4 carry GAP-SSH-EXEC-ARGC-001 and every earlier 0.5.x lacks the 0.5.6 fixes, so upgrade to 0.5.6 |
| 0.4.x | Supported | Critical / high when feasible, prefer upgrade to 0.5.x |
| 0.3.x | Limited | Critical only when feasible, crates.io 0.3.9 SCP wire was inoperant, so upgrade to 0.5.3+ for transfers |
| 0.2.x | Limited | Critical fixes only when feasible |
| 0.1.x | Unsupported | No patches |
| < 0.1 | Unsupported | No patches |
- Every ssh-cli release before 0.5.6 resolves dependencies that carry the advisories below, so upgrade to 0.5.6
russhbelow 0.65.1 carries RUSTSEC-2026-0492, RUSTSEC-2026-0495, GHSA-fw5c-8223-6h42 and GHSA-3hm4-p7h4-rvwprustix1.1.4 carries RUSTSEC-2026-0486webpki-roots1.0.9,rustls-webpki0.103.15 andwebpki-root-certs1.0.9 carry RUSTSEC-2026-0381, RUSTSEC-2026-0382 and RUSTSEC-2026-0383 (CVE-2026-111943)- The cause, the fixed version and the residual limit of each one are in Security Fixes in 0.5.6
- Report a new vulnerability through Reporting a Vulnerability
russh0.62.5 is now 0.65.1, which closes RUSTSEC-2026-0492 (HIGH,client::Handlercallbacks fired for a channel that was never opened) and RUSTSEC-2026-0495 (the agent server never calledAgent::confirm)- The same upgrade closes GHSA-fw5c-8223-6h42, where a zero maximum packet size stalled every channel write, and GHSA-3hm4-p7h4-rvwp, a stack overflow before authentication triggered by a nested OpenSSH certificate
Handler::check_server_keynow receives&PublicKeyOrCertificate, and the certificate branch is unreachable becausePreferred::DEFAULTleaveshost_key_certificatesempty, so the embedded key goes through the same TOFU checkrustix1.1.4, flagged unsound by RUSTSEC-2026-0486, is now 1.1.6, and the yankedasync-executor1.14.0 is now 1.14.1webpki-roots1.0.12 drops eight more distrusted roots: emSign Root CA - C1 and emSign ECC Root CA - C3 in 1.0.11, then Buypass Class 2, Buypass Class 3, DigiCert Trusted Root G4, QuoVadis Root CA 1 G3, QuoVadis Root CA 3 G3 and Certigna Root CA in 1.0.12- The
advisory-hitsgate readadvisory.patched_versionswhilecargo auditpublishesversions.patched, so it reported no patched release for advisories that had one, and it now reads the published field
ssh-cli secrets init --keyringreported success and persisted nothing- The cause was the crate, not the machine:
keyring = "3"resolved 3.6.3, which has no default feature and fell back to an in-processmockstore - The mock kept the key in the memory of the process that wrote it, so the next invocation answered
encryption_active: falseand the agent held a success it could not rely on - The backend is now
keyring4.2.0 with thev1feature, which compiles a real store on all three systems: Keychain on macOS, Credential Manager on Windows and Secret Service over zbus on the remaining unix targets - The store is consulted through
Entry::store_status()before every write and every read, so an unavailable store exits 69 instead of answeringOk - A second cause was measured: the leaf flag
secrets init --keyringnever reached the runtime state, because dispatch forwarded only the global--use-keyring - A third cause was measured: a key stored by an earlier
secrets init --keyringwas invisible without repeating the flag - The keyring entry is scoped by a digest of the resolved config directory, because the OS store is machine-wide while
secrets.keyandconfig.tomlare per profile - Without that scope, an isolated run with
--config-diroverwrote the key of the real profile, which this audit measured happening - A fourth cause was measured in an intermediate 0.5.6 build itself:
--use-keyring secrets initansweredkey_source: xdg_file, because the subcommand received only the leaf flag and the global one never reached the write path - A fifth cause was measured against a live profile:
secrets init --keyringon a profile that already heldsecrets.keyansweredkey_source: keyringand minted a second key, leaving the file to answer first on every later read - The profile then held two different primary keys,
secrets statusreportedxdg_file, and every secret already encrypted failed to decrypt with the key the command had just named as active - The guard now asks the profile instead of the destination, so an existing key in either store refuses the write without
--force, and--forceremoves the other source so exactly one key survives the rotation - D2:
secrets init --forceno longer writessecrets.key.bak - The backup copy left key material on disk after every rotation and violated the project rule against backup files
- The store is reached through a scoped OS thread, because the zbus blocking facade parks the calling thread on an
async-ioreactor while the process runs a multi-thread Tokio runtime
- The keyring round-trip is proved on Linux with a session bus, and macOS and Windows only reach type-check in the
cross-targetsgate, so the Keychain and Credential Manager stores are not executed by this release's battery - A machine without a session bus exits 69 and the tests report SKIP, never PASS
- The config directory digest is an identifier, not a secret, so it does not resist a local attacker who can already read the store's entry names
- A1: the TLS (Transport Layer Security) root store no longer trusts a distrusted authority:
webpki-roots1.0.9,rustls-webpki0.103.15 andwebpki-root-certs1.0.9 still carriedemSign ECC Root CA - G3, and the eMudhra intermediates under it have a public private key (Mozilla Bug 2083449, CVE-2026-111943, RUSTSEC-2026-0381/0382/0383) - The client ACME (Automatic Certificate Management Environment) and every outbound TLS connection build their
RootCertStorefrom that list, so the defect reached both - The minimum is pinned in the manifest,
webpki-roots = "1.0.10", and the lock resolvesrustls-webpki0.103.17 andwebpki-root-certs1.0.10 - The manifest pin exists on purpose: a bare lock update could walk back to the compromised version silently, and now it cannot build
- A4: a new gate compares the advisory database with the resolved tree on every run:
scripts/check_advisory_hits.pyrunscargo audit --jsonand fails on any entry invulnerabilities.listor on a yanked package - The older
advisory-freshnessgate measured only the age of the clone, so a stale database could keep the battery green over a live CVE
- The
advisory-hitsgate refuses to run when the advisory database is older than seven days, so a disconnected machine fails instead of passing on stale data - The pin covers the three crates in the measured advisory: a future advisory against a different crate still depends on the next database refresh
- Terminal escape sequences no longer reach the operator's terminal in text mode: A host name, an error message or a stored field could carry ESC, BEL or bidi characters, and text mode printed them raw
src/output/sanitize.rsescapes control, formatting and bidi characters as\u{1b}-style text in the one text funnel: success, error and warning messages,vps list,vps show,health-check,doctorand the batch outputssftp lsandsftp statalso escape\nand\tin the remote name, so a hostile server cannot forge extra lines in a listing- JSON output is unchanged, because it is for machines and
serde_jsonalready escapes ESC and BEL - A new host name has a strict charset:
VpsName::try_newaccepts ASCII letters, digits, dot, hyphen and underscore, starting with a letter or digit, from 1 to 255 bytes - It guards
vps add, the destination ofvps renameandvps edit --name, andvps import - Reading stays lenient, so an old
config.tomlwith a hostile name still loads,doctorlists the name escaped inlocal.invalid_host_namesandvps renamerepairs it - Two host tables that normalize (NFC) to the same name make loading fail with exit 65 and
config has two hosts named ..., so a host never disappears silently - A name that becomes a target (
exec,health-check,--hosts,connect) is refused with exit 64 when it carries control, formatting or bidi characters tls mtls import --namerefuses the same characters with exit 64 and creates no directory, so an identity name cannot become a hostile directory name- The config directory is born private: Every directory level the CLI creates gets mode
0700, where it used to inherit0755from the umask next to a0600config.toml - An existing directory is tightened to
0700only when it is the default XDG directory, is not a symlink and belongs to the current user - Any other existing directory is never chmodded:
doctorreportslocal.config_dir_exposedandlocal.config_dir_permissions, and a log warning is visible with-v vps export -ocreates missing directories0700, andknown_hosts.lockandlangare born0600with no window before a chmod- RUSTSEC-2026-0235 is resolved by removal: The advisory came from
rkyv, which entered the lock only through the optionalrust_decimal, so the unused money module and that dependency are gone - The removal is a BREAKING change for library users and the changelog carries its migration path
- The published package no longer carries the nine
scripts/*.sh, the stray root filejsonor pending*.snap.newfiles, and theCargo.tomlexcludekeeps them out - The rename path has no re-sealing step of its own, and the credential is never retyped:
VpsRecordseals its secrets without associated data, so the blob is not bound to the host name - The ciphertext token does change, because the ordinary save seals every secret again with a fresh nonce, and what survives is the credential, readable under the new name
- The export, edit and import detour is gone:
vps exportredacts secrets by design, so the round trip forced an operator to retype a credential into argv or a file, which is the exposure the stdin paths exist to avoid - That export also carried no
use_agentoragent_socket, so an agent-authenticated host came back unauthenticated and failed at the next connection instead of at the import - Clearing a field cannot silently leave a host unusable: The eight clear flags are refused with exit 64 when the result would have no primary authentication method, or when
--no-agent-socketwould strip the only endpoint on a platform that has no default - A malformed record at rest is worse than a refused edit, because every later command inherits it
- Contention is bounded, so a hostile or wedged lock holder cannot make the CLI hang: The config lock waits
--lock-wait-ms(default 5000, max 300000) under full-jitter backoff and then exits 69unavailable, which is retryable - Before this, a peer process holding
config.toml.lockkept every writer blocked indefinitely, with no output, no exit and no diagnosis
- The stdout and stderr of a remote
execstay raw by design, so a hostile server can still write escape sequences into them - JSON keeps every character of the escaped set raw, for example U+202E and ZWSP, because JSON is for machines
- Windows has no chmod, so the config directory relies on the profile ACL
- The global shaping flags reduce collections only, so they did not reach the
doctorobject: readlocal.config_dir_exposedwithssh-cli doctor --json | jaq .local.config_dir_exposed(a later 0.5.6 change added--selectfor single records, so this limit describes the first 0.5.6 fixes) - macOS and Windows only pass through type-check in the
cross-targetsgate, so mode0700was not executed there - A legacy record with a control character in a tag, host or user still exits 65, because only the host NAME is tolerant on load
- Error bodies stay in English by design, so agents receive stable text
- GAP-SSH-EXEC-ARGC-001: the target host was chosen by positional count instead of by content (CWE-441).
exec,sudo-execandsu-execaccepted one or two positionals: two meant<VPS> <COMMAND>, one meant "command against the hostconnectlast marked" --stepis an appending flag, not a positional, soexec <HOST> --step <CMD>collapsed into the one-positional branch- The host name was then run as a remote binary (remote exit 127) and every
--stepafter it executed on the marked host: a host the operator never typed - Because the later steps succeeded, the process exited 0, so the operator read success while systemd units, packages and firewall rules landed somewhere else
- This is a security defect and not an ergonomics one: in a CLI for remote access the critical dimension is not what runs, it is where
- It required no attacker: ambiguous argv from a legitimate operator was enough, which is why it outranks a bug needing a hostile peer
- Fixed by making the ambiguity unrepresentable, not by guessing intent: A lone positional is now a usage error (exit 64) on every remote surface,
health-checkincluded - Designate the target explicitly with
<VPS> <COMMAND>, with a selector (--all,--hosts a,b,--tags prod), or with the marker on purpose via the new--use-active - Opting in with no marker present is exit 66
- Failure now happens during parse, before any socket opens, so the blast radius of the mistake is a usage message instead of an audit across two hosts
- GAP-SSH-EXEC-ENVELOPE-002, the single-host envelope named no host, so the deflection above was undetectable from stdout: The fleet path already emitted a per-host
namewhile the single-host path, the one that accepted the silent fallback, emitted none, so the asymmetry ran opposite to the risk - Every envelope now carries
target_resolved,target_source(argv|active_marker|selector) andactive_fallback, on the error envelope too once the target resolved, where they are absent rather than empty host_resolvedandhost_sourceship as 0.5.5 read aliases- Bind new consumers to the canonical names
doctorreportslocal.active_vps, so the marker is now legible by machine: before 0.5.5 no command exposed it and the only way to read it was opening the XDG file- Upgrade to 0.5.5 for both
- Neither needs a remote attacker, and the first one silently rewrote the wrong machine
- A1, remotely triggerable abort while logging the pre-auth banner: The SSH banner is remote input that arrives before authentication, and it is retained for diagnostic logging
- The
AUTH_BANNER_MAX_CHARS(512) log cap already existed (G-SSH-14) - The defect was that the cut was applied by byte index, which panics whenever it lands inside a multi-byte character
- The release profile sets
panic = "abort", so there is no unwind: an unauthenticated peer could kill the process outright with a banner containing a single well-placed non-ASCII character, taking a whole multi-host fan-out down with it - Truncation now happens on character boundaries
- Note the residual limit of the fix: russh materialises the complete banner and hands it to the callback, so the client still holds the full string in memory, what A1 bounds is the panic, not the allocation
- This is the highest-severity class here because it needs no credentials
- A2, world-readable window on ACME and mTLS (mutual TLS) private keys: Key material under XDG
tls/acme/andtls/mtls/was written withstd::fs::write, which creates the file readable by any local user and only then tightens the mode, leaving a window a co-tenant could read - Writes now go through the shared helper that creates at
0600, so the window does not exist - Fixing the mode after the fact only shortens exposure
- It never removes it
- A3, server-sent elevation bits landing on downloaded files: The SCP
Cheader and SFTP (SSH File Transfer Protocol) attributes are produced by the server, so the mode they carry is untrusted - Masking with
0o7777let a hostile peer put setuid, setgid or sticky on the local file - Inbound modes are now masked with
SFTP_PERM_MASK_UNTRUSTED(0o0777), so only plainrwxtriples cross the network inbound - The remote end has no authority to decide what is privileged on your machine
- Non-loopback bind now requires acknowledgement:
tunnel --bindoutside loopback requires--i-accept-network-exposure(G-TUN-R13) - Before this,
--bind 0.0.0.0silently published the forwarded remote service to the whole local network - Under
--reversethe same flag guards the server's bind address, because that is the exposed end in that direction - Upgrade to 0.5.4 for all four
- A1 and A3 are reachable by a remote peer
- A2 is reachable by a local co-tenant
- Report security issues through GitHub Security Advisories in the public
ssh-clirepository as the preferred private channel - Use email at daniloaguiarbr@proton.me only as fallback when GitHub private reporting is unavailable
- Never open a public GitHub issue, pull request, or discussion for security-related reports
- Include a minimal reproduction, affected versions, and expected versus actual behavior
- Include environment details such as OS, architecture, and rustc version
- Include a CVSS v4.0 severity estimate when possible (CVSS 3.1 accepted as fallback) to accelerate triage
- Redact live credentials from every attachment and log excerpt
- Triage of every advisory starts within 72 business hours of submission
- Initial acknowledgment is sent within that same 72-business-hour window
- You receive a case identifier and an assigned maintainer contact
- Progress updates are shared at least every 7 days until resolution or public disclosure
- Critical severity (CVSS 9.0 to 10.0) receives a patch within 7 calendar days of validated triage
- High severity (CVSS 7.0 to 8.9) receives a patch within 14 calendar days of validated triage
- Medium severity (CVSS 4.0 to 6.9) receives a patch within 30 calendar days of validated triage
- Low severity (CVSS 0.1 to 3.9) receives a patch within 90 calendar days of validated triage
- Released fixes include a CHANGELOG entry and a GitHub Security Advisory when the affected line is still supported
- Coordinated disclosure is the default for validated vulnerabilities
- Public disclosure is delayed until a fixed release is available or the Fix SLA window expires with documented reason
- Researchers may publish after mutual agreement or after the coordinated window ends
- Security fixes land on the current minor line first
- Backports to older minor lines happen only when impact and effort justify the work
- Users must upgrade with
cargo install ssh-cli --locked --forceafter a security release python3 scripts/generate_sbom.pywrites a CycloneDX SBOM (software bill of materials) totarget/sbom/ssh-cli.cdx.jsonwhencargo-cyclonedxis installed- Without
cargo-cyclonedxthe same command writes only acargo treeinventory, which is not an SBOM
- Security researchers who request public credit are listed here after coordinated disclosure completes
- The list starts empty for the current ownership line under
danilo-aguiar-br
- Trust boundary: CLI argv, env, XDG files (
config.toml,secrets.key,known_hosts), and the remote SSH server are untrusted inputs - Only the local process identity and OS keyring (when enabled) are treated as higher-trust material
- Assets: SSH passwords / key passphrases / sudo-su secrets (memory + at-rest), host-key fingerprints (TOFU), primary AEAD (Authenticated Encryption with Associated Data) key (
secrets.key/ keyring), the ability to run remote shell / SCP / local port forwards, and, as GAP-SSH-EXEC-ARGC-001 proved, the identity of the host reached, since the same argv running on the wrong machine is a breach even when every credential held - Adversaries considered: (1) hostile CLI/agent argv and env
- (2) malicious or MITM SSH servers (host-key change)
- (3) co-tenant local users reading world-readable files or process listings
- (4) compromised transitive crates (supply chain)
- (5) ambiguous argv from a legitimate operator or agent: no adversary required
- GAP-SSH-EXEC-ARGC-001 was of this class: a well-formed command line that the product resolved to a host nobody named
- Treat "which machine does this reach" as an attacker-controlled question even when no attacker exists
- Out of scope: multi-tenant SaaS isolation, browser XSS/CSRF, container escape, kernel Spectre mitigations beyond normal process isolation, and long-lived daemon attack surface (this CLI is one-shot)
- Controls: typed validation at CLI/import/path boundaries
SecretString+ zeroize- ChaCha20-Poly1305 at rest
- TOFU host keys with constant-time fingerprint compare
- Shell packing with single-quote escape and secrets on channel stdin
- No local shell-out for SSH
- Terminal-safe text output: control, formatting and bidi characters are escaped before any text reaches the terminal (0.5.6)
- Strict host-name and mTLS identity-name charset on creation (0.5.6)
- Config directory born
0700(0.5.6) deny.toml+ localcargo deny check- Release
overflow-checks - Documented
unsafeonly at OS console/signal edges - Review trigger: re-read this model on any change that adds network protocols, long-lived processes, new secret stores, or
unsafeoutside OS FFI wrappers
- Default path: product network transport is SSH-2 via
russhon plain TCP - Wire crypto uses the aws-lc-rs backend (
ssh-real) - Host authentication is TOFU (
known_hostsunder XDG) - Optional SSH-over-TLS: when a VPS has
tls = true(or--tlsonvps add/edit), the client dials TCP, completes a rustls handshake (SNI + optional mTLS), then runs SSH on the TLS stream - Floor: rustls ≥ 0.23.18 (never 0.23.13–0.23.17 Acceptor CVE window), the
min_rustlsvalue thatssh-cli tls providerprints - The manifest asks for more than the floor:
Cargo.tomldeclaresrustls = { version = "0.23.25" } - Provider: binary
maincallsCryptoProvider::install_defaultwithaws_lc_rsonly (once, before the Tokio runtime) - Libraries use
ClientConfig::builder/ process default: they never reinstall - Dual provider
ringis banned indeny.toml - Forbidden stacks: no
native-tls, OpenSSL,libssh2-sys, or product use ofring - PEM parse uses
rustls-pki-types(not unmaintainedrustls-pemfile) - mTLS: client cert/key PEMs under XDG
tls/mtls/<name>/(ssh-cli tls mtls import,ssh-cli tls mtls list,ssh-cli tls mtls show,ssh-cli tls mtls remove) or absolute paths on the VPS record - ACME: Let's Encrypt via
instant-acme(DNS-01, agent two-step:tls acme issue --domain <DOMAIN> --print-challenge→ set TXT →tls acme complete --domain <DOMAIN>) - Account + certs under XDG
tls/acme/(0o600) - Prefer staging with
--stagingfor tests - SSH channel compression is disabled (client prefers
noneonly) to reduce compression side-channels when secrets cross the channel - Release and supply-chain gates for this policy are local scripts (
cargo deny check,cargo tree, residual tests) (G-TLS-11)
| Component | S | T | R | I | D | E | Primary controls |
|---|---|---|---|---|---|---|---|
| CLI argv / env / stdin secrets | spoof agent args | tamper flags | deny which host ran it | info leak via argv | DoS huge stdin | elev via sudo-exec | typed clap + secret stdin → SecretString + size cap + explicit target designation with target_resolved / target_source on every envelope (0.5.5) |
XDG config.toml / secrets.key |
forge host entry | rewrite secrets | - | read co-tenant | truncate/corrupt | - | 0o600 file + 0o700 directory (0.5.6) + flock + AEAD at-rest |
TOFU known_hosts |
MITM host | swap fingerprint | - | fingerprint oracle | lock starve | - | flock + constant-time compare + --replace-host-key explicit |
| SSH session (russh) | fake server | MITM / key change | - | channel sniff | hang / flood | remote shell | TOFU + timeouts + no local shell-out |
Remote packing (sh -c sudo/su) |
- | inject metachar | - | secret on argv | - | unintended elev | single-quote escape + password on channel stdin |
| Multi-host fan-out | - | reorder jobs | - | cross-host leak in logs | resource exhaustion | - | Semaphore budget + cancel + redacted JSON |
| Terminal text output | hostile host name posing as another | ESC / bidi redraws the screen | - | hidden or forged lines | - | - | one escape funnel + strict name charset (0.5.6) |
| Supply chain (deps) | typosquat | malicious crate | - | backdoor | build DoS | - | deny.toml + local cargo deny + --locked + lockfile TLS bans |
- Password on argv is still parseable, with a warning, so prefer stdin
- The remote host after authentication is fully trusted for the one-shot command the operator requested, a premise that only holds once the target is designated explicitly, which is why 0.5.5 made designation mandatory
- A co-tenant with the same UID can read process memory, which is OS isolation and not this CLI
- Spectre and CPU side-channel threats are out of scope for a one-shot userland tool
- Never: rely on ambient
RUST_LOGfor product debugging, the product ignores it (not a config store) - Use graduated flags only:
-v(info) /-vv(debug) /-vvv(trace) viaArgAction::Count - The filter is always crate-scoped (
warn,ssh_cli=*): never bare globaldebug/trace - This keeps russh (and other deps) from dumping passwords or channel material onto stderr
- Even
-vvvdoes not enable russh encrypted-channel dumps - Only the
ssh_clicrate (plus warn-level deps) is allowlisted - Prefer 0.5.3+ so agents cannot accidentally enable global debug that leaked credentials in older lines
- Prefer private key authentication over password authentication when the host allows it
- Prefer
--password-stdin,--sudo-password-stdin, and--su-password-stdinover argv secrets (password-on-argv emits a stderr warning on 0.5.2+) - Prefer stdin password flags for agent runs
- Avoid embedding live secrets in shell history
- Default at-rest encryption (ChaCha20-Poly1305): on first secret write, auto-creates
secrets.key(0o600) next toconfig.tomlunless you opt out - Secrets control is CLI/XDG only:
--allow-plaintext-secrets,--secrets-key-file,--use-keyring, or XDGsecrets.key - No
.envproduct store - Key resolution order:
--secrets-key-file→ keyring (when--use-keyring) → XDGsecrets.key→ keyring holding a key from an earliersecrets init --keyring SSH_CLI_SECRETS_KEY/SSH_CLI_SECRETS_KEY_FILEare rejected fail-closed (not a store)- CLI:
ssh-cli secrets status|init|reencrypt(never prints the master key) --jsonemitssecrets-init/secrets-reencryptwithout key material- Opt-out for tests only:
--allow-plaintext-secrets(no env store) - Never: log the master key, passwords, or decrypted secrets
--include-secretsto pipe/non-TTY requires-o/--outputor--i-understand-secrets-on-stdout(guard against accidental secret dump on stdout)- Keep
config.tomlmode0600and restrict backup locations - Review TOFU host-key change errors before using
--replace-host-key - Never commit host registries that include live secrets
- Never commit local tooling sidecars (e.g.
.setting.cyber/), the Grok config (~/.grok/config.toml), XDGconfig.toml/secrets.key/known_hosts, or the password files handed to the E2E script (--password-file,--sudo-password-file) into the repository - Real-SSH E2E (G-E2E-05) must keep credentials outside the tree: prefer
python3 scripts/e2e_real_ssh.py --local-sshd(unprivileged sshd on loopback, Unix only), or the lab flags--host,--port,--userand--password-file(plus--sudo-password-file, a path or-for stdin), or$HOME/.grok/config.tomlvia--from-grok-config - The script takes flags only and reads no environment variables
- Offline / no lab → SKIP exit 0. The script refuses grok configs under the repo root
- Demo passwords in public docs are placeholders only (e.g.
demo-password-not-real) - Never reuse them on live hosts
- Disable elevation with
--disable-sudowhen a workflow must not escalate - Keep
tunnel --bindon loopback unless a second machine genuinely must reach the forward - Any routable bind requires
--i-accept-network-exposure, which is the acknowledgement of that exposure rather than a formality - Treat
tunnel --remote-socket /var/run/docker.sockas privilege delegation: forwarding a Docker or systemd socket hands the local port whatever authority that socket grants on the remote host - Prefer
tunnel --socks5bound to loopback over a routable fixed forward when an agent needs several destinations - One acknowledged local bind beats N exposed ones
- Run one-shot commands only
- Never expect a long-lived SSH daemon from this CLI
- Install with
--lockedto avoid accidental crypto re-resolve drift - Read machine output as JSON, because text mode escapes control characters for the human reader
- Keep the config directory private: The CLI never chmods an existing directory that is not the default XDG one, so read
local.config_dir_exposedinssh-cli doctor --jsonand runchmod 700yourself when it istrue - After upgrading to 0.5.6, read
local.invalid_host_namesinssh-cli doctor --jsonand repair each legacy name withssh-cli vps rename - Switch a host between password, key and agent with
vps edit, which clears the old secret, instead of the export, edit and import detour - Prefer current 0.5.3+ for SFTP integrity (G1 closed 0-byte upload truncation), crate-scoped verbosity (G2/G14: no russh password dumps), the supply-chain floor (russh 0.65.1), working SCP/SFTP wire, ACME permanent classification (
invalidContact→ exit 64), and the three-asterisk mask (FIXED_MASK) shown byvps listandvps show - Historical honesty: 0.4.1 fixed empty-secret redacted export (never
sshcli-enc:of empty) and tunnel post-bind exit 0 - Default redacted
vps exportclears secrets:passwordis an empty string and the other secrets are null - Empty secrets must serialize as empty strings, never encrypted
sshcli-enc:blobs of empty values (0.4.2 EXP-001)