@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 +29 -4
- package/dist/index.js +34 -2
- package/dist/server.d.ts +12 -1
- package/dist/server.js +39 -2
- package/package.json +1 -1
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
|
|
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,
|