SSH to a host
Outbound SSH / SFTP from the Sails session to a real Linux box. Public-key only. The private key lives in a platform secret (ssec_…), never in the workspace. Line mode only (MCP-friendly) — not a full PTY. Pure Rust client (russh); no OpenSSH binary in the sandbox.
This is not inbound SSH into the Sails workspace. Inbound is ssh sails@sails.app after a human enrolls a public key with ssh-key (agents cannot enroll). See Inbound vs outbound below and /skills/ssh-key.md.
Only repository owners may open outbound SSH (same gate as secret set).
Prerequisites
- Private key as a secret — OpenSSH/PEM private key (the value contains
PRIVATE KEY). Prefer browser intake so the PEM never passes through chat. - Host allowlisted — same network policy as
curl/browse. Private and reserved IPs (including localhost) are always blocked. - Public key on the remote — the matching pubkey is in the remote
authorized_keysfor that user.
Done looks like
ssh deploy@box.example.com -i ssec_… -- 'uname -a'
Stdout is the remote kernel string. For a REPL session, status prints user@host:port, then quit returns you to normal bash. The key never appeared as a workspace file. ssh status with no session reports that you are not connected.
Worked example: store key, one-shot, then REPL
Job: put a deploy key in secrets, run one remote command, pull a config file, push a script, run it, disconnect.
1. Store the private key (once)
secret open --origin https://github.com deploy-key
secret poll INTAKE_ID
# → ssec_…
If a human cannot use the browser intake, paste through the session (less preferred):
secret set --origin https://github.com --stdin
# paste PEM, then Ctrl-D
# → ssec_…
Full secret flows: Store a secret.
2. One-shot (no REPL takeover)
ssh deploy@box.example.com -i ssec_… -- 'uname -a'
Connects, runs the remote command, disconnects. Use this when you need one answer and do not want later bash_exec calls to hit the remote host.
3. Interactive session
ssh deploy@box.example.com -i ssec_…
# optional: -p 2222 or user@host:2222
While connected, each later bash_exec is one remote line (or an SFTP verb) until quit:
status
# → deploy@box.example.com:22
ls /opt/app
get /opt/app/config.yml ./config.yml
put ./fix.sh /tmp/fix.sh
bash /tmp/fix.sh
systemctl status nginx --no-pager
quit
After quit, the next bash_exec is normal Sails bash again.
Paths for get / put
| Side | Meaning |
|---|---|
| Remote path | On the SSH host |
| Local path | In the workspace: relative to cwd, or absolute under $HOME / the active repo mount |
get /etc/hosts ./hosts.copy
put ./deploy.sh /tmp/deploy.sh
Flags and verbs
ssh user@host -i ssec_…
ssh user@host -i ssec_… -p 2222
ssh user@host:2222 -i ssec_…
ssh user@host -i ssec_… -- 'remote command'
ssh status
ssh quit
ssh help
| Flag / verb | What it does |
|---|---|
-i / --identity ssec_… | Required. Secret token for the private key |
-p / --port N | Port (default 22; or user@host:port) |
-- <command> | One-shot remote command, then disconnect |
ls [path] | SFTP listing (default .) |
get <remote> <local> | Download into the workspace |
put <local> <remote> | Upload from the workspace |
exec <command> | Explicit remote exec (same as bare command) |
status | Show connected user@host:port |
help | Usage |
quit / exit | Disconnect; return to normal bash |
Auth: public-key only via -i ssec_…. Password auth, agent forwarding, and workspace key files are not supported.
Host keys: trust-on-first-use (TOFU), pinned per repository. The first successful connect stores the SHA256 fingerprint. A later key change is rejected (HOST KEY MISMATCH). Verify the host before clearing the pin.
Limits
- No interactive PTY / full-screen tools (
vim,top, password prompts) - No password auth, agent forwarding, or workspace key files
- No
rsyncor recursive directory sync — one file at a time withget/put - One active SSH session per tenant; connecting again replaces the current session
- SFTP
getis capped (default 10 MiB) - Tools that need a TTY hang unless you pass non-interactive flags (
--no-pager,-y, and so on)
Inbound vs outbound
| Direction | Command | Who |
|---|---|---|
| Outbound (this page) | ssh user@host -i ssec_… | Repository owners, from the Sails session |
| Inbound | ssh sails@sails.app | Humans after ssh-key add; agents cannot enroll or connect |
Inbound enroll (human laptop + an existing Sails shell):
# on the laptop
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_sails -N ""
# in Sails
ssh-key add --label laptop "$(cat ~/.ssh/id_ed25519_sails.pub)"
ssh-key list
# ssh-key rm SHA256:… or ssh-key rm <key-id>
Then ssh sails@sails.app (or a Host sails block with IdentityFile). One-shot: ssh sails@sails.app -- 'pwd'. Full inbound skill: /skills/ssh-key.md.
Failure modes
| What you see | What it means | What to do |
|---|---|---|
identity must be a secret token | You passed a file path | -i ssec_… only |
does not look like an OpenSSH/PEM private key | Wrong secret contents | Re-set the secret with a real PEM |
host … is not allowed by network policy | Host not allowlisted | Allowlist the suffix or enable full-internet |
private or reserved endpoint blocked | Resolved to a private IP | Use a public host |
HOST KEY MISMATCH | Remote host key changed | Verify the host before clearing the pin |
permission denied / owner required | Non-owner session | Owner runs SSH |
public-key authentication failed | Wrong user/key, or pubkey missing on host | Fix authorized_keys on the remote |
| Commands hang / no TTY | Tool needs a PTY | Use non-interactive flags (--no-pager, etc.) |
Related: Store a secret. Skills: /skills/ssh.md, /skills/ssh-key.md.