From 1f25cdcd19438ec8becef45d35c41457c5f78477 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Sat, 5 Sep 2026 07:45:21 -0700 Subject: [PATCH] feat(tooling): register the remote-ssh MCP server and split the SSH guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit elway is a convergent deploy runner and it is good at that: 64 playbooks, sudo handled once, upload, and three tiers of idempotency. It is the wrong shape for "what is running over there", which is why this session answered every reconnaissance question with a raw `ssh host '...'` despite CLAUDE.md saying to use elway — a playbook for a read-only `docker ps` is ceremony, and each ad-hoc command is a fresh handshake and a fresh permission decision. The MCP server fills that half. It holds a persistent shell, so cwd and exported variables survive between calls and a command costs ~6 ms rather than a full connect. Measured against nh3-extdev and irv-ml1: state persisted across calls on both, including the host reached over WireGuard. CLAUDE.md now routes by intent — changing versus looking — rather than naming one tool, because the previous single-tool instruction was being ignored in practice for exactly the cases it did not fit. Registered project-scoped rather than user-scoped: this workspace is where SSH infra work happens, and a server that can open shells on the fleet should be opted into per project rather than inherited everywhere. SSH_MCP_STRICT_ALLOWLIST=1 is set in .mcp.json deliberately and documented as load-bearing. Without it the allowlist is every Host in ~/.ssh/config — 18 aliases including both backup servers, database VMs and a tenant-managed host. Verified: corviduo-dev, present in ssh_config but not in our allowlist, is refused with host_not_allowed. --- .mcp.json | 11 +++++++++++ CLAUDE.md | 28 ++++++++++++++++++++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 .mcp.json diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..a2a2b88 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "remote-ssh": { + "command": "node", + "args": ["/home/lkraven/development/remote-ssh-mcp/dist/index.js"], + "env": { + "SSH_MCP_STRICT_ALLOWLIST": "1" + } + } + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 2ce838d..6882c75 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,14 +21,38 @@ lags the code defeats its own purpose. **New session starting here?** Read [`docs/orientation.md`](docs/orientation.md) first — fleet topology, backup architecture, governing principles, and all the NFS/DSM/naming gotchas that have cost past sessions time. -**For SSH-driven work: use `scripts/elway`.** Write a playbook under +**SSH work splits two ways. Pick by whether you are CHANGING something or +LOOKING at something — neither tool replaces the other.** + +**Changing things → `scripts/elway`.** Write a playbook under `playbooks/.yaml` and run `scripts/elway --playbook ...` instead of chaining `ssh -t host 'sudo …'` commands — handles sudo once lazily, structured pass/change/fail reporting, idempotency via -`creates:` / `when:` / `changed_when:`. Template: +`creates:` / `when:` / `changed_when:`, and file upload. Template: [`playbooks/elway-smoke.yaml`](playbooks/elway-smoke.yaml). +**Looking at things → the `remote-ssh` MCP server.** Registered project-scoped in +[`.mcp.json`](.mcp.json); tools `ssh_hosts` / `ssh_open` / `ssh_run` / `ssh_peek` / +`ssh_interrupt` / `ssh_list` / `ssh_close`. It holds a **persistent shell**, so +`cd` and exported variables survive between calls and each command costs ~6 ms +instead of a fresh handshake. Reach for it for reconnaissance — the +`docker ps` / `grep` / `curl` questions where authoring a playbook is ceremony. +It deliberately has **no file transfer and no idempotency**; that is elway's half. + +- Source: `~/development/remote-ssh-mcp` — our fork of + [the-nine-nation/remote-ssh-mcp](https://gitea.phasefinal.com/vh/remote-ssh-mcp) + (MIT). `.mcp.json` points at the **built** `dist/`, so after editing the fork + run `npm run build` or the server keeps serving the old code. +- ⚠ **The host allowlist is the only real containment boundary.** The denylist + is three regexes and a `bash -c` walks past it. We run with + `SSH_MCP_STRICT_ALLOWLIST=1`, which makes `~/.config/remote-ssh-mcp/config.json`'s + `allowedHosts` **authoritative** rather than additive — without it, every one of + the 18 `Host` entries in `~/.ssh/config` is reachable. Widening that list is a + deliberate act; do it in the config file, not by relying on discovery. +- Hosts not in the allowlist return `host_not_allowed`. Hosts reachable only by + raw IP (no `Host` alias) are not reachable at all — add an alias first. + **Task visibility via task-board.** If the Claude Code session has the `task-board` plugin enabled (installed from `git@gitea.phasefinal.com:vh/task-board.git`), a card at