A lightweight Go daemon that acts as a local intermediary for SSH connections. It exposes an MCP (Model Context Protocol) server over a Unix socket so Claude Code can run remote commands, read and write files, and use other SSH capabilities through a persistent SSH ControlMaster session that you manage.
The key idea: Claude never touches your SSH credentials or manages connection lifecycle. You bring up the ControlMaster session, the daemon proxies through it.
Step 1: Build and install the binary
go build ./cmd/claude-ssh-daemon
sudo cp claude-ssh-daemon /usr/local/bin/Step 2: Start your SSH ControlMaster session
The daemon never manages SSH connections. You start and maintain the session yourself. The -S socket path must match the ssh_socket value in your config.
ssh -M -S /tmp/ssh-ctrl-user@host.sock -fN user@hostStep 3: Write your config
Create ~/.config/claude-ssh-daemon/config.json. See the Config section below for the full example.
Step 4: Install the background service
The daemon should run persistently so it is available whenever Claude Code needs it.
-
macOS (launchd): Copy the plist, substitute your username (launchd does not expand
~in paths), then load the service:cp contrib/com.claude-ssh-daemon.plist ~/Library/LaunchAgents/ sed -i '' "s/YOUR_USERNAME/$(id -un)/g" ~/Library/LaunchAgents/com.claude-ssh-daemon.plist launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.claude-ssh-daemon.plist
View logs with:
tail -f ~/Library/Logs/claude-ssh-daemon.logTo unload:
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.claude-ssh-daemon.plist
-
Linux (systemd): Copy
contrib/claude-ssh-daemon.serviceto~/.config/systemd/user/and run:systemctl --user daemon-reload systemctl --user enable --now claude-ssh-daemonThe service file has install instructions in its comments. View logs with:
journalctl --user -u claude-ssh-daemon -f
Step 5: Register the MCP server with Claude Code
Claude Code spawns MCP servers as subprocesses using a restricted PATH that typically does not include Homebrew (/opt/homebrew/bin) on macOS. You must use the full path to socat.
Find the path first:
which socat
# macOS Apple Silicon (Homebrew): /opt/homebrew/bin/socat
# macOS Intel (Homebrew): /usr/local/bin/socat
# Linux (apt/system): /usr/bin/socatThen register using that path (replace the path and socket path as needed):
claude mcp add claude-ssh-daemon /opt/homebrew/bin/socat -- - UNIX-CONNECT:/tmp/claude-ssh-daemon.sockThis registers the server globally (all projects). Verify it connected:
# In any Claude Code session:
/mcpYou should see claude-ssh-daemon listed as connected with its tools.
SSH tools (v2.1 -- all 9 shipped)
| Tool | Capability | Type | Description |
|---|---|---|---|
ssh_connection_status |
always on | read-only | Check whether the SSH ControlMaster socket is alive and get a re-establishment hint if it is not |
ssh_exec |
exec |
destructive | Execute a remote shell command via the SSH ControlMaster session |
ssh_read_file |
file_read |
read-only | Read the contents of a remote file |
ssh_list_dir |
file_read |
read-only | List the contents of a remote directory |
ssh_write_file |
file_write |
destructive | Write or overwrite a remote file |
ssh_upload_file |
file_write |
destructive | Upload a local file to the remote host |
ssh_download_file |
file_write |
destructive | Download a remote file to the local machine |
ssh_forward_port |
port_forward |
destructive | Create a local SSH port forward via the ControlMaster session (auto-allocates a free local port) |
ssh_list_forwards |
port_forward |
read-only | List all active port forwards managed by this daemon instance, with running/dead status |
Core daemon
- Starts up and creates a Unix socket at the path you configure
- Loads config from
~/.config/claude-ssh-daemon/config.json - Exposes an MCP server over the socket using the official
go-sdk(spec 2025-11-25) - Accepts one client connection at a time (sequential, intentional)
- Handles
SIGTERMandSIGINTfor clean shutdown with a 5-second drain timeout - Removes the socket file on exit so restarts do not hit
EADDRINUSE
Security
- Socket created with mode 0600 (owner read/write only)
- Umask-before-listen pattern to close the race window between
listen()andchmod()(mitigates CVE-2023-45145 class) - Capability toggles in config (
exec,file_read,file_write,port_forward) all default to off; disabled tools are never registered - Safeguards layer: prompt-injection scanning on all tool output (on by default), overwrite protection for
ssh_write_file(opt-in), destructive command blocking forssh_exec(opt-in) - Destructive command blocking is a guardrail against accidents, not a security boundary: it only inspects the first token of the command, so
sh -c 'rm -rf /',true && rm -rf /,find . -delete, and truncating shell redirection all pass through it. Do not rely on it to contain an untrusted caller. - Per-host
base_dir: lexically confines remote file paths and thessh_execcwd to a directory subtree (opt-in per host) - Top-level
local_base_dir: lexically confines the local path ofssh_upload_fileandssh_download_fileto a directory subtree (opt-in, applies to all hosts) - Per-host
exec_allowlist: restrictsssh_execto a set of command prefixes and rejects shell control characters (opt-in per host)
- Go 1.25+
- macOS or Linux
- An SSH ControlMaster session already running (you manage this); OpenSSH 6.0+ recommended (
-O checkrequires OpenSSH 5.6+, 6.0+ is a safe documented floor) socatfor the Claude Code stdio bridge:brew install socat(macOS) orapt install socat(Debian/Ubuntu). Use the full absolute path tosocatin your MCP config — Claude Code spawns servers with a restricted PATH and may not find socat by name alone (see Step 5 above).
go build ./cmd/claude-ssh-daemonCreate ~/.config/claude-ssh-daemon/config.json.
Single-host (legacy, still fully supported):
{
"ssh_socket": "/tmp/ssh-ctrl-user@host.sock",
"mcp_socket": "/tmp/claude-ssh-daemon.sock",
"ssh_user": "ubuntu",
"ssh_host": "my.server.com",
"capabilities": {
"exec": true,
"file_read": true,
"file_write": true,
"port_forward": false
}
}Multi-host:
{
"mcp_socket": "/tmp/claude-ssh-daemon.sock",
"default_host": "prod",
"local_base_dir": "/Users/me/work",
"hosts": {
"prod": {
"socket": "/tmp/ssh-ctrl-ubuntu@prod.sock",
"user": "ubuntu",
"host": "prod.example.com",
"base_dir": "/srv/app",
"exec_allowlist": ["git ", "make ", "npm "]
},
"staging": {
"socket": "/tmp/ssh-ctrl-ubuntu@staging.sock",
"user": "ubuntu",
"host": "staging.example.com"
}
},
"capabilities": {
"exec": true,
"file_read": true,
"file_write": true,
"port_forward": false
}
}With multi-host config every tool accepts an optional host parameter. Omit it to target default_host. Each host needs its own ControlMaster session running against its socket path.
Top-level optional fields:
| Field | Default | Effect |
|---|---|---|
local_base_dir |
"" (unset) |
Absolute path, host-independent, opt-in. When empty or absent local paths are unconfined (previous behaviour). When set it confines the local path of ssh_upload_file (the source) and ssh_download_file (the destination) to this directory tree. Must be an absolute path or the daemon fails to start with config: local_base_dir must be an absolute path, got "..."; the value is cleaned to a canonical form at load time. Confinement is lexical — local symlinks are not resolved and may point outside, the same limitation as the per-host base_dir. Paths outside it are rejected with isError: true and local path "..." is outside local_base_dir "...". |
Per-host optional fields:
| Field | Default | Effect |
|---|---|---|
base_dir |
"" (unset) |
Absolute path. When set, remote paths are confined to this directory tree by lexical path checking: ssh_read_file, ssh_write_file, ssh_list_dir, the remote path of ssh_upload_file and ssh_download_file, and the ssh_exec cwd. It never constrains the local path of the transfer tools — use the top-level local_base_dir for that. Paths that resolve outside are rejected with isError: true. Symlinks on the remote are not resolved and may point outside base_dir. |
exec_allowlist |
null (allow-all) |
JSON array of command prefixes. When null or absent: all commands are allowed. When set to [] (empty array): all commands are denied. When set to ["git ", "make "]: two rules apply. (1) The command string must start with one of the listed prefixes — a raw string prefix match, not token-aware. (2) The command must contain none of these shell control characters: ; & ` |
Safeguards (optional):
{
"safeguards": {
"guard_disabled": false,
"allow_overwrite": false,
"allow_delete": false,
"patterns": []
}
}| Field | Default | Effect |
|---|---|---|
guard_disabled |
false |
When false, stdout/stderr from every tool is scanned for prompt-injection patterns. A warning is appended to the result but the operation is not blocked. |
allow_overwrite |
false |
When false, ssh_write_file refuses to write to paths that already exist on the remote. |
allow_delete |
false |
When false, ssh_exec blocks commands whose first token has the basename rm, unlink, truncate, shred, or dd. Only that first token is checked, so sh -c 'rm -rf /', true && rm -rf /, find . -delete, and truncating redirection are not caught — treat this as an accident guardrail, not a security boundary. |
patterns |
[] |
Additional regex strings appended to the built-in injection-detection ruleset. |
Capability toggles: only tools for enabled capabilities are registered. Disabled tools are invisible to Claude — they do not appear in tools/list.
./claude-ssh-daemonThe daemon logs to stderr in JSON format. It logs the socket path on startup and each connection/disconnection event. For persistent operation, use the service files in contrib/ (see Quick start above).
Claude Code speaks MCP over stdio. The daemon listens on a Unix domain socket. A socat bridge connects the two -- Claude Code launches socat, which forwards its stdin/stdout to the daemon's socket.
Important: Claude Code spawns MCP server processes with a restricted PATH that typically does not include Homebrew prefixes on macOS. Always use the absolute path to socat. Find it with which socat.
Global registration (recommended):
# macOS Apple Silicon — adjust path for your system (see `which socat`)
claude mcp add claude-ssh-daemon /opt/homebrew/bin/socat -- - UNIX-CONNECT:/tmp/claude-ssh-daemon.sockReplace /tmp/claude-ssh-daemon.sock with the mcp_socket value from your config. This makes the server available in all Claude Code projects.
Per-project registration:
Add a .claude/mcp.json file in your project directory:
{
"mcpServers": {
"claude-ssh-daemon": {
"type": "stdio",
"command": "/opt/homebrew/bin/socat",
"args": ["-", "UNIX-CONNECT:/tmp/claude-ssh-daemon.sock"]
}
}
}Adjust the command path for your platform (/usr/local/bin/socat on Intel Mac, /usr/bin/socat on Linux).
Note: Claude Code has no native Unix-socket transport type -- the stdio + socat bridge is the correct and only approach. A unix or socket transport key is not supported.
The MCP socket is created mode 0600 (owner-only), so the socat bridge is only reachable by the user who owns the daemon process.
Once the daemon is running and registered, you can talk to your remote hosts naturally. Here are some prompts that work well:
Explore and understand a remote service
Check the status of nginx on my prod server — is it running, what config is it using, and are there any recent errors in the logs?
Tail logs and diagnose issues
Read the last 100 lines of /var/log/app/error.log on prod and summarize any recurring errors or patterns you see.
Deploy a change
Upload my local ./dist/app.js to /srv/app/dist/app.js on the staging host, then restart the Node process with: systemctl --user restart myapp
Database access via port forwarding
Forward the Postgres port from db.internal:5432 so I can connect to it locally. Tell me the local port when it's ready.
After the forward is established, you can then connect with psql -h 127.0.0.1 -p <local_port> -U myuser mydb.
Inspect and edit a config file
Read /etc/nginx/sites-available/mysite on prod. The upstream timeout is too low — update it to 60s and reload nginx.
Multi-host workflow
Compare the app version currently deployed on staging vs prod — read /srv/app/package.json on each host and tell me if they differ.
Check connection health before starting work
Check the SSH connection status for all my configured hosts before we start.
v2.1 is complete. All 9 SSH tools work end-to-end: 7 core tools (connection check, exec, file read/write/list/upload/download) plus 2 port-forwarding tools (ssh_forward_port, ssh_list_forwards). Multi-host config, base_dir sandboxing, exec_allowlist, and safeguards (prompt-injection scanning, overwrite protection, delete blocking) are all shipped.