@yawlabs/ssh-mcp 0.9.3 → 0.10.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/README.md CHANGED
@@ -66,7 +66,7 @@ Tools that fix your local SSH setup so everything else — git, deploys, tunnels
66
66
 
67
67
  | Tool | Description |
68
68
  |------|-------------|
69
- | `ssh_exec` | Execute a command on a remote host. Returns stdout, stderr, and exit code. |
69
+ | `ssh_exec` | Execute a command on a remote host. Returns stdout, stderr, and exit code (or `[signal: NAME]` and `code: -1` when the channel closed signal-only). Subject to [command policy](#command-policy) if configured. |
70
70
  | `ssh_read_file` | Read a file from a remote host via SFTP. |
71
71
  | `ssh_write_file` | Write content to a file on a remote host via SFTP. |
72
72
  | `ssh_upload` | Upload a local file to a remote host via SFTP. |
@@ -79,10 +79,10 @@ Tools that wrap common patterns agents build with ssh_exec — faster and less e
79
79
 
80
80
  | Tool | Description |
81
81
  |------|-------------|
82
- | `ssh_multi_exec` | Run a command on multiple hosts in parallel. Returns results per host. |
83
- | `ssh_find` | Search for files remotely with structured parameters (name, type, size, depth). |
82
+ | `ssh_multi_exec` | Run a command on multiple hosts in parallel. Returns results per host. Subject to [command policy](#command-policy) if configured (policy is checked once before fan-out). |
83
+ | `ssh_find` | Search for files remotely with structured parameters (`name`, `type`, `size`, `depth`, `newer` — match files modified more recently than a reference path). |
84
84
  | `ssh_tail` | Read the last N lines of a file, optionally filtered by a grep pattern. |
85
- | `ssh_service_status` | Check systemd service status (active, PID, uptime, description). |
85
+ | `ssh_service_status` | Check systemd service status (active, PID, uptime, description). Flags `isError` only when the unit could not be found / queried, not when an existing unit is intentionally stopped. |
86
86
 
87
87
  ### Auto-diagnostics
88
88
 
@@ -92,6 +92,8 @@ When any remote operation fails, ssh-mcp automatically runs diagnostics and incl
92
92
 
93
93
  Remote operations reuse SSH connections automatically. When your agent makes multiple calls to the same host, the first call opens a connection and subsequent calls reuse it. Connections are kept alive for 60 seconds after the last use, then closed automatically.
94
94
 
95
+ The pool caps at 100 active connections by default. Set `SSH_MCP_MAX_POOL_SIZE=<n>` to raise it for fan-out workloads against many distinct hosts (e.g. `ssh_multi_exec` across a large fleet). When the cap is reached, the pool evicts an idle entry to make room; if every entry is in use it rejects with `Connection pool is full`.
96
+
95
97
  ### SSH config support
96
98
 
97
99
  All connections respect your `~/.ssh/config`. Host aliases, custom ports, usernames, identity files, and ProxyJump settings are used automatically. If you have `Host myserver` configured in your SSH config, just pass `host: "myserver"` — ssh-mcp resolves everything.
@@ -110,6 +112,29 @@ For stricter environments, set `SSH_MCP_STRICT_HOST_KEY=1` to reject unknown hos
110
112
 
111
113
  The diagnostic tools (`ssh_test`, `ssh_diagnose`) use `StrictHostKeyChecking=no` for their probe commands. Those probes only run `echo SSH_OK` — no credentials or data pass through — so the relaxed setting is safe for connectivity testing. Real operations always go through the `hostVerifier`.
112
114
 
115
+ ### Command policy
116
+
117
+ `ssh_exec` and `ssh_multi_exec` accept free-form shell commands from the agent. For security-conscious deployments, you can restrict which commands run via two env vars, each accepting a comma-separated list of regex patterns:
118
+
119
+ - `SSH_MCP_COMMAND_WHITELIST` — if set, the command **must** match at least one pattern, else it's blocked.
120
+ - `SSH_MCP_COMMAND_BLACKLIST` — if set, the command **must not** match any pattern, else it's blocked.
121
+
122
+ When both are set, the command must pass both checks (whitelist first, then blacklist). When neither is set (the default), all commands are allowed.
123
+
124
+ Patterns are JavaScript regexes. Use `^` and `$` for anchored matches; otherwise patterns are treated as substring matches. Commas are the delimiter, so a literal comma in a pattern needs to be expressed as `\x2c` or via a character class.
125
+
126
+ ```bash
127
+ # Read-only allowlist: only ls / df / cat / find / tail
128
+ SSH_MCP_COMMAND_WHITELIST="^ls( .*)?,^df( .*)?,^cat ,^find ,^tail "
129
+
130
+ # Block destructive ops even if your agent goes off-script
131
+ SSH_MCP_COMMAND_BLACKLIST="^rm ,^shutdown,^reboot,^mkfs,^dd if=,>\s*/dev/"
132
+ ```
133
+
134
+ Blocked commands surface as a clear error mentioning which pattern (or which env var) rejected the call, so the agent can adapt rather than guess. Policy is enforced before the SSH connection opens — no remote process is started for a blocked command.
135
+
136
+ The structured higher-level tools (`ssh_find`, `ssh_tail`, `ssh_service_status`, SFTP ops) are exempt from policy. They build commands from typed parameters, so a tight `^ls` whitelist would otherwise force you to allow `^find `, `^tail `, `^systemctl ` just to keep those tools working — defeating the point of a tight whitelist.
137
+
113
138
  ### Windows support
114
139
 
115
140
  On Windows, ssh-mcp detects the OpenSSH Authentication Agent service automatically (via the `\\.\pipe\openssh-ssh-agent` named pipe). No `SSH_AUTH_SOCK` needed — just make sure the OpenSSH agent service is running.
package/dist/index.js CHANGED
@@ -1241,6 +1241,36 @@ async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
1241
1241
  };
1242
1242
  }
1243
1243
 
1244
+ // src/policy.ts
1245
+ function parsePatterns(raw) {
1246
+ if (!raw) return [];
1247
+ const patterns = [];
1248
+ for (const p of raw.split(",")) {
1249
+ const cleaned = p.replace(/^\s+/, "");
1250
+ if (!cleaned) continue;
1251
+ try {
1252
+ patterns.push(new RegExp(cleaned));
1253
+ } catch {
1254
+ console.error(`ssh-mcp: ignoring malformed regex in command policy: "${cleaned}"`);
1255
+ }
1256
+ }
1257
+ return patterns;
1258
+ }
1259
+ function enforcePolicy(command) {
1260
+ const whitelist = parsePatterns(process.env.SSH_MCP_COMMAND_WHITELIST);
1261
+ if (whitelist.length > 0 && !whitelist.some((r) => r.test(command))) {
1262
+ throw new Error(
1263
+ `Command blocked: does not match any pattern in SSH_MCP_COMMAND_WHITELIST. Configured patterns: ${whitelist.map((r) => r.source).join(", ")}`
1264
+ );
1265
+ }
1266
+ const blacklist = parsePatterns(process.env.SSH_MCP_COMMAND_BLACKLIST);
1267
+ for (const pattern of blacklist) {
1268
+ if (pattern.test(command)) {
1269
+ throw new Error(`Command blocked by SSH_MCP_COMMAND_BLACKLIST: pattern "${pattern.source}"`);
1270
+ }
1271
+ }
1272
+ }
1273
+
1244
1274
  // src/tools.ts
1245
1275
  var HostSchema = z.string().describe("SSH hostname or IP address");
1246
1276
  var PortSchema = z.number().int().min(1).max(65535).optional().describe("SSH port (default: 22)");
@@ -1261,13 +1291,14 @@ function registerTools(server, pool) {
1261
1291
  const connectionPool = pool ?? new ConnectionPool();
1262
1292
  server.tool(
1263
1293
  "ssh_exec",
1264
- "Execute a command on a remote host via SSH. The command is interpreted by the remote login shell \u2014 pipes, redirects, globs, and other shell metacharacters work as expected. Returns stdout, stderr, and exit code.",
1294
+ "Execute a command on a remote host via SSH. The command is interpreted by the remote login shell \u2014 pipes, redirects, globs, and other shell metacharacters work as expected. Returns stdout, stderr, and exit code. Subject to SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST if configured.",
1265
1295
  {
1266
1296
  ...connectionParams,
1267
1297
  command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
1268
1298
  timeout: TimeoutSchema
1269
1299
  },
1270
1300
  async ({ command, timeout, ...conn }) => {
1301
+ enforcePolicy(command);
1271
1302
  return connectionPool.withConnection(conn, async (client) => {
1272
1303
  const result = await exec(client, command, timeout || 3e4);
1273
1304
  const parts = [];
@@ -1511,7 +1542,7 @@ ${result.stderr}`);
1511
1542
  );
1512
1543
  server.tool(
1513
1544
  "ssh_multi_exec",
1514
- "Execute a command on multiple remote hosts in parallel. Returns results per host. Use this instead of calling ssh_exec multiple times \u2014 it's faster and shows results side by side.",
1545
+ "Execute a command on multiple remote hosts in parallel. Returns results per host. Use this instead of calling ssh_exec multiple times \u2014 it's faster and shows results side by side. Subject to SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST if configured (policy is checked once before fan-out).",
1515
1546
  {
1516
1547
  hosts: z.array(z.string()).describe("List of SSH hostnames or IPs"),
1517
1548
  command: z.string().describe("Shell command to execute on all hosts"),
@@ -1522,6 +1553,7 @@ ${result.stderr}`);
1522
1553
  timeout: TimeoutSchema
1523
1554
  },
1524
1555
  async ({ hosts, command, port, username, privateKeyPath, password, timeout }) => {
1556
+ enforcePolicy(command);
1525
1557
  const hostConfigs = hosts.map((host) => ({ host, port, username, privateKeyPath, password }));
1526
1558
  const results = await multiExec(connectionPool, hostConfigs, command, timeout || 3e4);
1527
1559
  const lines = [];
package/dist/server.d.ts CHANGED
@@ -185,8 +185,19 @@ interface ServiceStatus {
185
185
  }
186
186
  declare function serviceStatus(client: Client, serviceName: string, timeoutMs?: number): Promise<ServiceStatus>;
187
187
 
188
+ /**
189
+ * Check a command against the env-configured policy. Throws if blocked.
190
+ *
191
+ * Called from MCP tool handlers (ssh_exec, ssh_multi_exec) -- not from `exec()` itself,
192
+ * so library consumers using the programmatic API don't get policy enforcement (they're
193
+ * outside the MCP trust boundary and write their own gating).
194
+ */
195
+ declare function enforcePolicy(command: string): void;
196
+ /** Returns true if any policy is currently configured. Used by tool descriptions to surface that. */
197
+ declare function isPolicyConfigured(): boolean;
198
+
188
199
  declare function registerTools(server: McpServer, pool?: ConnectionPool): void;
189
200
 
190
201
  declare function createServer(pool?: ConnectionPool): McpServer;
191
202
 
192
- export { type AgentResult, type ConfigLookupResult, ConnectionPool, type DiagnosticReport, type DiagnosticResult, type ExecResult, type FindOptions, type KeyInfo, type MultiExecHost, type MultiExecResult, type PoolOptions, type ResolvedConfig, type SSHConfig, type ServiceStatus, checkConnectivity, checkGitSsh, checkKnownHosts, checkSshAgent, checkSshConfig, checkSshKeys, configLookup, connect, connectRaw, connectWithProxy, createServer, diagnose, downloadFile, ensureAgent, exec, find, fixKnownHosts, formatDiagnostics, listDir, listSshKeys, loadKey, multiExec, readFile, readKnownHostsKeys, registerTools, resolveConfig, serviceStatus, tail, testConnection, uploadFile, writeFile };
203
+ export { type AgentResult, type ConfigLookupResult, ConnectionPool, type DiagnosticReport, type DiagnosticResult, type ExecResult, type FindOptions, type KeyInfo, type MultiExecHost, type MultiExecResult, type PoolOptions, type ResolvedConfig, type SSHConfig, type ServiceStatus, checkConnectivity, checkGitSsh, checkKnownHosts, checkSshAgent, checkSshConfig, checkSshKeys, configLookup, connect, connectRaw, connectWithProxy, createServer, diagnose, downloadFile, enforcePolicy, ensureAgent, exec, find, fixKnownHosts, formatDiagnostics, isPolicyConfigured, listDir, listSshKeys, loadKey, multiExec, readFile, readKnownHostsKeys, registerTools, resolveConfig, serviceStatus, tail, testConnection, uploadFile, writeFile };
package/dist/server.js CHANGED
@@ -1052,6 +1052,39 @@ async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
1052
1052
  };
1053
1053
  }
1054
1054
 
1055
+ // src/policy.ts
1056
+ function parsePatterns(raw) {
1057
+ if (!raw) return [];
1058
+ const patterns = [];
1059
+ for (const p of raw.split(",")) {
1060
+ const cleaned = p.replace(/^\s+/, "");
1061
+ if (!cleaned) continue;
1062
+ try {
1063
+ patterns.push(new RegExp(cleaned));
1064
+ } catch {
1065
+ console.error(`ssh-mcp: ignoring malformed regex in command policy: "${cleaned}"`);
1066
+ }
1067
+ }
1068
+ return patterns;
1069
+ }
1070
+ function enforcePolicy(command) {
1071
+ const whitelist = parsePatterns(process.env.SSH_MCP_COMMAND_WHITELIST);
1072
+ if (whitelist.length > 0 && !whitelist.some((r) => r.test(command))) {
1073
+ throw new Error(
1074
+ `Command blocked: does not match any pattern in SSH_MCP_COMMAND_WHITELIST. Configured patterns: ${whitelist.map((r) => r.source).join(", ")}`
1075
+ );
1076
+ }
1077
+ const blacklist = parsePatterns(process.env.SSH_MCP_COMMAND_BLACKLIST);
1078
+ for (const pattern of blacklist) {
1079
+ if (pattern.test(command)) {
1080
+ throw new Error(`Command blocked by SSH_MCP_COMMAND_BLACKLIST: pattern "${pattern.source}"`);
1081
+ }
1082
+ }
1083
+ }
1084
+ function isPolicyConfigured() {
1085
+ return Boolean(process.env.SSH_MCP_COMMAND_WHITELIST?.trim() || process.env.SSH_MCP_COMMAND_BLACKLIST?.trim());
1086
+ }
1087
+
1055
1088
  // src/pool.ts
1056
1089
  function defaultMaxPoolSize() {
1057
1090
  const raw = process.env.SSH_MCP_MAX_POOL_SIZE;
@@ -1266,13 +1299,14 @@ function registerTools(server, pool) {
1266
1299
  const connectionPool = pool ?? new ConnectionPool();
1267
1300
  server.tool(
1268
1301
  "ssh_exec",
1269
- "Execute a command on a remote host via SSH. The command is interpreted by the remote login shell \u2014 pipes, redirects, globs, and other shell metacharacters work as expected. Returns stdout, stderr, and exit code.",
1302
+ "Execute a command on a remote host via SSH. The command is interpreted by the remote login shell \u2014 pipes, redirects, globs, and other shell metacharacters work as expected. Returns stdout, stderr, and exit code. Subject to SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST if configured.",
1270
1303
  {
1271
1304
  ...connectionParams,
1272
1305
  command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
1273
1306
  timeout: TimeoutSchema
1274
1307
  },
1275
1308
  async ({ command, timeout, ...conn }) => {
1309
+ enforcePolicy(command);
1276
1310
  return connectionPool.withConnection(conn, async (client) => {
1277
1311
  const result = await exec(client, command, timeout || 3e4);
1278
1312
  const parts = [];
@@ -1516,7 +1550,7 @@ ${result.stderr}`);
1516
1550
  );
1517
1551
  server.tool(
1518
1552
  "ssh_multi_exec",
1519
- "Execute a command on multiple remote hosts in parallel. Returns results per host. Use this instead of calling ssh_exec multiple times \u2014 it's faster and shows results side by side.",
1553
+ "Execute a command on multiple remote hosts in parallel. Returns results per host. Use this instead of calling ssh_exec multiple times \u2014 it's faster and shows results side by side. Subject to SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST if configured (policy is checked once before fan-out).",
1520
1554
  {
1521
1555
  hosts: z.array(z.string()).describe("List of SSH hostnames or IPs"),
1522
1556
  command: z.string().describe("Shell command to execute on all hosts"),
@@ -1527,6 +1561,7 @@ ${result.stderr}`);
1527
1561
  timeout: TimeoutSchema
1528
1562
  },
1529
1563
  async ({ hosts, command, port, username, privateKeyPath, password, timeout }) => {
1564
+ enforcePolicy(command);
1530
1565
  const hostConfigs = hosts.map((host) => ({ host, port, username, privateKeyPath, password }));
1531
1566
  const results = await multiExec(connectionPool, hostConfigs, command, timeout || 3e4);
1532
1567
  const lines = [];
@@ -1648,11 +1683,13 @@ export {
1648
1683
  createServer,
1649
1684
  diagnose,
1650
1685
  downloadFile,
1686
+ enforcePolicy,
1651
1687
  ensureAgent,
1652
1688
  exec,
1653
1689
  find,
1654
1690
  fixKnownHosts,
1655
1691
  formatDiagnostics,
1692
+ isPolicyConfigured,
1656
1693
  listDir,
1657
1694
  listSshKeys,
1658
1695
  loadKey,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/ssh-mcp",
3
- "version": "0.9.3",
3
+ "version": "0.10.0",
4
4
  "description": "MCP server for SSH operations with built-in diagnostics",
5
5
  "type": "module",
6
6
  "bin": {