Recently Written · git

sshp-ai

Agent skill + read-only MCP server for parallel SSH fleet checks with bahamas10/sshp

git clone https://github.com/equwal/sshp-ai

Log | Files | Refs


commit 8be7117977c80aa703b1f17412fc57756b62980e
equwal <13551856+equwal@users.noreply.github.com>
2026-10-03 21:53:13 -0700

Reframe as a parallel SSH runner; document allow_write as the general-purpose switch

Add a test that a write-style command is refused by default and allowed with allow_write.

 README.md            |  8 ++++----
 skills/sshp/SKILL.md | 12 ++++++------
 sshp_mcp.py          |  7 ++++---
 test_sshp_mcp.py     |  5 +++++
 4 files changed, 19 insertions(+), 13 deletions(-)
diff --git a/README.md b/README.md
index b21a1e7..0bfbaa1 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,10 @@
 # sshp-ai
 
-Let AI coding agents check a whole fleet of servers in one call, safely.
+A parallel SSH runner for AI coding agents: run one command on many hosts at once and get per-host output and exit codes.
 
 sshp-ai wraps [sshp](https://github.com/bahamas10/sshp) by Dave Eddy (bahamas10), a fast parallel SSH executor written in C. It adds two things:
 
-- **`skills/sshp/SKILL.md`**: an agent skill that teaches when and how to use sshp: read-only checks first, `-n` dry runs before writes, `BatchMode` and timeouts so nothing hangs, output grouping, and per-host exit codes. It works in the skills folders of Claude Code, Codex, Copilot CLI and Antigravity.
+- **`skills/sshp/SKILL.md`**: an agent skill that teaches when and how to use sshp: `-n` dry runs before risky commands, `BatchMode` and timeouts so nothing hangs, output grouping, and per-host exit codes. It works in the skills folders of Claude Code, Codex, Copilot CLI and Antigravity.
 - **`sshp_mcp.py`**: a stdio MCP server (Python 3 stdlib only) with two tools:
   - `sshp_run(command, hosts?, max_jobs?, timeout?, user?, dry_run?)` returns JSON with each host's output and exit code.
   - `sshp_hosts()` lists the allowlisted hosts and whether writes are enabled.
@@ -16,8 +16,8 @@ sshp-ai does not include sshp's code. Install sshp yourself.
 The operator sets the limits in a config file. The model cannot change them.
 
 - **Host allowlist.** Only hosts in the hosts file can be targeted.
-- **Read-only by default.** A command must start with an allowed verb (`uptime`, `df`, `systemctl status`, `journalctl`, `docker ps`, ...) and must not contain shell metacharacters (`; | & > < $ \` ( )`). Write-like forms such as `find -delete`, `systemctl restart` or `date -s` are refused.
-- **Writes need `"allow_write": true`** in the config. Only the operator can set it.
+- **Conservative default.** Out of the box, a command must start with an allowed verb (`uptime`, `df`, `systemctl status`, `journalctl`, `docker ps`, ...) and must not contain shell metacharacters (`; | & > < $ \` ( )`). Write-like forms such as `find -delete`, `systemctl restart` or `date -s` are refused.
+- **`"allow_write": true` makes it a general-purpose runner.** Set it in the config to run any command (restarts, installs, edits). Only the operator can set it. The host allowlist still applies.
 - ssh runs with `BatchMode=yes` and `ConnectTimeout=10`, so a prompt fails fast. The whole run has a timeout (default 60 s, max 600 s).
 
 ## Install
diff --git a/skills/sshp/SKILL.md b/skills/sshp/SKILL.md
index 4dcdef0..4076d31 100644
--- a/skills/sshp/SKILL.md
+++ b/skills/sshp/SKILL.md
@@ -1,18 +1,18 @@
 ---
 name: sshp
-description: Run one command on many SSH hosts in parallel with sshp, and read per-host output and exit codes. Use for fleet-wide checks such as "is the service up on every server", "disk space on all hosts", "which hosts run kernel X", or when the user names several hosts or a hosts file. Read-only checks first; dry run before any write.
+description: Run one command on many SSH hosts in parallel with sshp, and read per-host output and exit codes. Use to run a command on several servers, for example "is the service up on every server", "disk space on all hosts", "which hosts run kernel X", or when the user names several hosts or a hosts file. Use -n for a dry run before risky commands.
 ---
 
-# sshp: parallel SSH for fleet checks
+# sshp: parallel SSH runner
 
 `sshp` (https://github.com/bahamas10/sshp) runs one command on many hosts at once and prefixes each output line with `[host]`. Prefer it over a shell loop of `ssh` calls.
 
-If the `sshp_run` MCP tool is available, use it: it enforces the host allowlist and read-only mode, and returns JSON per host. Otherwise call the `sshp` binary as below.
+If the `sshp_run` MCP tool is available, use it: it enforces the host allowlist and returns JSON per host. If the operator left `allow_write` off, it refuses commands outside a read-only verb list. Otherwise call the `sshp` binary as below.
 
 ## Rules
 
-1. **Read-only first.** Start with commands that change nothing: `uptime`, `df -h`, `systemctl is-active X`, `journalctl -u X -n 50`, `cat /etc/os-release`.
-2. **Dry run before a write.** Run with `-n` to list what would run. Show the user the host list and command. Get an explicit yes before any command that changes state (restart, install, edit, delete, reboot).
+1. **Probe first.** Commands that change nothing are a good start: `uptime`, `df -h`, `systemctl is-active X`, `journalctl -u X -n 50`, `cat /etc/os-release`.
+2. **Dry run risky commands.** Run with `-n` to list what would run. For a destructive command (delete, reboot, mass restart), show the user the host list and command and get a yes first.
 3. **Never let it hang.** Always pass `-o BatchMode=yes -o ConnectTimeout=10`. A password prompt or host-key prompt then fails fast instead of blocking.
 4. **Limit concurrency for heavy work.** Default `-m 50`. Use `-m 1` for rolling changes (one host at a time), `-m 5` for anything that loads the hosts.
 5. **Read exit codes per host.** Always pass `-e`. sshp prints `[host] exited: N (T ms)`. Report the hosts that failed, not only the overall status. sshp itself exits non-zero if any host failed.
@@ -21,7 +21,7 @@ If the `sshp_run` MCP tool is available, use it: it enforces the host allowlist
 ## Commands
 
 ```sh
-# Check: one line per host, exit codes, no prompts
+# Run: one line per host, exit codes, no prompts
 sshp -e -c off -o BatchMode=yes -o ConnectTimeout=10 -f hosts uptime
 
 # Group output by host (multi-line output stays together)
diff --git a/sshp_mcp.py b/sshp_mcp.py
index 084124d..a179148 100644
--- a/sshp_mcp.py
+++ b/sshp_mcp.py
@@ -3,7 +3,8 @@
 
 Safety comes from the server config, never from the model:
 - Only hosts listed in the hosts file can be targeted.
-- Read-only mode is the default. A command must start with an allowed verb
+- Out of the box only read-only commands run; set "allow_write": true for a
+  general-purpose runner. A command must start with an allowed verb
   and must not contain shell metacharacters, unless the config sets
   "allow_write": true.
 
@@ -160,8 +161,8 @@ TOOLS = [
         "name": "sshp_run",
         "description": "Run one command on many SSH hosts in parallel with sshp. "
                        "Returns per-host output (stdout+stderr merged) and exit code. "
-                       "Hosts must be in the server allowlist. Read-only commands only "
-                       "unless the operator enabled writes in the server config.",
+                       "Hosts must be in the server allowlist. Without allow_write in the "
+                       "server config, only read-only commands are accepted.",
         "inputSchema": {
             "type": "object",
             "properties": {
diff --git a/test_sshp_mcp.py b/test_sshp_mcp.py
index 8aaf793..e35481a 100644
--- a/test_sshp_mcp.py
+++ b/test_sshp_mcp.py
@@ -56,6 +56,11 @@ class PolicyTest(unittest.TestCase):
     def test_allow_write_comes_from_config(self):
         self.assertIsNone(sshp_mcp.check_command("systemctl restart nginx", cfg("x", allow_write=True)))
 
+    def test_write_command_refused_then_runs(self):
+        cmd = "touch /tmp/x && rm /tmp/x"
+        self.assertIsNotNone(sshp_mcp.check_command(cmd, cfg("x")))
+        self.assertIsNone(sshp_mcp.check_command(cmd, cfg("x", allow_write=True)))
+
     def test_host_allowlist(self):
         with tempfile.NamedTemporaryFile("w", delete=False) as f:
             f.write("# comment\na.example.com\n\nb.example.com\n")