Update saiop-mcp-servers skill: docker-mcp widened to all tools (operator-approved), proxmox-mcp added (custom server + sshd ForceCommand hardening)

This commit is contained in:
Claude Code
2026-06-25 09:05:33 +00:00
parent e5c9bc6613
commit 9be001c4fc
@@ -1,16 +1,16 @@
--- ---
name: saiop-mcp-servers name: saiop-mcp-servers
description: "MCP server setup for SAIOP (git, postgres read-only, docker read-only) — least-privilege patterns and what was deliberately NOT wired up." description: "MCP server setup for SAIOP (git, postgres read-only, docker, proxmox snapshot access) — least-privilege patterns, and what was operator-approved to widen beyond the initial conservative defaults."
version: 1.0.0 version: 2.0.0
author: Claude Code (SAIOP ops session) author: Claude Code (SAIOP ops session)
license: MIT license: MIT
platforms: [linux] platforms: [linux]
prerequisites: prerequisites:
env_vars: [MCP_POSTGRES_READONLY_PASSWORD] env_vars: [MCP_POSTGRES_READONLY_PASSWORD]
commands: [uvx] commands: [uvx, uv, ssh]
metadata: metadata:
hermes: hermes:
tags: [SAIOP, MCP, security, least-privilege, postgres, docker, git] tags: [SAIOP, MCP, security, least-privilege, postgres, docker, git, proxmox, ssh]
--- ---
# MCP servers on SAIOP (Phase 6.3) # MCP servers on SAIOP (Phase 6.3)
@@ -32,27 +32,68 @@ Adding or auditing MCP tool access for Hermes (Deployment Guide §6.3).
blocks non-SELECT statements at the app layer too. Connection string goes blocks non-SELECT statements at the app layer too. Connection string goes
through the `DATABASE_URI` env var (checked the package source — through the `DATABASE_URI` env var (checked the package source —
`os.environ.get("DATABASE_URI", args.database_url)`), not a CLI arg, `os.environ.get("DATABASE_URI", args.database_url)`), not a CLI arg,
since CLI args are visible to any local user via `ps aux`. since CLI args are visible to any local user via `ps aux`. `postgresql`'s
- **docker-mcp** (`uvx docker-mcp`) — **the npm/pip package itself has no port is published to `127.0.0.1:5432` only (loopback) since Hermes runs
read-only mode.** It exposes `create-container` and `deploy-compose` on the host, not in Docker, and can't resolve container names.
(full write/deploy) alongside `get-logs`/`list-containers`. Restricted via - **docker-mcp** (`uvx docker-mcp`) — **operator-approved widening:**
Hermes's own `tools: {include: [...]}` allow-list in `config.yaml` to originally restricted to `get-logs`/`list-containers` only (the package
just the two read-only tools — same enforcement mechanism already has no native read-only mode; `create-container`/`deploy-compose` are
field-verified for the Telegram platform-toolset restriction (a real full write/deploy). After an explicit flag-and-confirm round, the
destructive command was confirmed blocked at the tool layer, not just operator chose to enable all 4 tools. This is a real, deliberate widening
configured). **Do not change this to "all tools" without a deliberate of what Hermes can do to the host via MCP — if revisiting this decision,
decision** — full Docker access would directly undermine the project's remember the restriction mechanism is still available and field-proven
core containment design (the `hermes` Linux user is deliberately denied (`tools: {include: [...]}` in `config.yaml`, same mechanism verified for
`docker compose`/restart-docker capability via scoped sudoers; an the Telegram platform-toolset restriction) if you want to dial it back.
unrestricted docker-mcp would hand that back through a side door). - **proxmox-mcp** — a **custom-written, single-tool MCP server**
(`/opt/ai-stack/hermes/scripts/proxmox_mcp.py`, run via
`uv run --with mcp python3 ...`), not an off-the-shelf package. Exposes
exactly one tool, `proxmox_qm(command)`, which SSHes to the Proxmox host
as the existing scoped `claude` account using a **dedicated keypair**
generated specifically for Hermes (not reusing the operator's desktop
key — separate blast radius per principal, same pattern as every other
dedicated credential in this project).
- **All three off-the-shelf SSH MCP packages checked (`mcp-server-ssh`,
`ssh-mcp-server`, `ssh-mcp`) were rejected before writing a custom one:**
`mcp-server-ssh` exposes a sprawling toolkit (`execute_command`,
`file_operations`, `process_manager`, `sftp_upload`/`download`,
`system_monitor`) — far more surface than needed. `ssh-mcp` (the
`mcpssh` package) was the narrowest (2 tools) but depends on an
external "Passman" credential-management service to resolve
connection details — an unwanted new dependency just to satisfy one
fixed host/user/key. `ssh-mcp-server` didn't expose a usable interface
for this case either. Writing ~60 lines directly against the `mcp`
Python SDK (`FastMCP`) was less work than adapting any of them, and
means the *only* security-relevant logic in the whole path is the
server-side sshd restriction (see below) — the MCP server itself is a
deliberately thin, unopinionated pipe.
- **The real enforcement is server-side, not in the MCP tool.** An sshd
`ForceCommand` drop-in on the Proxmox host (`Match User claude` in
`/etc/ssh/sshd_config.d/claude-restricted.conf`) routes every SSH
session for that account through a wrapper script
(`/usr/local/bin/claude-ssh-wrapper.sh`) that only permits
`qm snapshot/listsnapshot/status` patterns — regardless of what the
SSH client (the MCP tool, or anything else) requests. Verified directly:
allowed commands work, arbitrary commands (`whoami`, `cat /etc/passwd`)
are rejected, and a command-injection attempt via the snapshot-name
argument (`qm snapshot 141 test; whoami`) is rejected too (the wrapper
splits on whitespace and validates each token, not just regex-matching
the whole string). `AllowAgentForwarding`/`AllowTcpForwarding`/
`PermitTTY` are all disabled in the same `Match` block.
- **Wrapper-script gotcha hit while building this:** a long `elif [[ ... =~
... ]]` line got an actual newline inserted into it during copy/paste
into `nano`, breaking the regex syntax (manifested as a confusing
`unexpected argument 'newline' to conditional binary operator` error —
not obviously a paste problem from the error text alone). Rewritten to
avoid any long lines at all (`read -ra parts <<< "$cmd"` + per-token
validation instead of one big regex) specifically so there's nothing
left for a wrap/paste to corrupt.
- I don't have write access to `/etc/ssh` or reload `sshd` myself — only
the scoped `claude` account, which can't escalate to root. The operator
applied the sshd-side changes directly; I verified them afterward over
SSH from the VM side.
## What was deliberately NOT wired up ## What was deliberately NOT wired up
- **SSH MCP** — no second system exists in this environment that needs SSH
access beyond the VM Hermes already runs on. The one genuinely sensitive
SSH target (the Proxmox hypervisor) is the project's hardest boundary —
don't wire SSH MCP access to it without the operator directly involved in
scoping exactly what it can reach.
- **pfSense/MikroTik MCP** — no real device credentials exist in this - **pfSense/MikroTik MCP** — no real device credentials exist in this
environment to configure them safely. Needs the operator to provide environment to configure them safely. Needs the operator to provide
scoped, read-only-by-default credentials first. scoped, read-only-by-default credentials first.
@@ -68,3 +109,12 @@ hermes mcp test <name> # verify a server connects and discover its tool
non-interactive path — for a scripted/repeatable setup, hand-edit non-interactive path — for a scripted/repeatable setup, hand-edit
`mcp_servers:` in `config.yaml` directly instead (schema: `mcp_servers:` in `config.yaml` directly instead (schema:
`{command, args, env, enabled, tools: {include: [...]}}`). `{command, args, env, enabled, tools: {include: [...]}}`).
## If revisiting the Docker or Proxmox scope later
- To re-restrict docker-mcp: add back
`tools: {include: ["get-logs", "list-containers"]}` under its entry.
- To extend proxmox-mcp's reach: the *only* place that matters is the sshd
wrapper on the Proxmox host, not this MCP server or Hermes's config — the
MCP tool will happily send any command, the wrapper decides what actually
runs.