@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.
@@ -19,79 +19,35 @@ const CONNECTION_RESET_FIELDS = [
19
19
  "identitiesOnly",
20
20
  "authOptional",
21
21
  "socksProxy",
22
+ "jumpHost",
22
23
  ];
23
- /**
24
- * Default command whitelist - only allow safe Linux commands
25
- * These patterns are regex strings that match allowed commands
26
- */
27
- /**
28
- * Default command whitelist - only allow safe Linux commands
29
- * These patterns are regex strings that match allowed commands
30
- *
31
- * SECURITY: Commands not matching any pattern will be BLOCKED with an error message
32
- */
33
- const DEFAULT_COMMAND_WHITELIST = [
34
- // Basic file operations (READ-ONLY)
35
- "^ls( .*)?$",
36
- "^cat .*$",
37
- "^head .*$",
38
- "^tail .*$",
39
- "^less .*$",
40
- "^more .*$",
41
- "^wc .*$",
42
- "^file .*$",
43
- "^stat .*$",
44
- "^find .*$",
45
- "^grep .*$",
46
- "^awk .*$",
47
- "^sed .*$",
48
- // Directory navigation
49
- "^pwd$",
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 destructivePatterns = [
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: Whitelist check for non-destructive commands
819
+ // LAYER 2: Built-in blacklist for high-risk operations
746
820
  // ========================================
747
- // Use config whitelist if provided, otherwise use default whitelist
748
- const whitelist = (config.commandWhitelist && config.commandWhitelist.length > 0)
749
- ? config.commandWhitelist
750
- : DEFAULT_COMMAND_WHITELIST;
751
- // Check whitelist - command must match one of the patterns to be allowed
752
- const matchesWhitelist = whitelist.some((pattern) => {
753
- try {
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: Blacklist check
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 whitelist/blacklist before execution
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 whitelist/blacklist before execution
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 status = this.statusCache.get(key);
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 whitelist rules even if each subcommand is safe."),
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()
@@ -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 allowed/blocked command patterns for a server.
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: Whitelist patterns, blacklist patterns, and example commands.`,
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 allowed/blocked command patterns.
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>" } → see what's allowed
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 see basic connection state before calling other tools. Set refresh=true to collect live system status (hostname, CPU, memory, disk, GPUs) from connected servers.", {
10
- refresh: z.boolean().optional().describe("When true, re-collects live system status from all enabled servers before returning. Without this, cached status from connection time is returned."),
11
- }, async ({ refresh }) => {
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 servers = sshManager.getAllServerInfos();
18
+ const wantStatus = verbose === true || refresh === true;
19
+ const servers = sshManager.getAllServerInfos({ verbose: wantStatus });
18
20
  return {
19
21
  content: [
20
22
  {