@aaarc/handfree-ssh-mcp 1.0.0 → 1.0.2
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 +166 -133
- package/build/config/config-loader.js +60 -0
- package/build/config/server.js +13 -13
- package/build/config/ssh-config-loader.js +45 -2
- package/build/core/mcp-server.js +0 -5
- package/build/services/ssh-connection-manager.js +274 -164
- package/build/tools/execute-command.js +1 -1
- package/build/tools/help.js +4 -4
- package/build/tools/list-servers.js +6 -4
- package/build/tools/show-whitelist.js +67 -42
- package/package.json +20 -11
- package/build/tests/command-validation.test.js +0 -876
- package/build/tests/config.test.js +0 -617
- package/build/tests/integration.test.js +0 -348
- package/build/tests/output-collector.test.js +0 -70
- package/build/tests/output-log-writer.test.js +0 -205
- package/build/tests/recent-fixes.test.js +0 -229
- package/build/tests/tool-error.test.js +0 -26
- package/build/tests/tools.test.js +0 -486
|
@@ -19,79 +19,35 @@ const CONNECTION_RESET_FIELDS = [
|
|
|
19
19
|
"identitiesOnly",
|
|
20
20
|
"authOptional",
|
|
21
21
|
"socksProxy",
|
|
22
|
+
"jumpHost",
|
|
22
23
|
];
|
|
23
|
-
|
|
24
|
-
*
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
"
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
|
|
49
|
-
"
|
|
50
|
-
"^cd .*$",
|
|
51
|
-
// System info (read-only)
|
|
52
|
-
"^echo .*$",
|
|
53
|
-
"^hostname$",
|
|
54
|
-
"^whoami$",
|
|
55
|
-
"^id$",
|
|
56
|
-
"^uname .*$",
|
|
57
|
-
"^df .*$",
|
|
58
|
-
"^du .*$",
|
|
59
|
-
"^free .*$",
|
|
60
|
-
"^top -bn1.*$",
|
|
61
|
-
"^ps .*$",
|
|
62
|
-
"^uptime$",
|
|
63
|
-
"^date$",
|
|
64
|
-
"^env$",
|
|
65
|
-
"^printenv.*$",
|
|
66
|
-
// Network diagnostics (read-only)
|
|
67
|
-
"^ping -c \\d+ .*$",
|
|
68
|
-
"^curl .*$",
|
|
69
|
-
"^wget .*$",
|
|
70
|
-
"^netstat .*$",
|
|
71
|
-
"^ss .*$",
|
|
72
|
-
"^ip .*$",
|
|
73
|
-
// Git operations
|
|
74
|
-
"^git .*$",
|
|
75
|
-
// Systemctl (read-only)
|
|
76
|
-
"^systemctl status .*$",
|
|
77
|
-
"^systemctl list-.*$",
|
|
78
|
-
"^journalctl .*$",
|
|
79
|
-
// Docker (read-only)
|
|
80
|
-
"^docker ps.*$",
|
|
81
|
-
"^docker logs.*$",
|
|
82
|
-
"^docker images.*$",
|
|
83
|
-
"^docker inspect.*$",
|
|
84
|
-
// ============================================
|
|
85
|
-
// DANGEROUS COMMANDS - EXPLICITLY BANNED:
|
|
86
|
-
// - rm, rmdir (delete files/dirs)
|
|
87
|
-
// - mv (can overwrite/move files)
|
|
88
|
-
// - cp (can overwrite files)
|
|
89
|
-
// - chmod, chown (change permissions)
|
|
90
|
-
// - kill, pkill (terminate processes)
|
|
91
|
-
// - mkdir, touch (create files/dirs)
|
|
92
|
-
// - >, >> (redirect/overwrite files)
|
|
93
|
-
// - wget -O, curl -o (download and overwrite)
|
|
94
|
-
// ============================================
|
|
24
|
+
export const BUILT_IN_COMMAND_BLACKLIST = [
|
|
25
|
+
{ regex: /^\s*(?:sudo\s+)?(?:reboot|shutdown|halt|poweroff)(?:\s|$)/i, reason: "system power command" },
|
|
26
|
+
{ regex: /\b(?:Restart-Computer|Stop-Computer)\b/i, reason: "Windows power command" },
|
|
27
|
+
{ regex: /^\s*(?:sudo\s+)?(?:init\s+[06]|telinit\s+[06])(?:\s|$)/i, reason: "system runlevel power command" },
|
|
28
|
+
{ regex: /^\s*(?:sudo\s+)?rm\b(?=.*(?:\s--recursive\b|\s-\S*r))(?=.*(?:\s--force\b|\s-\S*f))/i, reason: "recursive force rm" },
|
|
29
|
+
{ regex: /\bRemove-Item\b(?=.*\s-Recurse(?:\s|$))(?=.*\s-Force(?:\s|$))/i, reason: "recursive force Remove-Item" },
|
|
30
|
+
{ regex: /^\s*(?:del|erase|rd)\b(?=.*(?:\/s\b|\s-Recurse(?:\s|$)))(?=.*(?:\/q\b|\s-Force(?:\s|$)))/i, reason: "recursive quiet Windows delete" },
|
|
31
|
+
{ regex: /^\s*(?:sudo\s+)?rmdir\s+(?:\/|\*|~|\$HOME|%USERPROFILE%|[A-Za-z]:\\)(?:\s|$)/i, reason: "dangerous rmdir target" },
|
|
32
|
+
{ regex: /^\s*(?:sudo\s+)?dd\b.*\bof=/i, reason: "dd with of= (can overwrite devices/files)" },
|
|
33
|
+
{ regex: /^\s*(?:sudo\s+)?mkfs(?:\.\S+)?\b/i, reason: "filesystem formatting command" },
|
|
34
|
+
{ regex: /^\s*(?:sudo\s+)?(?:diskpart|format)(?:\s|$)/i, reason: "disk formatting command" },
|
|
35
|
+
{ regex: /\b(?:Format-Volume|Clear-Disk|Remove-Partition)\b/i, reason: "Windows disk destructive command" },
|
|
36
|
+
{ regex: /^\s*(?:sudo\s+)?chmod\s+-R\s+777\b/i, reason: "recursive world-writable chmod" },
|
|
37
|
+
{ regex: /^\s*(?:sudo\s+)?chown\s+-R\s+\S+\s+\/(?:\s|$)/i, reason: "recursive chown on root" },
|
|
38
|
+
];
|
|
39
|
+
export const BUILT_IN_DESTRUCTIVE_GUARDS = [
|
|
40
|
+
{ regex: /\brm\b/, reason: "rm in command chain" },
|
|
41
|
+
{ regex: /\brmdir\b/, reason: "rmdir in command chain" },
|
|
42
|
+
{ regex: /\bunlink\b/, reason: "unlink in command chain" },
|
|
43
|
+
{ regex: /\bshred\b/, reason: "shred in command chain" },
|
|
44
|
+
{ regex: /\btruncate\b/, reason: "truncate in command chain" },
|
|
45
|
+
{ regex: /-delete\b/, reason: "find -delete detected" },
|
|
46
|
+
{ regex: /-exec\s+rm\b/, reason: "find -exec rm detected" },
|
|
47
|
+
{ regex: /\bmv\b/, reason: "mv detected (can overwrite)" },
|
|
48
|
+
{ regex: /\bcp\b.*-f/, reason: "cp -f detected (force overwrite)" },
|
|
49
|
+
{ regex: /(?<![0-9])>\s*\/(?!dev\/null)/, reason: "output redirection to absolute path" },
|
|
50
|
+
{ regex: />\s*~/, reason: "output redirection to home path" },
|
|
95
51
|
];
|
|
96
52
|
/**
|
|
97
53
|
* SSH Connection Manager class
|
|
@@ -108,6 +64,11 @@ export class SSHConnectionManager {
|
|
|
108
64
|
// concurrent connect attempts so we never create two SSH clients for the
|
|
109
65
|
// same server and leak the loser.
|
|
110
66
|
connecting = new Map();
|
|
67
|
+
// Dedicated tunneling clients for targets that use a `jumpHost`. Keyed by
|
|
68
|
+
// target server name (NOT jump name), so each target that jumps through the
|
|
69
|
+
// same bastion still gets its own jump client. This keeps tunnel lifetimes
|
|
70
|
+
// tied 1:1 to the target connection and avoids cross-target interference.
|
|
71
|
+
jumpClients = new Map();
|
|
111
72
|
defaultName = "default";
|
|
112
73
|
enabledServers = null; // null = all servers enabled
|
|
113
74
|
outputLogRoot = null; // null = use <cwd>/.handfree-output at write time
|
|
@@ -183,52 +144,6 @@ export class SSHConnectionManager {
|
|
|
183
144
|
getOutputLogRoot() {
|
|
184
145
|
return this.outputLogRoot ?? path.join(process.cwd(), ".handfree-output");
|
|
185
146
|
}
|
|
186
|
-
/**
|
|
187
|
-
* Hot-reload mutable policy fields from a fresh config map.
|
|
188
|
-
* Only updates whitelist, blacklist, safeDirectory, allowedRemoteDirectories,
|
|
189
|
-
* and allowedLocalDirectories for servers that already exist. Does NOT touch
|
|
190
|
-
* SSH connections or credentials.
|
|
191
|
-
*/
|
|
192
|
-
updatePolicies(freshConfigs) {
|
|
193
|
-
let changed = 0;
|
|
194
|
-
for (const [name, existing] of Object.entries(this.configs)) {
|
|
195
|
-
const fresh = freshConfigs[name];
|
|
196
|
-
if (!fresh)
|
|
197
|
-
continue;
|
|
198
|
-
const wlChanged = JSON.stringify(existing.commandWhitelist) !==
|
|
199
|
-
JSON.stringify(fresh.commandWhitelist);
|
|
200
|
-
const blChanged = JSON.stringify(existing.commandBlacklist) !==
|
|
201
|
-
JSON.stringify(fresh.commandBlacklist);
|
|
202
|
-
const sdChanged = existing.safeDirectory !== fresh.safeDirectory;
|
|
203
|
-
const ardChanged = JSON.stringify(existing.allowedRemoteDirectories) !==
|
|
204
|
-
JSON.stringify(fresh.allowedRemoteDirectories);
|
|
205
|
-
const aldChanged = JSON.stringify(existing.allowedLocalDirectories) !==
|
|
206
|
-
JSON.stringify(fresh.allowedLocalDirectories);
|
|
207
|
-
if (wlChanged || blChanged || sdChanged || ardChanged || aldChanged) {
|
|
208
|
-
existing.commandWhitelist = fresh.commandWhitelist;
|
|
209
|
-
existing.commandBlacklist = fresh.commandBlacklist;
|
|
210
|
-
existing.safeDirectory = fresh.safeDirectory;
|
|
211
|
-
existing.allowedRemoteDirectories = fresh.allowedRemoteDirectories;
|
|
212
|
-
existing.allowedLocalDirectories = fresh.allowedLocalDirectories;
|
|
213
|
-
changed++;
|
|
214
|
-
const parts = [];
|
|
215
|
-
if (wlChanged)
|
|
216
|
-
parts.push(`whitelist(${(fresh.commandWhitelist ?? []).length})`);
|
|
217
|
-
if (blChanged)
|
|
218
|
-
parts.push(`blacklist(${(fresh.commandBlacklist ?? []).length})`);
|
|
219
|
-
if (sdChanged)
|
|
220
|
-
parts.push(`safeDirectory(${fresh.safeDirectory ?? "none"})`);
|
|
221
|
-
if (ardChanged)
|
|
222
|
-
parts.push(`allowedRemoteDirectories(${(fresh.allowedRemoteDirectories ?? []).length})`);
|
|
223
|
-
if (aldChanged)
|
|
224
|
-
parts.push(`allowedLocalDirectories(${(fresh.allowedLocalDirectories ?? []).length})`);
|
|
225
|
-
Logger.log(`Hot-reloaded policies for [${name}]: ${parts.join(", ")}`, "info");
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
if (changed === 0) {
|
|
229
|
-
Logger.log("Config file changed but no policy updates detected", "info");
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
147
|
connectionFieldsChanged(oldConfig, nextConfig) {
|
|
233
148
|
return CONNECTION_RESET_FIELDS.some((key) => oldConfig[key] !== nextConfig[key]);
|
|
234
149
|
}
|
|
@@ -256,6 +171,7 @@ export class SSHConnectionManager {
|
|
|
256
171
|
}
|
|
257
172
|
this.clients.delete(name);
|
|
258
173
|
}
|
|
174
|
+
this.teardownJumpChain(name);
|
|
259
175
|
this.connected.set(name, false);
|
|
260
176
|
this.connecting.delete(name);
|
|
261
177
|
}
|
|
@@ -426,6 +342,24 @@ export class SSHConnectionManager {
|
|
|
426
342
|
if (agent) {
|
|
427
343
|
sshConfig.agent = agent;
|
|
428
344
|
}
|
|
345
|
+
// Add jump-host tunnel if provided. Mutually exclusive with socksProxy
|
|
346
|
+
// (enforced at config load time).
|
|
347
|
+
if (config.jumpHost) {
|
|
348
|
+
try {
|
|
349
|
+
const sock = await this.openJumpTunnel(key, config);
|
|
350
|
+
sshConfig.sock = sock;
|
|
351
|
+
Logger.log(`Using jump host '${config.jumpHost}' for [${key}]`, "info");
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
// A multi-hop chain may have partially connected (e.g. an inner hop
|
|
355
|
+
// came up but a later hop or the final forwardOut failed) before
|
|
356
|
+
// this rejected. Those already-connected hops were recorded in
|
|
357
|
+
// jumpClients as each one came up, so tear them down now instead of
|
|
358
|
+
// leaking live SSH sessions until the next connect attempt.
|
|
359
|
+
this.teardownJumpChain(key);
|
|
360
|
+
return reject(new ToolError("SSH_CONNECTION_FAILED", `Failed to open jump tunnel for [${key}] via '${config.jumpHost}': ${err.message}`, true));
|
|
361
|
+
}
|
|
362
|
+
}
|
|
429
363
|
// Add SOCKS proxy configuration if provided
|
|
430
364
|
if (config.socksProxy) {
|
|
431
365
|
try {
|
|
@@ -496,6 +430,162 @@ export class SSHConnectionManager {
|
|
|
496
430
|
}
|
|
497
431
|
this.clients.set(key, client);
|
|
498
432
|
}
|
|
433
|
+
/**
|
|
434
|
+
* Open a TCP tunnel from the target's jump chain to `config.host:config.port`
|
|
435
|
+
* and return the duplex stream to be used as `sock` for the target SSH client.
|
|
436
|
+
*
|
|
437
|
+
* Supports chained jumps to any depth: `target -> J1 -> J2 -> ...` where each
|
|
438
|
+
* hop's `jumpHost` names the next hop. The chain is built innermost-first —
|
|
439
|
+
* the deepest, directly-reachable hop connects normally, and each outer hop is
|
|
440
|
+
* connected through the previous hop's forwarded stream.
|
|
441
|
+
*
|
|
442
|
+
* Every hop's SSH client is cached (in order) under the target key so the whole
|
|
443
|
+
* chain can be torn down with the target connection. A hop's own client tracked
|
|
444
|
+
* in `this.clients` is intentionally NOT reused — jump usage and direct tool
|
|
445
|
+
* calls against a bastion stay isolated.
|
|
446
|
+
* @private
|
|
447
|
+
*/
|
|
448
|
+
async openJumpTunnel(targetKey, config) {
|
|
449
|
+
// Tear down any stale jump chain for this target before opening a new one.
|
|
450
|
+
this.teardownJumpChain(targetKey);
|
|
451
|
+
this.jumpClients.set(targetKey, []);
|
|
452
|
+
const jumpClient = await this.connectJumpChain(targetKey, config.jumpHost);
|
|
453
|
+
return this.forwardOutStream(jumpClient, config.host, config.port);
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Recursively connect the SSH client for `jumpName`, tunneling through its own
|
|
457
|
+
* `jumpHost` first when set. Each connected hop is appended (innermost-first)
|
|
458
|
+
* to the target's jump-client chain. Returns the connected client for this hop.
|
|
459
|
+
* @private
|
|
460
|
+
*/
|
|
461
|
+
async connectJumpChain(targetKey, jumpName) {
|
|
462
|
+
const jumpConfig = this.configs[jumpName];
|
|
463
|
+
if (!jumpConfig) {
|
|
464
|
+
// Should be caught at config load, but guard at runtime too.
|
|
465
|
+
throw new Error(`jump host '${jumpName}' not found in config`);
|
|
466
|
+
}
|
|
467
|
+
// If this hop is itself reached through another jump, build that inner
|
|
468
|
+
// tunnel first and hand its stream to this hop as `sock`.
|
|
469
|
+
let sock;
|
|
470
|
+
if (jumpConfig.jumpHost) {
|
|
471
|
+
const innerClient = await this.connectJumpChain(targetKey, jumpConfig.jumpHost);
|
|
472
|
+
sock = await this.forwardOutStream(innerClient, jumpConfig.host, jumpConfig.port);
|
|
473
|
+
}
|
|
474
|
+
const jumpClient = await this.connectJumpClient(targetKey, jumpName, jumpConfig, sock);
|
|
475
|
+
const chain = this.jumpClients.get(targetKey);
|
|
476
|
+
if (chain) {
|
|
477
|
+
chain.push(jumpClient);
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
this.jumpClients.set(targetKey, [jumpClient]);
|
|
481
|
+
}
|
|
482
|
+
return jumpClient;
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Connect a single jump-host SSH client, optionally through `sock` (the stream
|
|
486
|
+
* from the previous hop). Wires a close handler that tears the whole target
|
|
487
|
+
* chain down so a dead hop surfaces as a clear failure on the next op.
|
|
488
|
+
* @private
|
|
489
|
+
*/
|
|
490
|
+
connectJumpClient(targetKey, jumpName, jumpConfig, sock) {
|
|
491
|
+
return new Promise((resolve, reject) => {
|
|
492
|
+
const jumpClient = new Client();
|
|
493
|
+
jumpClient.on("ready", () => resolve(jumpClient));
|
|
494
|
+
jumpClient.on("error", (err) => {
|
|
495
|
+
reject(new Error(`jump SSH connect failed for '${jumpName}': ${err.message}`));
|
|
496
|
+
});
|
|
497
|
+
jumpClient.on("close", () => {
|
|
498
|
+
// If any hop dies, kill the target too so callers get a clear failure on
|
|
499
|
+
// the next op and reconnect through a fresh chain.
|
|
500
|
+
this.teardownTargetViaJump(targetKey);
|
|
501
|
+
});
|
|
502
|
+
const jumpSsh = {
|
|
503
|
+
host: jumpConfig.host,
|
|
504
|
+
port: jumpConfig.port,
|
|
505
|
+
username: jumpConfig.username,
|
|
506
|
+
};
|
|
507
|
+
if (sock) {
|
|
508
|
+
jumpSsh.sock = sock;
|
|
509
|
+
}
|
|
510
|
+
const jumpAgent = jumpConfig.agent === false
|
|
511
|
+
? undefined
|
|
512
|
+
: jumpConfig.agent || (jumpConfig.identitiesOnly ? undefined : process.env.SSH_AUTH_SOCK);
|
|
513
|
+
if (jumpAgent) {
|
|
514
|
+
jumpSsh.agent = jumpAgent;
|
|
515
|
+
}
|
|
516
|
+
if (jumpConfig.privateKey) {
|
|
517
|
+
try {
|
|
518
|
+
jumpSsh.privateKey = fs.readFileSync(jumpConfig.privateKey, "utf8");
|
|
519
|
+
if (jumpConfig.passphrase)
|
|
520
|
+
jumpSsh.passphrase = jumpConfig.passphrase;
|
|
521
|
+
}
|
|
522
|
+
catch (err) {
|
|
523
|
+
return reject(new Error(`read jump private key failed: ${err.message}`));
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
else if (jumpConfig.password) {
|
|
527
|
+
jumpSsh.password = jumpConfig.password;
|
|
528
|
+
}
|
|
529
|
+
else if (jumpAgent || jumpConfig.authOptional) {
|
|
530
|
+
Logger.log(`Using SSH agent/default authentication for jump host '${jumpName}'`, "info");
|
|
531
|
+
}
|
|
532
|
+
else {
|
|
533
|
+
return reject(new Error(`jump host '${jumpName}' has no password, privateKey, or default authentication`));
|
|
534
|
+
}
|
|
535
|
+
jumpClient.connect(jumpSsh);
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Open a forwarded TCP stream from `client` to `host:port`.
|
|
540
|
+
* @private
|
|
541
|
+
*/
|
|
542
|
+
forwardOutStream(client, host, port) {
|
|
543
|
+
return new Promise((resolve, reject) => {
|
|
544
|
+
client.forwardOut("127.0.0.1", 0, host, port, (err, ch) => {
|
|
545
|
+
if (err)
|
|
546
|
+
return reject(err);
|
|
547
|
+
resolve(ch);
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* End and forget every jump client in a target's chain (no target teardown).
|
|
553
|
+
* @private
|
|
554
|
+
*/
|
|
555
|
+
teardownJumpChain(targetKey) {
|
|
556
|
+
const chain = this.jumpClients.get(targetKey);
|
|
557
|
+
if (!chain) {
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
this.jumpClients.delete(targetKey);
|
|
561
|
+
for (const client of chain) {
|
|
562
|
+
try {
|
|
563
|
+
client.end();
|
|
564
|
+
}
|
|
565
|
+
catch { /* ignore */ }
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Tear the target connection and its whole jump chain down together. Idempotent
|
|
570
|
+
* so re-entrant close events (ending one hop closes the next) settle quietly.
|
|
571
|
+
* @private
|
|
572
|
+
*/
|
|
573
|
+
teardownTargetViaJump(targetKey) {
|
|
574
|
+
const chain = this.jumpClients.get(targetKey);
|
|
575
|
+
const target = this.clients.get(targetKey);
|
|
576
|
+
if (!chain && !target) {
|
|
577
|
+
return; // already torn down
|
|
578
|
+
}
|
|
579
|
+
this.connected.set(targetKey, false);
|
|
580
|
+
if (target) {
|
|
581
|
+
this.clients.delete(targetKey);
|
|
582
|
+
try {
|
|
583
|
+
target.end();
|
|
584
|
+
}
|
|
585
|
+
catch { /* ignore */ }
|
|
586
|
+
}
|
|
587
|
+
this.teardownJumpChain(targetKey);
|
|
588
|
+
}
|
|
499
589
|
/**
|
|
500
590
|
* Get SSH Client with specified name
|
|
501
591
|
*/
|
|
@@ -604,23 +694,7 @@ export class SSHConnectionManager {
|
|
|
604
694
|
*/
|
|
605
695
|
getDestructiveMatch(command) {
|
|
606
696
|
// This catches: "cd /; rm -rf *", "echo test && rm file", "$(rm file)", and risky writes like "> /etc/..."
|
|
607
|
-
const
|
|
608
|
-
{ regex: /\brm\b/, reason: "rm in command chain" },
|
|
609
|
-
{ regex: /\brmdir\b/, reason: "rmdir in command chain" },
|
|
610
|
-
{ regex: /\bunlink\b/, reason: "unlink in command chain" },
|
|
611
|
-
{ regex: /\bshred\b/, reason: "shred in command chain" },
|
|
612
|
-
{ regex: /\btruncate\b/, reason: "truncate in command chain" },
|
|
613
|
-
{ regex: /-delete\b/, reason: "find -delete detected" },
|
|
614
|
-
{ regex: /-exec\s+rm\b/, reason: "find -exec rm detected" },
|
|
615
|
-
{ regex: /\bdd\b.*\bof=/, reason: "dd with of= (can overwrite files)" },
|
|
616
|
-
{ regex: /\bmv\b/, reason: "mv detected (can overwrite)" },
|
|
617
|
-
{ regex: /\bcp\b.*-f/, reason: "cp -f detected (force overwrite)" },
|
|
618
|
-
// Allow stderr redirection to /dev/null (2>/dev/null, 2>&1>/dev/null, etc.)
|
|
619
|
-
// Block dangerous writes like: echo x > /etc/passwd, cat > /bin/bash
|
|
620
|
-
{ regex: /(?<![0-9])>\s*\/(?!dev\/null)/, reason: "output redirection to absolute path" },
|
|
621
|
-
{ regex: />\s*~/, reason: "output redirection to home path" },
|
|
622
|
-
];
|
|
623
|
-
for (const { regex, reason } of destructivePatterns) {
|
|
697
|
+
for (const { regex, reason } of BUILT_IN_DESTRUCTIVE_GUARDS) {
|
|
624
698
|
if (regex.test(command)) {
|
|
625
699
|
return reason;
|
|
626
700
|
}
|
|
@@ -742,32 +816,19 @@ export class SSHConnectionManager {
|
|
|
742
816
|
}
|
|
743
817
|
}
|
|
744
818
|
// ========================================
|
|
745
|
-
// LAYER 2:
|
|
819
|
+
// LAYER 2: Built-in blacklist for high-risk operations
|
|
746
820
|
// ========================================
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
const regex = new RegExp(pattern);
|
|
755
|
-
return regex.test(command);
|
|
756
|
-
}
|
|
757
|
-
catch (e) {
|
|
758
|
-
Logger.log(`Invalid whitelist regex pattern: ${pattern}`, "error");
|
|
759
|
-
return false;
|
|
821
|
+
for (const { regex, reason } of BUILT_IN_COMMAND_BLACKLIST) {
|
|
822
|
+
if (regex.test(command)) {
|
|
823
|
+
Logger.log(`Command blocked by built-in blacklist (${reason}): ${command}`, "info");
|
|
824
|
+
return {
|
|
825
|
+
isAllowed: false,
|
|
826
|
+
reason: `Command blocked by built-in blacklist: ${reason}`,
|
|
827
|
+
};
|
|
760
828
|
}
|
|
761
|
-
});
|
|
762
|
-
if (!matchesWhitelist) {
|
|
763
|
-
Logger.log(`Command blocked by whitelist: ${command}`, "info");
|
|
764
|
-
return {
|
|
765
|
-
isAllowed: false,
|
|
766
|
-
reason: `Command not in whitelist, execution forbidden. Command: "${command}"`,
|
|
767
|
-
};
|
|
768
829
|
}
|
|
769
830
|
// ========================================
|
|
770
|
-
// LAYER 3:
|
|
831
|
+
// LAYER 3: User blacklist check
|
|
771
832
|
// ========================================
|
|
772
833
|
if (config.commandBlacklist && config.commandBlacklist.length > 0) {
|
|
773
834
|
const matchesBlacklist = config.commandBlacklist.some((pattern) => {
|
|
@@ -788,6 +849,31 @@ export class SSHConnectionManager {
|
|
|
788
849
|
};
|
|
789
850
|
}
|
|
790
851
|
}
|
|
852
|
+
// ========================================
|
|
853
|
+
// LAYER 4: Optional whitelist mode
|
|
854
|
+
// ========================================
|
|
855
|
+
const commandMode = config.commandMode
|
|
856
|
+
?? ((config.commandWhitelist && config.commandWhitelist.length > 0) ? "whitelist" : "blacklist");
|
|
857
|
+
if (commandMode === "whitelist") {
|
|
858
|
+
const whitelist = config.commandWhitelist ?? [];
|
|
859
|
+
const matchesWhitelist = whitelist.some((pattern) => {
|
|
860
|
+
try {
|
|
861
|
+
const regex = new RegExp(pattern);
|
|
862
|
+
return regex.test(command);
|
|
863
|
+
}
|
|
864
|
+
catch (e) {
|
|
865
|
+
Logger.log(`Invalid whitelist regex pattern: ${pattern}`, "error");
|
|
866
|
+
return false;
|
|
867
|
+
}
|
|
868
|
+
});
|
|
869
|
+
if (!matchesWhitelist) {
|
|
870
|
+
Logger.log(`Command blocked by whitelist: ${command}`, "info");
|
|
871
|
+
return {
|
|
872
|
+
isAllowed: false,
|
|
873
|
+
reason: `Command not in whitelist, execution forbidden. Command: "${command}"`,
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
}
|
|
791
877
|
// Validation passed
|
|
792
878
|
return {
|
|
793
879
|
isAllowed: true,
|
|
@@ -914,7 +1000,7 @@ export class SSHConnectionManager {
|
|
|
914
1000
|
* Execute SSH command with auto-retry on connection errors
|
|
915
1001
|
*
|
|
916
1002
|
* Features:
|
|
917
|
-
* - Validates command against
|
|
1003
|
+
* - Validates command against command policy before execution
|
|
918
1004
|
* - Auto-reconnects and retries on connection failures
|
|
919
1005
|
* - Exponential backoff between retries (500ms, 1000ms, 2000ms)
|
|
920
1006
|
* - Configurable timeout per command
|
|
@@ -993,7 +1079,7 @@ export class SSHConnectionManager {
|
|
|
993
1079
|
* Execute SSH command with real-time streaming output via progress callback
|
|
994
1080
|
*
|
|
995
1081
|
* Features:
|
|
996
|
-
* - Validates command against
|
|
1082
|
+
* - Validates command against command policy before execution
|
|
997
1083
|
* - Streams stdout/stderr chunks to the onProgress callback in real-time
|
|
998
1084
|
* - Auto-reconnects and retries on connection failures
|
|
999
1085
|
* - Longer default timeout suitable for long-running tasks
|
|
@@ -1427,23 +1513,47 @@ export class SSHConnectionManager {
|
|
|
1427
1513
|
}
|
|
1428
1514
|
this.clients.clear();
|
|
1429
1515
|
}
|
|
1516
|
+
if (this.jumpClients.size > 0) {
|
|
1517
|
+
for (const chain of this.jumpClients.values()) {
|
|
1518
|
+
for (const client of chain) {
|
|
1519
|
+
try {
|
|
1520
|
+
client.end();
|
|
1521
|
+
}
|
|
1522
|
+
catch { /* ignore */ }
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
this.jumpClients.clear();
|
|
1526
|
+
}
|
|
1430
1527
|
}
|
|
1431
1528
|
/**
|
|
1432
|
-
* Get basic information of all configured servers
|
|
1529
|
+
* Get basic information of all configured servers.
|
|
1530
|
+
*
|
|
1531
|
+
* Lean by default — returns only identity + connection state. Pass
|
|
1532
|
+
* `verbose: true` to include the cached `status` block (hostname, CPU,
|
|
1533
|
+
* memory, disk, GPUs, etc.). The status block is large and rarely useful
|
|
1534
|
+
* for routing decisions, so the LLM should opt in.
|
|
1433
1535
|
*/
|
|
1434
|
-
getAllServerInfos() {
|
|
1536
|
+
getAllServerInfos(opts = {}) {
|
|
1537
|
+
const verbose = opts.verbose === true;
|
|
1435
1538
|
return Object.keys(this.configs).map((key) => {
|
|
1436
1539
|
const config = this.configs[key];
|
|
1437
|
-
const
|
|
1438
|
-
return {
|
|
1540
|
+
const info = {
|
|
1439
1541
|
name: key,
|
|
1440
1542
|
host: config.host,
|
|
1441
1543
|
port: config.port,
|
|
1442
1544
|
username: config.username,
|
|
1443
1545
|
connected: this.connected.get(key) === true,
|
|
1444
1546
|
enabled: this.isServerEnabled(key),
|
|
1445
|
-
status: status,
|
|
1446
1547
|
};
|
|
1548
|
+
if (config.jumpHost) {
|
|
1549
|
+
info.jumpHost = config.jumpHost;
|
|
1550
|
+
}
|
|
1551
|
+
if (verbose) {
|
|
1552
|
+
const status = this.statusCache.get(key);
|
|
1553
|
+
if (status)
|
|
1554
|
+
info.status = status;
|
|
1555
|
+
}
|
|
1556
|
+
return info;
|
|
1447
1557
|
});
|
|
1448
1558
|
}
|
|
1449
1559
|
/**
|
|
@@ -12,7 +12,7 @@ import { formatToolErrorResponse, toToolError } from "../utils/tool-error.js";
|
|
|
12
12
|
export function registerExecuteCommandTool(server) {
|
|
13
13
|
const sshManager = SSHConnectionManager.getInstance();
|
|
14
14
|
server.tool("execute-command", "Execute a shell command on a remote server over SSH. Use this for command-line actions on the selected host. Streaming mode is enabled by default so long-running commands can emit progress; set stream=false for short commands where you only want the final output. The returned text is tail-only-capped at maxOutputBytes PER STREAM (default 65536 bytes for stdout and 65536 bytes for stderr, so the response can be up to ~2x that); the FULL stdout/stderr is always persisted to a local log file under <cwd>/.handfree-output/<server>/<user>/, and the response includes that path whenever output was truncated.", {
|
|
15
|
-
cmdString: z.string().describe("Exact remote shell command to run. Prefer a single command per call, for example 'pwd', 'ls -la', 'cat /etc/hostname', or 'git status'. Compound commands may be blocked by
|
|
15
|
+
cmdString: z.string().describe("Exact remote shell command to run. Prefer a single command per call, for example 'pwd', 'ls -la', 'cat /etc/hostname', or 'git status'. Compound commands may be blocked by command policy even if each subcommand is safe."),
|
|
16
16
|
connectionName: z
|
|
17
17
|
.string()
|
|
18
18
|
.optional()
|
package/build/tools/help.js
CHANGED
|
@@ -31,7 +31,7 @@ Examples:
|
|
|
31
31
|
execute-command { cmdString: "pwd" }
|
|
32
32
|
execute-command { cmdString: "docker ps -a", connectionName: "prod", stream: false }
|
|
33
33
|
execute-command { cmdString: "tail -f /var/log/syslog", stream: true, timeout: 600000 }`,
|
|
34
|
-
"show-whitelist": `show-whitelist — Show
|
|
34
|
+
"show-whitelist": `show-whitelist — Show the active command policy for a server.
|
|
35
35
|
|
|
36
36
|
Parameters:
|
|
37
37
|
connectionName (string, see below) Target server name from list-servers.
|
|
@@ -40,7 +40,7 @@ connectionName rule:
|
|
|
40
40
|
• If only one server is enabled → optional (auto-selected).
|
|
41
41
|
• If multiple servers are enabled → REQUIRED.
|
|
42
42
|
|
|
43
|
-
Returns:
|
|
43
|
+
Returns: Command mode, built-in blacklist, configured whitelist/blacklist patterns, and example commands when whitelist mode is active.`,
|
|
44
44
|
"upload": `upload — Upload a single local file to a remote server over SFTP.
|
|
45
45
|
|
|
46
46
|
Parameters:
|
|
@@ -115,7 +115,7 @@ const TOOL_OVERVIEW = `Available tools (use help { tool: "<name>" } for details)
|
|
|
115
115
|
|
|
116
116
|
list-servers Discover available SSH servers and their status.
|
|
117
117
|
execute-command Run a shell command on a remote server.
|
|
118
|
-
show-whitelist Show
|
|
118
|
+
show-whitelist Show the active command policy.
|
|
119
119
|
upload Upload a single file to a remote server.
|
|
120
120
|
download Download a single file from a remote server.
|
|
121
121
|
transfer Move files: single, recursive, or cross-server relay.
|
|
@@ -123,7 +123,7 @@ const TOOL_OVERVIEW = `Available tools (use help { tool: "<name>" } for details)
|
|
|
123
123
|
|
|
124
124
|
Quick start:
|
|
125
125
|
1. list-servers → discover server names
|
|
126
|
-
2. show-whitelist { connectionName: "<name>" } →
|
|
126
|
+
2. show-whitelist { connectionName: "<name>" } → inspect command policy
|
|
127
127
|
3. execute-command { cmdString: "pwd", connectionName: "<name>" }`;
|
|
128
128
|
/**
|
|
129
129
|
* Register help tool
|
|
@@ -6,15 +6,17 @@ import { formatToolErrorResponse, toToolError } from "../utils/tool-error.js";
|
|
|
6
6
|
* Register list-servers tool
|
|
7
7
|
*/
|
|
8
8
|
export function registerListServersTool(server) {
|
|
9
|
-
server.tool("list-servers", "List the SSH servers that were loaded from OpenSSH config and/or YAML and enabled for this MCP process. Use this first to discover valid connectionName values, confirm whether a server is enabled, and
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
server.tool("list-servers", "List the SSH servers that were loaded from OpenSSH config and/or YAML and enabled for this MCP process. Use this first to discover valid connectionName values, confirm whether a server is enabled, see jumpHost wiring, and check basic connection state before calling other tools. By default the response is lean (identity + connection state only). Set verbose=true to include the cached system status block (hostname, CPU, memory, disk, GPUs). Set refresh=true to re-collect that status from live servers; refresh implies verbose.", {
|
|
10
|
+
verbose: z.boolean().optional().describe("Include the cached system status block (hostname, CPU, memory, disk, GPUs, etc.) for each server. Off by default to keep the response small. Implied by refresh=true."),
|
|
11
|
+
refresh: z.boolean().optional().describe("Re-collect live system status from all enabled servers before returning. Implies verbose=true. Without this, status (if requested via verbose) is read from the cache populated at connect time."),
|
|
12
|
+
}, async ({ verbose, refresh }) => {
|
|
12
13
|
try {
|
|
13
14
|
const sshManager = SSHConnectionManager.getInstance();
|
|
14
15
|
if (refresh) {
|
|
15
16
|
await sshManager.refreshStatus();
|
|
16
17
|
}
|
|
17
|
-
const
|
|
18
|
+
const wantStatus = verbose === true || refresh === true;
|
|
19
|
+
const servers = sshManager.getAllServerInfos({ verbose: wantStatus });
|
|
18
20
|
return {
|
|
19
21
|
content: [
|
|
20
22
|
{
|