ssh-fleet-mcp-server 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Jordan Burke
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,594 @@
1
- # Temporary Holding Version
1
+ # ssh-fleet-mcp-server
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![License](https://img.shields.io/github/license/jordanburke/ssh-client-mcp-server)](../../LICENSE)
4
+ [![NPM Version](https://img.shields.io/npm/v/ssh-fleet-mcp-server)](https://www.npmjs.com/package/ssh-fleet-mcp-server)
5
+
6
+ **ssh-fleet-mcp-server** is a Model Context Protocol (MCP) server that manages a fleet of SSH hosts behind a pooled connection layer. LLMs and MCP clients get a single server that can route `exec` and `tmux_*` commands to any configured host by name — no separate server process per host required.
7
+
8
+ Built on [`somamcp`](https://github.com/sapientsai/SomaMCP) and the shared `@ssh-mcp/core` package (pool, tmux builders, auth resolution).
9
+
10
+ ## Contents
11
+
12
+ - [Quick Start](#quick-start)
13
+ - [First Run](#first-run)
14
+ - [Config Discovery](#config-discovery)
15
+ - [Fleet Config Schema](#fleet-config-schema)
16
+ - [Auth Modes](#auth-modes)
17
+ - [Secrets-by-Reference Rule](#secrets-by-reference-rule)
18
+ - [Tools](#tools)
19
+ - [Policy](#policy)
20
+ - [Audit Log](#audit-log)
21
+ - [CA Mode](#ca-mode)
22
+ - [Client Setup](#client-setup)
23
+ - [Running on Several Machines](#running-on-several-machines)
24
+ - [Command Line](#command-line)
25
+ - [Development](#development)
26
+
27
+ ## Quick Start
28
+
29
+ 1. Add the server to your MCP client with no `--config` — see [Client Setup](#client-setup).
30
+ 2. Ask your LLM to call `fleet_setup`. On first start the server wrote a commented starter config; `fleet_setup` says where.
31
+ 3. Uncomment an example host in that file, restart the server, and ask your LLM to list hosts or run commands.
32
+
33
+ ## First Run
34
+
35
+ When no `--config` flag or `SSH_FLEET_CONFIG` is given and `~/.config/ssh-fleet/fleet.toml` doesn't exist, the server writes a starter config there (mode `0600`, folder `0700`) and starts with zero hosts. The file is all comments, with example blocks for a password host, a key host, a read-only policy and a CA; remove the leading `# ` from a block's lines to use it. Edits take effect on restart (in Claude Code, `/mcp` and reconnect).
36
+
37
+ - **Only the default path is created.** A `--config` or `SSH_FLEET_CONFIG` path that doesn't exist fails startup, so a typo can't give you a healthy-looking server with no hosts.
38
+ - **`fleet_setup` says what's missing:** an unset `password_env` variable, a host's last connection error, or a new CA's line to add on hosts.
39
+ - **A misconfigured host doesn't stop the server.** A `key` file that can't be read, an unset `key_env`, or `agent = true` without `SSH_AUTH_SOCK` (Claude Desktop doesn't pass it) fails that host's calls with the reason, and `fleet_setup` shows it from the first start.
40
+ - **A CA `key_file` that doesn't exist is created** (`ssh-keygen`, ed25519, `0600`). This keeps the key on disk; use `key_cmd` or `key_env` to keep it off disk instead. A typo in `key_file` creates a new CA that no host trusts, so check `fleet_setup` after any CA change.
41
+ - **One client per config in CA mode.** A second server on the same config fails on the `runtime_dir` lock before it can explain itself, and Claude Desktop hides stderr. Give each client its own config with its own `runtime_dir`.
42
+ - **Don't list the hub machine as a fleet host when its CA is a `key_file`.** An agent with `exec` there could read the key or use the server's private agent and sign its own certificates.
43
+ - **Two clients starting at once** can race to create the same CA key; the loser fails to load it, and a restart fixes it.
44
+
45
+ ## Config Discovery
46
+
47
+ The server looks for its fleet configuration in this order:
48
+
49
+ 1. `--config=<path>` CLI flag — explicit path wins.
50
+ 2. `SSH_FLEET_CONFIG` environment variable — useful in Docker / systemd.
51
+ 3. Default location: `~/.config/ssh-fleet/fleet.toml`.
52
+
53
+ If the default config is missing, the server creates it (see [First Run](#first-run)). If any other config file cannot be read, or a config contains validation errors, the server logs the problem and exits immediately (fail-fast). No host is connected until an MCP tool call is received (lazy connect via the pool).
54
+
55
+ ## Fleet Config Schema
56
+
57
+ The fleet config is a [TOML](https://toml.io) file with four top-level sections.
58
+
59
+ ### `[defaults]` (optional)
60
+
61
+ Default values applied to every host that does not override them.
62
+
63
+ | Key | Type | Default | Description |
64
+ | -------------- | ------ | --------- | --------------------------------------------------- |
65
+ | `port` | int | `22` | SSH port. |
66
+ | `user` | string | OS user | SSH username. Falls back to the OS user if omitted. |
67
+ | `tmux_session` | string | `"agent"` | Default tmux session name for `tmux_*` tools. |
68
+
69
+ ### `[pool]` (optional)
70
+
71
+ Controls the shared per-host connection pool. Each key below that is set must be a whole number from 1 to 2147483647, or startup fails; for the `_ms` keys that limit is about 24.8 days. There is no value that turns a timeout off.
72
+
73
+ | Key | Type | Default | Description |
74
+ | --------------------- | ---- | --------- | --------------------------------------------------------------------------------------------------- |
75
+ | `max_per_host` | int | `2` | Maximum concurrent SSH connections per host. |
76
+ | `idle_timeout_ms` | int | `60000` | Idle connection eviction interval (ms). |
77
+ | `acquire_timeout_ms` | int | `30000` | How long to wait for a pool slot before failing a tool call (ms). |
78
+ | `exec_timeout_ms` | int | `600000` | How long `exec` and the `tmux_*` tools wait for a command when the call gives no `timeout_ms` (ms). |
79
+ | `max_exec_timeout_ms` | int | `3600000` | The most an `exec` call's `timeout_ms` may ask for; a larger value is lowered to it (ms). |
80
+
81
+ ### `[audit]` (optional)
82
+
83
+ Where the audit log is written. See [Audit Log](#audit-log).
84
+
85
+ | Key | Type | Default | Description |
86
+ | ------ | ------ | -------------------------------------- | ------------------------------------------------------------------- |
87
+ | `path` | string | `~/.local/state/ssh-fleet/audit.jsonl` | Audit log file (supports `~` and `$VAR`). Created with mode `0600`. |
88
+
89
+ ### `[defaults.policy]` / `[hosts.policy]` (optional)
90
+
91
+ Per-host rules checked before every tool call. See [Policy](#policy). A key in a host's `[hosts.policy]` replaces the same key from `[defaults.policy]`; lists are replaced, not merged.
92
+
93
+ | Key | Type | Built-in default | Description |
94
+ | ---------------- | -------- | ---------------- | ----------------------------------------------------------------------------------------- |
95
+ | `mode` | string | `"readwrite"` | `"readonly"`, `"readwrite"` or `"deny"`. |
96
+ | `tmux` | string | `"open"` | `"approve"` or `"open"`; gates `tmux_send` / `tmux_keys` on a `readwrite` host. |
97
+ | `allow` | string[] | — | `readonly` only: regexes a whole `exec` command must match. |
98
+ | `needs_approval` | string[] | — | `readwrite` only: regexes that, matching anywhere in an `exec` command, require approval. |
99
+ | `agents` | string[] | — | Agents allowed on this host. `claude` matches `claude` and every `claude@<instance>`. |
100
+
101
+ An empty list means "not set": `allow = []` is how a host drops an `allow` list it would otherwise inherit. With no policy anywhere, every call is allowed, as before policy existed.
102
+
103
+ ### `[[hosts]]` (zero or more)
104
+
105
+ With none, the server starts and `fleet_setup` says what to add.
106
+
107
+ Each `[[hosts]]` entry defines a remote machine. Host names must be unique and may contain only letters, digits, hyphens, and underscores.
108
+
109
+ | Key | Type | Required | Description |
110
+ | -------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------ |
111
+ | `name` | string | yes | Logical name used as the `host` param in tool calls. |
112
+ | `host` | string | yes | Hostname or IP address. |
113
+ | `port` | int | no | Overrides `[defaults].port`. |
114
+ | `user` | string | no | Overrides `[defaults].user`. |
115
+ | `tmux_session` | string | no | Overrides `[defaults].tmux_session`. |
116
+ | `log_output` | bool | no | Add a 1024-character excerpt of `exec` output to the audit log. Off by default because output can contain secrets. |
117
+ | `key` | string | no\* | Path to private SSH key (supports `~` and `$VAR`). |
118
+ | `key_env` | string | no\* | Name of env var holding the private key PEM. |
119
+ | `agent` | bool | no\* | `true` to use `SSH_AUTH_SOCK` (ssh-agent / 1Password / Bitwarden). |
120
+ | `password_env` | string | no\* | Name of env var holding the SSH password. |
121
+
122
+ \*Exactly one auth field is required per host. See [Auth Modes](#auth-modes).
123
+
124
+ ## Auth Modes
125
+
126
+ Each host must specify exactly one authentication method:
127
+
128
+ | Mode | Field | How it works |
129
+ | ------------ | -------------- | ---------------------------------------------------------------------------------------- |
130
+ | Key file | `key` | Path to a PEM private key; `~` and `$VAR` are expanded at startup. |
131
+ | Key from env | `key_env` | Name of an env var holding the PEM (injected by envpkt, Vault, Doppler). |
132
+ | SSH agent | `agent = true` | Delegates to `SSH_AUTH_SOCK` — the server never sees the private key. |
133
+ | Password env | `password_env` | Name of an env var holding the password; read once at startup. |
134
+ | CA | `ca` | Names a `[[cas]]` entry; connects with a 10-minute certificate. See [CA Mode](#ca-mode). |
135
+
136
+ ## Secrets-by-Reference Rule
137
+
138
+ **Inline `password` and `private_key` fields are rejected at parse time.** The parser will refuse to start if you write:
139
+
140
+ ```toml
141
+ # WRONG — fails validation
142
+ [[hosts]]
143
+ name = "bad"
144
+ host = "example.com"
145
+ password = "hunter2"
146
+ ```
147
+
148
+ Use `password_env` pointing at an environment variable instead. This keeps secrets out of the config file and out of source control.
149
+
150
+ ## Tools
151
+
152
+ All tools except `hosts_list` and `fleet_setup` require a `host` parameter — the logical name of a configured fleet host (from `[[hosts]].name`). An unknown name raises an error immediately, before any SSH attempt.
153
+
154
+ A failed call is an error result the model can read, labelled with an `errorCategory` and, where they help, `suggestions`:
155
+
156
+ | `errorCategory` | When | Hint |
157
+ | --------------- | --------------------------------------------------------- | ------------------------------------------------ |
158
+ | `auth` | The host's policy denied the call. | Use what the policy allows, or ask the operator. |
159
+ | `not_found` | No host by that name. | Call `hosts_list`. |
160
+ | `timeout` | Every pooled connection to the host is busy. | Try again, or raise `[pool].max_per_host`. |
161
+ | `timeout` | `exec` ran past its timeout (message: `timed out after`). | Run it in tmux, or pass a larger `timeout_ms`. |
162
+ | `timeout` | A `tmux_*` command ran past `[pool].exec_timeout_ms`. | Try again, or raise it. |
163
+ | `execution` | The connection failed (message starts `[<host>]`). | Call `fleet_setup`. |
164
+ | `execution` | The command exited non-zero, or tmux reported an error. | None: the message carries the output. |
165
+ | `validation` | A bad tmux argument: session name, key, or empty `keys`. | None: the message names the argument. |
166
+ | `internal` | The audit log can't be written, so every call is refused. | Check `[audit].path`. |
167
+ | `internal` | A bug in the server (a `TypeError` or `RangeError`). | None: report it. |
168
+
169
+ The server also has somamcp's `info` tool. It reports the name, version and build: the short commit it was built from (with `-dirty` when the tree had uncommitted changes), the branch and the build date. Use it to tell which build a hub runs.
170
+
171
+ ### `hosts_list`
172
+
173
+ List configured fleet hosts and their live connection-pool status.
174
+
175
+ Parameters: none.
176
+
177
+ Returns a JSON array of `{ name, state, inFlight }` objects, where `inFlight` is the count of currently active connections to that host. `state` is one of:
178
+
179
+ - `"connected"`: a pooled connection is open and running a command.
180
+ - `"idle"`: a pooled connection is open and waiting.
181
+ - `"disconnected"`: no open connection. This is normal: hosts connect on their first call and close after `idle_timeout_ms`.
182
+ - `"error"`: no open connection, and the last call failed for a connection reason: refused, auth, or a busy pool's `acquire_timeout_ms`. The row adds `last_error: { message, at }`, and stays `error` until the host's next successful call.
183
+
184
+ A CA host shows one row under its own name until its first call, then one row per agent, named `<host> (<agent>)`. If a host you added to the config is missing, the server started before the edit; `fleet_setup` says so.
185
+
186
+ ### `fleet_setup`
187
+
188
+ Show setup state and what to do next. Read-only: it never writes config or keys. Audited like `hosts_list`.
189
+
190
+ Parameters: none.
191
+
192
+ Returns JSON:
193
+
194
+ - `config`: `{ path, created_this_start, changed_since_start }`. The server reads `fleet.toml` once, at start. When the file on disk differs, `config` adds `hosts_on_disk` (or `errors_on_disk`, if the new file doesn't parse), and the first next step says to reconnect the server.
195
+ - `next_steps`: what to do, in order: reconnect after a config edit (or fix the edited file's errors), add a host, set a missing `password_env` variable, add a new CA's line on hosts, and each host's last connection error.
196
+ - `hosts`: per host, `name`, `address` (`user@host:port`), `auth` (`password_env`, `key`, `key_env`, `agent` or `ca`), `connections` (the `hosts_list` rows for it), and when they apply `password_env` with `env_set` (never the value), `ca`, `principal`, `last_error` `{ message, at }`, and `enroll_line`.
197
+ - `cas`: per CA, `name`, `key_source` (`key_cmd`, `key_env` or `key_file`; a `key_cmd` string is never shown), `key_file`, `created_this_start`, `public_key` and `enroll_line` (`cert-authority,principals="…" <public key>`).
198
+ - `audit_log`, and in CA mode `runtime_dir` and `known_hosts`.
199
+
200
+ ### `exec`
201
+
202
+ Run a one-shot shell command on a fleet host over SSH.
203
+
204
+ | Parameter | Type | Required | Description |
205
+ | ------------ | ------ | -------- | ---------------------------------------------------------------------------------------------------- |
206
+ | `host` | string | yes | Configured host name. |
207
+ | `command` | string | yes | Shell command to execute. |
208
+ | `timeout_ms` | int | no | How long to wait (ms). Defaults to `[pool].exec_timeout_ms`, capped at `[pool].max_exec_timeout_ms`. |
209
+
210
+ Returns stdout. If the command also wrote to stderr, the stderr text follows under a `[stderr]` line, so warnings stay visible. A non-zero exit raises a `UserError` with the host name, exit code, and stderr, then stdout under a `[stdout]` line (either one alone when the other is empty). Exit code 0 always succeeds, even with stderr: many tools, such as `systemctl`, warn on stderr and exit 0. A command a signal ended (an OOM kill, `kill -9`) fails as 128 plus the signal number, with the name: `exit 137 (signal KILL)`; the audit result line records `signal` beside `code`. On a `ca` host, OpenSSH's `ssh` reports a signal as plain `exit 255`, with no name. If the connection drops before the command finishes, the call fails with `exit 255 (no exit status: channel closed)` and the audit line records `no_exit_status`.
211
+
212
+ When the timeout runs out, the call fails with label `timeout` and the output so far: `[<host>] timed out after 5000 ms:` then stderr and stdout as above. The server stops waiting, sends the command `KILL`, and closes the channel. OpenSSH 7.9 and later deliver the signal; an older server ignores it, and the command keeps running until it next writes output. On a `ca` host, the server kills its local `ssh` client instead. That closes the channel but sends no signal, so the command stops only when it next writes output, as on an older server. The audit result line records `timed_out_ms`. The `tmux_*` tools wait `[pool].exec_timeout_ms` too.
213
+
214
+ #### Long jobs
215
+
216
+ A package upgrade, an image pull, or a backup can outlive one call, and an upgrade of the network layer under the connection (a VPN daemon, `tailscaled`) can drop the SSH session partway. Don't hold the call open. Start the job somewhere that survives the session, then check on it:
217
+
218
+ 1. **tmux (preferred).** `tmux_send` starts the job in a session on the host, and `tmux_read` shows its progress. The tmux server outlives the SSH session, so a dropped connection doesn't stop the job, and every send and read goes through policy and the audit log. It needs tmux on the host and a `readwrite` host whose policy leaves `tmux` at its default, `"open"`: a `readonly` host refuses tmux input, and `tmux = "approve"` refuses it until approval exists.
219
+ 2. **A detached process.** On Linux, `systemd-run --user --unit=upgrade-1 <command>`, then `systemctl --user status upgrade-1` or `journalctl --user -u upgrade-1`. On macOS, `nohup <command> > /tmp/job.log 2>&1 & echo $! > /tmp/job.pid`, then `tail /tmp/job.log` and `kill -0 $(cat /tmp/job.pid)`. Start it with `exec`; check it with `exec`. Both use `>` or `&`, which a `readonly` policy rejects, so they need a `readwrite` host.
220
+
221
+ ### `tmux_list`
222
+
223
+ List tmux sessions on a fleet host. Returns a JSON array of session names, empty when no tmux server is running.
224
+
225
+ The `tmux_*` tools find tmux on the host's `PATH`, then in `/opt/homebrew/bin` and `/usr/local/bin`. That covers Homebrew on macOS, where a command sent over SSH gets only `/usr/bin:/bin:/usr/sbin:/sbin`. `exec` gets no such help: to run other Homebrew tools through it, load Homebrew's `PATH` in the host's `~/.zshenv`.
226
+
227
+ | Parameter | Type | Required | Description |
228
+ | --------- | ------ | -------- | --------------------- |
229
+ | `host` | string | yes | Configured host name. |
230
+
231
+ ### `tmux_send`
232
+
233
+ Type text into a persistent tmux session on a fleet host. Creates the session if it does not exist.
234
+
235
+ | Parameter | Type | Required | Description |
236
+ | --------- | ------- | -------- | ---------------------------------------------------------- |
237
+ | `host` | string | yes | Configured host name. |
238
+ | `session` | string | no | tmux session name (defaults to the host's `tmux_session`). |
239
+ | `input` | string | yes | Text to type. |
240
+ | `submit` | boolean | no | Press Enter after the text (default `true`). |
241
+
242
+ ### `tmux_read`
243
+
244
+ Capture the recent pane transcript of a tmux session on a fleet host.
245
+
246
+ | Parameter | Type | Required | Description |
247
+ | --------- | ------ | -------- | ---------------------------------------------------------- |
248
+ | `host` | string | yes | Configured host name. |
249
+ | `session` | string | no | tmux session name (defaults to the host's `tmux_session`). |
250
+ | `lines` | int | no | Lines of scrollback (default 200; capped at 2000). |
251
+
252
+ ### `tmux_keys`
253
+
254
+ Send control/special keys (e.g. `C-c`) to a tmux session on a fleet host.
255
+
256
+ | Parameter | Type | Required | Description |
257
+ | --------- | -------- | -------- | ---------------------------------------------------------- |
258
+ | `host` | string | yes | Configured host name. |
259
+ | `session` | string | no | tmux session name (defaults to the host's `tmux_session`). |
260
+ | `keys` | string[] | yes | tmux key names, e.g. `["C-c"]`. |
261
+
262
+ ## Policy
263
+
264
+ Each host-routed call is checked against its host's policy before it runs. `hosts_list` is never gated. The first matching rule decides:
265
+
266
+ 1. **`mode = "deny"`** — every tool on the host is denied.
267
+ 2. **`agents`** — if set and the calling agent isn't listed, the call is denied.
268
+ 3. **`tmux_list` / `tmux_read`** — allowed.
269
+ 4. **`tmux_send` / `tmux_keys`** — denied on a `readonly` host. On a `readwrite` host, `tmux = "approve"` requires approval and `tmux = "open"` allows. (`allow` and `needs_approval` can't gate tmux input: it can be split across calls.)
270
+ 5. **`exec` on a `readonly` host** — denied if the command contains a newline, carriage return, NUL or any of ``; & | $ ` ( ) < > { } \ ' " * ? [ ~``. Otherwise it must match some `allow` pattern **as a whole** (each pattern is wrapped as `^(?:…)$`, no regex flags).
271
+ 6. **`exec` on a `readwrite` host** — requires approval if any `needs_approval` pattern matches anywhere in the command; otherwise allowed.
272
+
273
+ A denied call returns `[<host>] denied by rule <rule>: <reason>` to the agent and is logged with `decision: "deny"`, `rule` and `reason` (see [Audit Log](#audit-log)).
274
+
275
+ **Read before relying on it:**
276
+
277
+ - **`readonly` is an allowlist, on purpose.** There is no deny-list mode: deny-lists over shell text are trivially bypassed (`r''m`, `$(…)`, `sh -c`). Spell out the characters a pattern allows, as in `systemctl status [\w@.-]+`. **Never use `.`, `.*` or `\S` in an `allow` pattern.**
278
+ - **Write patterns as TOML literal strings.** In a TOML double-quoted string `\w` is an invalid escape and fails startup. Use single quotes, `'systemctl status [\w@.-]+'`, or double each backslash, `"systemctl status [\\w@.-]+"`. Patterns have no regex flags, so group an emoji or other astral character before repeating it: `(?:😀)+`.
279
+ - **Avoid nested quantifiers** such as `(\w+ ?)+` in `allow` and `needs_approval`. Patterns run synchronously, and one that backtracks badly can stall the whole server, not just one host. As a backstop, a command longer than 4096 characters is denied whenever policy patterns would be matched against it (any `readonly` host, or a `readwrite` host with `needs_approval`).
280
+ - **`needs_approval` is a tripwire, not a boundary.** It catches honest mistakes; an agent trying to get around it can.
281
+ - **Approval isn't available yet,** so anything that requires approval is **denied**. `tmux = "approve"` is the recommended setting once approval ships; the built-in default is `"open"`.
282
+ - **Agent identity isn't available yet** — every agent is `"unknown"` — so a host with `agents` set denies every call. The server prints a warning naming each such host at startup.
283
+ - **Over stdio, nothing is governed.** A stdio server runs as the agent's own OS user, so the agent can bypass every rule here with its own shell and credentials. Governance needs the hub to run as a separate user that alone holds the SSH credentials.
284
+ - **The real boundary is the remote account** the host entry logs in as, and its `sudoers`.
285
+
286
+ ### Sample restricted account
287
+
288
+ On a `readonly` host, log in as a dedicated account that can only read. For example, a `fleet-ro` user with no `sudo` except this `/etc/sudoers.d/fleet-ro`:
289
+
290
+ ```
291
+ fleet-ro ALL=(root) NOPASSWD: /usr/bin/systemctl status *, /usr/bin/journalctl -u *
292
+ ```
293
+
294
+ - In sudoers, `*` matches **any** trailing arguments, so these entries allow `systemctl status` and `journalctl -u` with anything after them. Keep the list to commands that are harmless with any arguments.
295
+ - The `allow` patterns must then include the `sudo ` prefix, for example `sudo systemctl status [\w@.-]+` and `sudo journalctl -u [\w@.-]+ -n \d+`.
296
+
297
+ ## Audit Log
298
+
299
+ Every tool call that runs writes two JSON lines to the audit log (see [`[audit]`](#audit-optional)): a `decision` line **before** the call runs and a `result` line after it, sharing `id`. A call that hangs or crashes the server is still on record. A call denied by [policy](#policy) never runs, so it writes only its decision line.
300
+
301
+ ```json
302
+ {"ts":"2026-10-17T14:02:11.000Z","id":"5f0c…","event":"decision","agent":"unknown","tool":"exec","host":"box-a","command":"systemctl status cups","decision":"allow","rule":"box-a:readonly"}
303
+ {"ts":"2026-10-17T14:02:11.412Z","id":"5f0c…","event":"result","agent":"unknown","tool":"exec","host":"box-a","status":"ok","ms":412,"code":0,"stdout_bytes":1830,"stderr_bytes":0}
304
+ ```
305
+
306
+ What each tool records:
307
+
308
+ | Tool | Decision line | Result line |
309
+ | ------------ | ---------------------------- | ------------------------------------------------------------------------------------- |
310
+ | `hosts_list` | (no `host`) | — |
311
+ | `exec` | `command` | `code`, `stdout_bytes`, `stderr_bytes`; plus excerpts when the host sets `log_output` |
312
+ | `tmux_list` | — | — |
313
+ | `tmux_send` | `session`, `input`, `submit` | — |
314
+ | `tmux_read` | `session`, `lines` | `stdout_bytes` (never the transcript) |
315
+ | `tmux_keys` | `session`, `keys` | — |
316
+
317
+ - **Sizes, not contents.** Output can contain secrets, so only byte counts are logged. A host with `log_output = true` also gets `stdout_excerpt` / `stderr_excerpt` (first 1024 characters) on `exec`.
318
+ - **`status`** is `"ok"` when the call completed (an `exec` with a non-zero exit is still `"ok"`, with its `code`) and `"error"` with an `error` message when it didn't, for example an unknown host.
319
+ - **`decision`** is `"allow"` or `"deny"`. **`rule`** names the policy rule that decided, qualified by host (`box-a:readonly`); it is absent when no policy applies (`hosts_list`, an unknown host). A deny line also has a **`reason`**.
320
+ - **`agent`** is `"unknown"` until per-agent identity lands.
321
+ - **`client`** says which client made the call. It is `SSH_FLEET_CLIENT` from the server's environment if set (put it in the client's MCP config `env` block, for example `claude-code@mac-mini`), else the `name/version` the client sent in its MCP handshake, for example `claude-code/2.1.0`. Either way it has control characters removed and is at most 64 characters, and the field is left out when neither exists. It tells clients apart in the log. A client can claim any name, so it isn't a verified identity, and policy's `agents` never matches it.
322
+ - **No unaudited calls.** The server refuses to start if the log can't be written, and a call whose audit line can't be written fails.
323
+ - **The file is reopened for every line,** so a deleted or rotated log is recreated at mode `0600`. A symlink at the audit path is refused rather than followed.
324
+ - **Append-only is best effort** — the server's user can truncate its own file. For stronger integrity, set the OS append-only flag from an administrator account: `sudo chattr +a <file>` (Linux) or `sudo chflags uappend <file>` (macOS). Rotation is out of scope.
325
+
326
+ ## CA Mode
327
+
328
+ With `ca = "<name>"`, a host trusts one SSH certificate authority instead of a per-host key. The server holds the CA in a private `ssh-agent`, signs a **10-minute certificate** for each connection, and connects with the system `ssh` binary (OpenSSH: `ssh`, `ssh-keygen`, `ssh-agent`, `ssh-add`). Agents never hold an SSH credential.
329
+
330
+ ```toml
331
+ runtime_dir = "/run/ssh-fleet" # optional; sockets and ephemeral keys
332
+ known_hosts = "~/.local/state/ssh-fleet/known_hosts" # optional; the hub's own
333
+
334
+ [[cas]]
335
+ name = "home"
336
+ key_cmd = "security find-generic-password -s ssh-fleet-ca -w" # or key_env / key_file
337
+ principal = "fleet" # default
338
+ source_address = "100.64.0.1/32" # optional: certificates only work from here
339
+
340
+ [[hosts]]
341
+ name = "box-a"
342
+ host = "10.0.0.11"
343
+ user = "fleet-ro"
344
+ ca = "home"
345
+ ```
346
+
347
+ | `[[cas]]` key | Description |
348
+ | ---------------- | ----------------------------------------------------------------------------------------------------- |
349
+ | `name` | Letters, digits, hyphen, underscore. Hosts refer to it with `ca = "<name>"`. |
350
+ | `key_cmd` | A shell command whose output is the CA private key (a keychain or password-manager read). |
351
+ | `key_env` | An env var holding the CA private key; removed from the environment once read. |
352
+ | `key_file` | A key file: a regular file (not a symlink), mode `0600` or stricter, owned by the hub user. |
353
+ | `principal` | The principal certificates name (default `"fleet"`). A host may override it with its own `principal`. |
354
+ | `source_address` | An IPv4 or IPv6 CIDR; certificates are only accepted from those addresses. |
355
+
356
+ Exactly one of `key_cmd`, `key_env` or `key_file`. Inline keys are rejected.
357
+
358
+ `runtime_dir` (default `$XDG_RUNTIME_DIR/ssh-fleet`, else `~/.local/state/ssh-fleet/run`) must be a directory of its own: startup refuses one that holds files the server didn't create, and shutdown deletes only the server's own files. One server per `runtime_dir`: a second server started on the same one (say Claude Desktop and Claude Code with the same fleet config) fails startup with `runtime_dir … is in use by pid N` and leaves the first running. Give each its own config with its own `runtime_dir`.
359
+
360
+ ### Enrolling a host
361
+
362
+ Add one line to a **root-owned** authorized-keys file for the account the host entry logs in as:
363
+
364
+ ```
365
+ cert-authority,principals="fleet" ssh-ed25519 AAAA… ssh-fleet-ca
366
+ ```
367
+
368
+ Use `AuthorizedKeysFile /etc/ssh/authorized_keys/%u` in `sshd_config`, or `TrustedUserCAKeys` plus `AuthorizedPrincipalsFile`. A user-writable `~/.ssh/authorized_keys` defeats the point: any agent with write access could append its own key and bypass the hub for good. The principal is just a label the certificate must carry; what limits access is **which account's** file holds the CA line, and that account's rights.
369
+
370
+ ### The CA key at boot
371
+
372
+ The server loads the CA at startup with no interactive unlock, so it restarts unattended. Keep the canonical copy in your password manager, and give the server a working copy only it can read:
373
+
374
+ - **macOS:** `key_cmd` reading the hub user's keychain (`security find-generic-password -s ssh-fleet-ca -w`).
375
+ - **Linux:** a `0600` `key_file` owned by the hub user. If the file doesn't exist, the server creates it on first start, and `fleet_setup` shows the line to add on hosts.
376
+ - **Either:** `key_cmd` against a non-interactive secrets service (`op read …`, Bitwarden Secrets Manager, Infisical), or `key_env`.
377
+
378
+ The key reaches `ssh-add` on stdin; the server never writes it to disk except when it creates a missing `key_file`. A `key_cmd` must finish within 30 seconds and runs without a terminal, so a command that would prompt fails startup, naming the CA, instead of hanging.
379
+
380
+ ### What a certificate allows
381
+
382
+ - **10 minutes,** key ID = the agent's name, the configured principal, and an increasing serial.
383
+ - **No** port forwarding, agent forwarding, X11 forwarding or `~/.ssh/rc`; optional `source_address`.
384
+ - Each (agent, host) pair gets its own master connection and its own certificate, so one agent never rides another's session, and the host's sshd log names the agent (`Accepted certificate ID "claude"`).
385
+ - A master is retired when its certificate expires; calls in flight finish, and the next call gets a fresh certificate.
386
+ - Host keys are trusted on first use, recorded in the hub's own `known_hosts`.
387
+ - `~/.ssh/config` is **ignored** (`-F /dev/null`), so nothing in it can add an identity. `ProxyJump` isn't supported yet.
388
+ - At startup the server mints and discards one certificate per CA, so a CA it can't sign with, or a certificate option `ssh-keygen` rejects, stops startup instead of failing every call.
389
+ - A master to a host that stops answering drops within about 45 seconds (SSH keepalives).
390
+ - After a crash, the next start stops the old agent and any masters it left running, and clears stale sockets and keys from `runtime_dir`. It stops a process only if `ps` shows it is `ssh-agent` or `ssh` on a socket in that `runtime_dir`, so a reused process ID never takes down anything else.
391
+
392
+ ### Revoking access
393
+
394
+ - **Stop the server:** no new certificates; outstanding ones expire within 10 minutes and their masters are retired.
395
+ - **Cut access now:** remove the CA line from the host.
396
+ - **Separate trust domains** (fleets run by different parties): use separate `[[cas]]` entries, so each can be revoked alone.
397
+
398
+ ### Enforcement
399
+
400
+ CA mode governs access only if the server is the **only** path. Run it as its own OS user (a launchd or systemd service), so agent processes running as you can't read the CA key, the certificates or the audit log, and don't give your interactive user unrestricted `sudo`. Over stdio, as the same user as the agent, nothing is governed.
401
+
402
+ ## Example Config
403
+
404
+ ```toml
405
+ [defaults]
406
+ port = 22
407
+ user = "agent"
408
+ tmux_session = "agent"
409
+
410
+ [pool]
411
+ max_per_host = 2
412
+ idle_timeout_ms = 60000
413
+
414
+ [[hosts]]
415
+ name = "box-a"
416
+ host = "10.0.0.11"
417
+ user = "fleet-ro"
418
+ key = "~/.ssh/id_ed25519"
419
+ [hosts.policy]
420
+ mode = "readonly"
421
+ allow = ['sudo systemctl status [\w@.-]+', 'sudo journalctl -u [\w@.-]+ -n \d+', 'df -h', 'uptime']
422
+
423
+ [[hosts]]
424
+ name = "box-b"
425
+ host = "build.example.com"
426
+ agent = true
427
+
428
+ [[hosts]]
429
+ name = "box-c"
430
+ host = "192.168.1.50"
431
+ password_env = "BOXC_SSH_PASSWORD" # value read from the env var, never inline
432
+ ```
433
+
434
+ ## Client Setup
435
+
436
+ ### `.mcp.json` / `claude_desktop_config.json`
437
+
438
+ With no `--config`, the server uses `~/.config/ssh-fleet/fleet.toml` and creates it on first start (see [First Run](#first-run)). Put each host's `password_env` / `key_env` variables in the `env` block:
439
+
440
+ ```json
441
+ {
442
+ "mcpServers": {
443
+ "ssh-fleet": {
444
+ "command": "node",
445
+ "args": ["packages/orchestrator/dist/index.js"],
446
+ "env": { "MINI_SSH_PASSWORD": "…" }
447
+ }
448
+ }
449
+ }
450
+ ```
451
+
452
+ Or via `npx`, with an explicit config (which must already exist). Pin the version in the arguments, for the reason under [Installing on a hub](#installing-on-a-hub):
453
+
454
+ ```json
455
+ {
456
+ "mcpServers": {
457
+ "ssh-fleet": {
458
+ "command": "npx",
459
+ "args": ["-y", "ssh-fleet-mcp-server@<version>", "--config=/path/to/fleet.toml"]
460
+ }
461
+ }
462
+ }
463
+ ```
464
+
465
+ Set `SSH_FLEET_CONFIG` in your environment (or the MCP client's `env` block) if you prefer the env-var discovery path over an explicit `--config` flag. In CA mode, give each client its own config and `runtime_dir`.
466
+
467
+ These examples run from a clone of this repo. For an installed server on another machine, see [Running on Several Machines](#running-on-several-machines).
468
+
469
+ ## Running on Several Machines
470
+
471
+ A machine that runs this server and logs in to fleet hosts is a **hub**. Each hub keeps its own config (`~/.config/ssh-fleet/fleet.toml`), its own audit log, and its own credentials.
472
+
473
+ ### Where to register the server
474
+
475
+ Register it in the MCP config of a folder you start admin sessions from (for example `~/admin/.mcp.json`), not in your user-wide config. Only sessions started there get fleet access. Claude Code asks you to approve a folder's servers the first time; until then `claude mcp list` shows them as pending approval.
476
+
477
+ In CA mode, two Claude Code sessions started in that folder load the same config, so the second session's server fails on the `runtime_dir` lock. Start the second one from another folder, or with `claude --strict-mcp-config --mcp-config <file>`, where `<file>` starts the server with a `--config` that has its own `runtime_dir`. Without `--strict-mcp-config`, the folder's `.mcp.json` still loads and its server still collides.
478
+
479
+ Use full paths. An MCP client launched from a GUI may not have Homebrew's `PATH`, and the package's `#!/usr/bin/env node` needs `node` on it. On an Apple silicon Mac with Homebrew (`/usr/local/bin` on Intel):
480
+
481
+ ```json
482
+ {
483
+ "mcpServers": {
484
+ "ssh-fleet": {
485
+ "command": "/opt/homebrew/bin/node",
486
+ "args": ["/opt/homebrew/bin/ssh-fleet-mcp-server"]
487
+ }
488
+ }
489
+ }
490
+ ```
491
+
492
+ ### Installing on a hub
493
+
494
+ Pin a version, and update on purpose: this server holds SSH credentials, so a new version shouldn't arrive on its own at the next restart. In a terminal on the hub (not `ssh <hub> npm …`, which gets the bare `PATH` described under [macOS hosts](#macos-hosts) and won't find Homebrew's or nvm's `npm`):
495
+
496
+ ```sh
497
+ npm install -g ssh-fleet-mcp-server@<version>
498
+ ```
499
+
500
+ `npm uninstall -g ssh-fleet-mcp-server` removes it.
501
+
502
+ To try a build that isn't released yet, pack it on a machine with this repo, copy the tarball to the hub, and install that the same way:
503
+
504
+ ```sh
505
+ cd packages/orchestrator && pnpm build && pnpm pack # → ssh-fleet-mcp-server-<version>.tgz
506
+ scp ssh-fleet-mcp-server-<version>.tgz <hub>:/tmp/
507
+ npm install -g /tmp/ssh-fleet-mcp-server-<version>.tgz # on the hub
508
+ ```
509
+
510
+ ### Credentials: one key per hub and host
511
+
512
+ Start with a separate key for each hub and host it logs in to. Leave it without a passphrase (`-N ""`): the server can't unlock one.
513
+
514
+ ```sh
515
+ ssh-keygen -t ed25519 -N "" -C ssh-fleet@<hub> -f ~/.ssh/fleet_<host> # on the hub
516
+ ```
517
+
518
+ On the host, add the public key to the fleet account's `~/.ssh/authorized_keys`, limited to the hub's address (a CIDR such as `10.0.0.0/24` if it changes) and with forwarding and terminals turned off. The server runs every tool on a plain exec channel, so it needs none of them:
519
+
520
+ ```
521
+ from="<hub address>",no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-pty ssh-ed25519 AAAA… ssh-fleet@<hub>
522
+ ```
523
+
524
+ In CA mode the certificate carries the same limits, so hosts need no such options.
525
+
526
+ Then `key = "~/.ssh/fleet_<host>"` in the hub's config, and `ssh-fleet-mcp-server check` on the hub. To cut off one hub, delete its line on each host; the other hubs keep working. The cost grows with the fleet: three hubs and five hosts is fifteen lines to add and remove.
527
+
528
+ ### Why not one key in a password manager
529
+
530
+ A password manager's SSH agent (1Password, Bitwarden) can serve one key to every hub through `agent = true`, but it fits a fleet server badly:
531
+
532
+ - **It needs the app running and unlocked,** and it may ask you to approve each use. On a hub that restarts unattended, nobody is there to approve.
533
+ - **Claude Desktop doesn't pass `SSH_AUTH_SOCK`.** An `agent = true` host there fails every call, and `fleet_setup` says why, unless you set `SSH_AUTH_SOCK` in the server's `env` block.
534
+ - **One shared key widens the damage.** A compromised hub exposes every host, and revoking the key cuts off every hub.
535
+
536
+ ### Centralizing: CA mode
537
+
538
+ To manage access in one place, use [CA Mode](#ca-mode) instead:
539
+
540
+ - **One CA key; each host trusts it once,** with one `cert-authority` line in a root-owned authorized-keys file. Each hub signs itself a 10-minute certificate per connection.
541
+ - **Adding a hub doesn't touch the hosts.** The new hub gets a working copy of the CA key.
542
+ - **Keep the canonical copy in your password manager,** and give each hub its working copy once (see [The CA key at boot](#the-ca-key-at-boot)). No hub needs the password manager unlocked at runtime.
543
+ - **The config can be shared.** When every host uses `ca`, `fleet.toml` holds no secrets. A dotfiles manager can sync one copy to every hub if every hub reads the CA key the same way: `key_env`, or a `key_file` at the same path under `~`. Two traps:
544
+ - **Place the hub's working CA copy before the config arrives.** Otherwise the server finds no `key_file`, creates a new CA that no host trusts, and every connection fails.
545
+ - **Widen `source_address` to cover every hub,** or certificates signed on the other hubs are refused.
546
+
547
+ Two limits: enrolling a host needs root once, and only one server can run per `runtime_dir`, so two sessions on one hub need separate configs.
548
+
549
+ ### macOS hosts
550
+
551
+ - **Turn on Remote Login** (System Settings → General → Sharing).
552
+ - **`tmux_*` tools find Homebrew's tmux** without setup (see [`tmux_list`](#tmux_list)).
553
+ - **`exec` gets a bare `PATH`** (`/usr/bin:/bin:/usr/sbin:/sbin`): a command sent over SSH runs in a non-login shell, and zsh, the macOS default, doesn't read `~/.zprofile` then. For Homebrew tools through `exec`, add `eval "$(/opt/homebrew/bin/brew shellenv)"` (`/usr/local/bin/brew` on Intel) to the fleet account's `~/.zshenv`.
554
+
555
+ ## Command Line
556
+
557
+ ### `check`
558
+
559
+ ```sh
560
+ ssh-fleet-mcp-server check [--config=<path>]
561
+ ```
562
+
563
+ Connects to every host from a terminal and runs `true`, printing one line per host as it finishes, then each CA's enrollment line. Exits 0 when every host passes (or there are none), 1 otherwise.
564
+
565
+ ```
566
+ ok mini me@mini.local:22 password_env 212 ms
567
+ FAIL nas fleet@nas.local:22 ca Permission denied (publickey).
568
+ CA home: cert-authority,principals="fleet" ssh-ed25519 AAAA… ssh-fleet-ca-home
569
+ ```
570
+
571
+ - It never creates a config or key. A CA whose `key_file` is missing fails its hosts with a hint, without connecting.
572
+ - It skips policy, so it can run `true` even on a `readonly` or `deny` host. Each probe is audited with `tool: "check"`, `agent: "operator"`, `bypass: "policy"`. It isn't reachable over MCP.
573
+ - It uses its own runtime dir (`<runtime_dir>-check`), so it works while the server is running.
574
+ - A `ForceCommand` account that doesn't allow `true` fails the check.
575
+ - Run it with the same environment the MCP client gives the server, so `password_env` and `key_env` variables are set.
576
+
577
+ ## Development
578
+
579
+ All tooling is delegated to [`ts-builds`](https://github.com/jordanburke/ts-builds). Run from the repo root:
580
+
581
+ ```bash
582
+ pnpm -r validate # validate all packages (core, connector, orchestrator)
583
+ ```
584
+
585
+ Or from this package directory:
586
+
587
+ ```bash
588
+ pnpm validate # format → lint → typecheck → test → build
589
+ pnpm test # vitest run
590
+ pnpm test:watch # vitest watch mode
591
+ pnpm build # production build to dist/
592
+ ```
593
+
594
+ Integration tests (requiring a live SSH server on localhost) are automatically skipped when the host is not reachable — CI remains green in environments without SSH available.
@@ -0,0 +1 @@
1
+ export {}