Skip to main content
Glama
WilliamSmithEdward

ssh-for-agents

README.md
# 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

A4.1/5.0

Scored across 5 tools

Disambiguation5/5

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.

Naming Consistency5/5

All tool names follow a consistent verb_noun pattern (e.g., check_command, list_dir) using snake_case. No mixing of conventions.

Tool Count5/5

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.

Completeness4/5

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.

Maintenance

ActivityInactive
ResponsivenessNo issues