ssh-for-agents
# ssh-for-agents
An **MCP (Model Context Protocol) server** that lets AI agents talk to SSH servers —
run commands, read files, and list directories on remote hosts — behind a
configurable **command-safety policy**.
Because it speaks MCP, any MCP-capable agent (Claude Desktop, Claude Code, or your
own client) can use it with no glue code. Connections are async (built on
[`asyncssh`](https://asyncssh.readthedocs.io/)) and pooled per host.
```
AI agent ──MCP(stdio)──▶ ssh-for-agents ──asyncssh──▶ remote host(s)
│
guardrails policy
(readonly / guarded / unrestricted)
```
## Quickstart
```bash
git clone https://github.com/WilliamSmithEdward/pySSHForAgents.git
cd pySSHForAgents
python -m venv .venv
.venv\Scripts\Activate.ps1 # macOS/Linux: source .venv/bin/activate
pip install -e .
```
Store your SSH key's passphrase in an environment variable (it's never written to a
config file) — Windows `setx NAME "value"` then **open a new terminal**, macOS/Linux
`export NAME=value` — then register a host and test it end to end:
```bash
ssh-for-agents-config add myserver --hostname 203.0.113.10 --user deploy \
--passphrase-env MYSERVER_SSH_PASSPHRASE
ssh-for-agents-run myserver "uptime"
```
That's it. Now point your agent at it — see [Connect to Claude](#connect-to-claude-code)
below, or [Codex / a local Ollama model](SETUP.md#6-wire-it-into-your-agent). The full
walkthrough (keys, host-key verification, every agent) is in [SETUP.md](SETUP.md).
## Tools exposed to the agent
| Tool | What it does |
| --------------- | ----------------------------------------------------------------------- |
| `list_hosts` | List configured hosts and the active safety policy. |
| `check_command` | Dry-run the policy for a command (allow / needs_confirmation / block). |
| `run_command` | Run a shell command on a host. Destructive commands need `confirm=true`.|
| `read_file` | Read a remote file over SFTP (size-capped). |
| `list_dir` | List a remote directory over SFTP. |
## Requirements
- Python 3.10+
- An SSH key (password auth is not wired up by design — use keys).
## Install
```bash
python -m venv .venv
.venv\Scripts\activate # Windows
# source .venv/bin/activate # macOS/Linux
pip install -e .
```
`asyncssh` needs `bcrypt` to decrypt passphrase-protected OpenSSH keys; it's a
declared dependency, so the install above pulls it in.
## Configure
Copy the example and edit it:
```bash
cp hosts.example.json hosts.json
```
```json
{
"hosts": {
"myserver": {
"hostname": "203.0.113.10",
"username": "deploy",
"port": 22,
"private_key": "~/.ssh/id_ed25519",
"passphrase_env": "MYSERVER_SSH_PASSPHRASE",
"known_hosts": "~/.ssh/known_hosts",
"verify_host_key": true
}
},
"policy": {
"mode": "guarded",
"extra_denylist": [],
"extra_readonly_allow": [],
"max_output_bytes": 100000,
"default_timeout": 60
}
}
```
Host fields:
- **`private_key`** — path to the private key (`~` is expanded).
- **`passphrase_env`** — *name* of an environment variable holding the key's
passphrase. The passphrase itself is **never** stored in the config. Omit if the
key has no passphrase.
- **`known_hosts`** — path to a known_hosts file used to verify the server's host
key. Omit to use the default (`~/.ssh/known_hosts` + system files).
- **`verify_host_key`** — set `false` to skip host-key verification (insecure; only
for throwaway hosts).
The server looks for `hosts.json` in the working directory, or wherever
`SSH_AGENT_CONFIG` points.
> `hosts.json` is git-ignored since it describes your infrastructure. Commit
> `hosts.example.json` instead.
## Adding & managing hosts
The config holds **any number of hosts** — add as many as you like under `hosts`.
Manage them with the bundled CLI instead of hand-editing JSON:
```bash
# add or update a host
ssh-for-agents-config add prod \
--hostname 203.0.113.10 --user deploy \
--key ~/.ssh/id_ed25519 --passphrase-env PROD_SSH_PASSPHRASE
ssh-for-agents-config list # show configured hosts + policy
ssh-for-agents-config remove prod # drop a host
ssh-for-agents-config import-ssh-config # pull hosts from ~/.ssh/config
```
`--config <path>` (or `$SSH_AGENT_CONFIG`) targets a specific `hosts.json`.
**Hot-reload:** a *running* server watches `hosts.json` and reloads it when it
changes, so a newly added host is usable on the next tool call — no restart needed.
Live connections to unchanged hosts are kept; a half-written or invalid edit is
ignored (the last good config stays active).
## Shell CLI — for agents that can't call MCP tools
Some agents run shell commands but can't reliably call MCP tools — notably a **local
(Ollama) model in Codex**, which emits tool-call names the host rejects. For those,
the same hosts and safety policy are exposed as a plain command, `ssh-for-agents-run`:
```bash
ssh-for-agents-run hosts # list aliases + policy
ssh-for-agents-run linode "df -h" # run a command (default verb)
ssh-for-agents-run linode "rm /tmp/x" --confirm
ssh-for-agents-run check linode "rm -rf /" # dry-run the policy
ssh-for-agents-run read linode /etc/os-release
ssh-for-agents-run ls linode /var/log
```
It reuses `hosts.json` and the guardrails, and on Windows resolves the key passphrase
from the persisted user environment even when the launching shell filters env vars.
See [SETUP.md](SETUP.md) for wiring it into Codex (AGENTS.md + giving the sandbox read
access to `~/.ssh`).
## Run
```bash
ssh-for-agents # console script (serves over stdio)
python -m ssh_agent_mcp # equivalent
```
The server communicates over stdio — it's normally launched *by* an MCP client
rather than run by hand.
### Connect to Claude Code
```bash
claude mcp add ssh-for-agents -- /abs/path/to/.venv/Scripts/python.exe -m ssh_agent_mcp
```
Set the working directory (or `SSH_AGENT_CONFIG`) so it finds `hosts.json`.
### Connect to Claude Desktop
Add to `claude_desktop_config.json`:
```json
{
"mcpServers": {
"ssh-for-agents": {
"command": "C:\\path\\to\\.venv\\Scripts\\python.exe",
"args": ["-m", "ssh_agent_mcp"],
"env": {
"SSH_AGENT_CONFIG": "C:\\path\\to\\hosts.json",
"MYSERVER_SSH_PASSPHRASE": "..."
}
}
}
}
```
## Use it from a local Ollama model
Ollama runs models and does function calling, but it is **not** an MCP client — so a
small host/bridge sits between them: it launches this server, hands the model the
tools, runs the tool-calling loop, and routes tool calls back. A working one is
included at [`examples/ollama_bridge.py`](examples/ollama_bridge.py):
```bash
pip install -e ".[examples]" # adds the `ollama` client library
ollama pull qwen3-coder:30b # any tool-calling model works
python examples/ollama_bridge.py "what is the uptime and disk usage on linode?"
```
The model discovers hosts via `list_hosts` and runs commands via `run_command`. When
the policy returns `needs_confirmation`, the bridge prompts **you** on the terminal
rather than letting the model authorize its own destructive command. Pick a model
with `OLLAMA_MODEL=...`; it must support tool calling (qwen3-coder, llama3.1/3.2,
mistral-nemo, …).
```
Ollama model ◀──function calling──▶ bridge ◀──MCP(stdio)──▶ ssh-for-agents ──▶ host
```
## Safety model
The policy runs **before** any command reaches the server. Pick a `mode`:
| Mode | Behavior |
| -------------- | ------------------------------------------------------------------------------- |
| `readonly` | Only commands on a read-only allowlist run; everything else is blocked. |
| `guarded` | **(default)** Most commands run freely. Destructive/state-changing commands return `needs_confirmation` until the agent re-calls with `confirm=true`. A few catastrophic patterns are hard-blocked. |
| `unrestricted` | Anything runs. Only sensible for disposable/sandboxed hosts. |
In `guarded` mode:
- **Needs confirmation:** `rm`, `dd`, `mkfs`, `shutdown`/`reboot`, `kill`, `mount`,
firewall edits, `crontab`, package installs/removals (`apt install`, …), service
changes (`systemctl stop`, …), `git push`/`reset`, `curl … | sh`, recursive
`chmod`/`chown`, redirects into system paths, and more.
- **Hard-blocked (cannot be confirmed away):** fork bombs, recursive deletes of root
paths (`rm -rf /`, `/home`, …), and writing/formatting raw disk devices
(`dd of=/dev/sda`, `mkfs … /dev/nvme0n1`).
- Commands are parsed into segments across `;`, `&&`, `||`, and pipes, and `sudo`/`env`
wrappers are seen through, so `cd /x && sudo rm y` is still flagged.
Extend it without editing code via `extra_denylist` (more binaries that need
confirmation) and `extra_readonly_allow` (more binaries allowed in `readonly` mode).
> **Important — this is a safety net, not a sandbox.** String-based inspection of
> shell commands can always be evaded by a determined caller (`base64 … | sh`, exotic
> quoting, writing then running a script, etc.). It exists to prevent *accidents* and
> obvious mistakes. For a real trust boundary, connect as a **dedicated unprivileged
> user** and constrain it with OS permissions and/or sshd `ForceCommand`. Treat the
> mode as defense-in-depth, not as containment of an adversarial agent.
## Develop & test
```bash
pip install -e ".[dev]"
pytest # guardrail policy tests (no SSH needed)
```
## Project layout
```
ssh_agent_mcp/
guardrails.py # command-safety policy engine (pure, well-tested)
config.py # hosts.json loading + validation
ssh_client.py # asyncssh connection pool (run / read_file / list_dir)
server.py # FastMCP server + tools (with hosts.json hot-reload)
manage.py # `ssh-for-agents-config` CLI: add / list / remove / import hosts
run_cli.py # `ssh-for-agents-run` CLI: run SSH from the shell (Codex/local models)
tests/
test_guardrails.py
test_manage.py
test_run_cli.py
examples/
ollama_bridge.py # drive a local Ollama model with these tools
hosts.example.json
SETUP.md # step-by-step reproduction guide
```
TDQS
Scored across 5 tools
Each tool has a clearly distinct purpose: check_command is for policy dry-run, list_dir for directory listing, list_hosts for host discovery, read_file for file reading, and run_command for command execution. There is no overlap.
All tool names follow a consistent verb_noun pattern (e.g., check_command, list_dir) using snake_case. No mixing of conventions.
With 5 tools, the server is well-scoped for its purpose of secure SSH command execution with file reading and directory listing. Each tool serves a necessary role without redundancy.
The tools cover the core workflow of host discovery, command safety checking, command execution, and file reading. However, there is a notable gap: no tool for writing or uploading files, which may limit some use cases.