@aaarc/handfree-ssh-mcp 1.0.0 → 1.0.1

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
@@ -13,7 +13,7 @@ The original ssh-mcp-server requires passing credentials and options via CLI arg
13
13
  **handfree-ssh-mcp** takes a different approach:
14
14
 
15
15
  1. **Reuse your existing `~/.ssh/config`** automatically, or configure servers once in YAML
16
- 2. **Set your security whitelists** per server through a YAML overlay
16
+ 2. **Set command policies** per server through a YAML overlay
17
17
  3. **Let the LLM call whatever it needs** - hands-free
18
18
 
19
19
  Less manual interventions. Just autonomous SSH execution with safeguards.
@@ -24,7 +24,7 @@ Less manual interventions. Just autonomous SSH execution with safeguards.
24
24
  |---------|----------|------------------|
25
25
  | Configuration | CLI args | **OpenSSH `~/.ssh/config` + optional YAML overlay** |
26
26
  | Multi-server | Messy `--ssh` flags | **Clean YAML structure** |
27
- | Whitelists | Single comma-separated string | **Per-server arrays** |
27
+ | Command policy | Single comma-separated whitelist | **Blacklist mode by default, optional whitelist mode** |
28
28
  | Streaming | Not supported | **Real-time output with `stream` param** |
29
29
  | Discoverability | None | **`show-whitelist` tool for LLM** |
30
30
 
@@ -48,7 +48,7 @@ you can start the MCP without a YAML file:
48
48
  "mcpServers": {
49
49
  "ssh": {
50
50
  "command": "npx",
51
- "args": ["-y", "handfree-ssh-mcp", "--enable-servers", "dev"]
51
+ "args": ["-y", "@aaarc/handfree-ssh-mcp", "--enable-servers", "dev"]
52
52
  }
53
53
  }
54
54
  }
@@ -65,23 +65,21 @@ servers:
65
65
  dev: # Server name - use this in --enable-servers
66
66
  # host / port / username / privateKey can be omitted when dev exists in ~/.ssh/config.
67
67
  # Values here override the OpenSSH config entry when present.
68
- whitelist:
69
- - "^ls.*$"
70
- - "^cat.*$"
71
- - "^pwd$"
72
- - "^docker.*$"
73
- - "^git.*$"
74
- # Add whatever commands you trust the LLM to run
75
-
68
+ # commandMode defaults to blacklist: commands are allowed unless they
69
+ # match the built-in dangerous blacklist or a pattern below.
70
+ blacklist:
71
+ - "^docker system prune.*$"
72
+
76
73
  prod:
77
74
  host: XXXXX
78
75
  port: 22
79
- username: deploy
80
- privateKey: ~/.ssh/id_rsa
81
- whitelist:
82
- - "^ls.*$" # Read only
83
- - "^cat.*$"
84
- - "^tail.*$"
76
+ username: deploy
77
+ privateKey: ~/.ssh/id_rsa
78
+ commandMode: whitelist
79
+ whitelist:
80
+ - "^ls.*$" # Read only
81
+ - "^cat.*$"
82
+ - "^tail.*$"
85
83
  blacklist:
86
84
  - "^rm.*$" # Never allow delete
87
85
  - "^shutdown.*$"
@@ -116,7 +114,7 @@ The AI can now execute commands on your servers. All within your defined securit
116
114
  | Tool | Description |
117
115
  |------|-------------|
118
116
  | `execute-command` | Run SSH command (with optional `stream` for real-time output) |
119
- | `show-whitelist` | Show allowed commands + SFTP policy + output-log path for a server |
117
+ | `show-whitelist` | Show active command policy + SFTP policy + output-log path for a server |
120
118
  | `upload` | Upload local file to a remote server (CRLF-fix for shell scripts, skip-if-identical) |
121
119
  | `download` | Download remote file to local disk |
122
120
  | `transfer` | Unified upload / download / server-to-server relay (`mode`: `upload` / `download` / `relay`, optional `recursive`, optional `skipIfIdentical`) |
@@ -125,7 +123,7 @@ The AI can now execute commands on your servers. All within your defined securit
125
123
 
126
124
  ### show-whitelist
127
125
 
128
- **Use this first!** Let the LLM know what it's allowed to do:
126
+ **Use this first!** Let the LLM inspect the active command policy:
129
127
 
130
128
  ```json
131
129
  {
@@ -136,7 +134,7 @@ The AI can now execute commands on your servers. All within your defined securit
136
134
  }
137
135
  ```
138
136
 
139
- Returns a formatted list of allowed command patterns with examples.
137
+ Returns command mode, built-in command guards, configured whitelist/blacklist patterns, and examples when whitelist mode is active.
140
138
 
141
139
  ### execute-command
142
140
 
@@ -202,12 +200,14 @@ servers:
202
200
  # Network
203
201
  socksProxy: socks5://host:port
204
202
 
205
- # Security (regex patterns)
206
- whitelist: # Only allow matching commands
207
- - "^ls.*$"
208
- - "^cat.*$"
209
- blacklist: # Block matching commands
210
- - "^rm -rf.*$"
203
+ # Command policy (regex patterns)
204
+ # Default is blacklist. Set commandMode: whitelist to require a whitelist.
205
+ commandMode: blacklist
206
+ whitelist: # Active only in whitelist mode
207
+ - "^ls.*$"
208
+ - "^cat.*$"
209
+ blacklist: # Block matching commands
210
+ - "^docker system prune.*$"
211
211
 
212
212
  # Safe directory for destructive commands (rm, etc.)
213
213
  safeDirectory: /home/user # rm allowed only within this path
@@ -224,9 +224,9 @@ servers:
224
224
  - /path/to/extra/local/dir
225
225
  ```
226
226
 
227
- ### Security note: command whitelist
228
-
229
- If a server's YAML omits `whitelist:` (or sets it to an empty list), `execute-command` falls back to a built-in default whitelist. That default covers common read-mostly tools like `git .*`, `curl .*`, `find .*`, `awk .*`, `sed .*` broad enough that a determined LLM could write to disk via `awk 'BEGIN{system(...)}'` or similar. It is **strongly recommended to provide an explicit per-server `whitelist:`** narrowed to the commands you actually need. Startup logs a warning whenever a server is running on the default whitelist. See `show-whitelist` to inspect the effective whitelist at runtime.
227
+ ### Security note: command policy
228
+
229
+ `execute-command` defaults to `commandMode: blacklist`. In that mode commands are allowed unless they match built-in destructive guards, the built-in dangerous-command blacklist, or a server's configured `blacklist:` patterns. Built-in blocked operations include hidden/chained destructive file operations, risky absolute-path output redirection, system power commands (`reboot`, `shutdown`, `halt`, `poweroff`), recursive force delete (`rm -rf`), destructive disk writes (`dd ... of=`), and filesystem formatting commands. Set `commandMode: whitelist` to require every command to match `whitelist:` after blacklist checks. For compatibility, a YAML server that contains `whitelist:` without `commandMode:` is treated as whitelist mode.
230
230
 
231
231
  ### SFTP path policy
232
232
 
@@ -302,7 +302,7 @@ Example with selective servers:
302
302
 
303
303
  ## 🛡️ Security
304
304
 
305
- - **Whitelist everything**: Define exactly what commands are allowed
305
+ - **Pick the right command policy**: use default blacklist mode for flexible automation, or `commandMode: whitelist` for locked-down hosts
306
306
  - **Keep secrets safe**: Add `servers.yaml` to `.gitignore`
307
307
  - **Per-server control**: Prod can be locked down, dev can be permissive
308
308
 
@@ -130,8 +130,15 @@ function buildYamlServerConfig(name, serverConfig, options = {}) {
130
130
  }
131
131
  if (serverConfig.socksProxy !== undefined)
132
132
  merged.socksProxy = serverConfig.socksProxy ?? undefined;
133
+ if (serverConfig.commandMode !== undefined) {
134
+ if (serverConfig.commandMode !== "blacklist" && serverConfig.commandMode !== "whitelist") {
135
+ throw new Error(`Server '${name}': 'commandMode' must be 'blacklist' or 'whitelist'`);
136
+ }
137
+ merged.commandMode = serverConfig.commandMode;
138
+ }
133
139
  if (serverConfig.whitelist !== undefined) {
134
140
  merged.commandWhitelist = serverConfig.whitelist ?? undefined;
141
+ merged.commandMode ??= "whitelist";
135
142
  }
136
143
  if (serverConfig.blacklist !== undefined) {
137
144
  merged.commandBlacklist = serverConfig.blacklist ?? undefined;
@@ -159,6 +166,7 @@ function mergeConfigMaps(sshConfigs, yamlConfigs) {
159
166
  name,
160
167
  allowedRemoteDirectories: yamlConfig.allowedRemoteDirectories ?? merged[name]?.allowedRemoteDirectories,
161
168
  allowedLocalDirectories: yamlConfig.allowedLocalDirectories ?? merged[name]?.allowedLocalDirectories,
169
+ commandMode: yamlConfig.commandMode ?? merged[name]?.commandMode,
162
170
  commandWhitelist: yamlConfig.commandWhitelist ?? merged[name]?.commandWhitelist,
163
171
  commandBlacklist: yamlConfig.commandBlacklist ?? merged[name]?.commandBlacklist,
164
172
  safeDirectory: yamlConfig.safeDirectory ?? merged[name]?.safeDirectory,
@@ -1,13 +1,13 @@
1
1
  export const SERVER_CONFIG = {
2
2
  name: "ssh-mcp-server",
3
- version: "1.4.0",
3
+ version: "1.0.1",
4
4
  };
5
5
  export const SERVER_INSTRUCTIONS = `This server provides SSH access to servers loaded from OpenSSH config (~/.ssh/config by default) and optional YAML config/policy overlays.
6
6
 
7
7
  Recommended workflow:
8
8
  1. Call list-servers first to discover which server names are available, enabled, and currently connected.
9
- 2. Use show-whitelist before execute-command when you are unsure whether a command is allowed.
10
- 3. Use execute-command for remote shell commands. Prefer a single command per call. Compound commands such as "cmd1 && cmd2" may be rejected by whitelist rules even if each subcommand is individually safe.
9
+ 2. Use show-whitelist before execute-command when you are unsure which command policy is active.
10
+ 3. Use execute-command for remote shell commands. Prefer a single command per call. Compound commands may be rejected by command policy even if each subcommand is individually safe.
11
11
  4. Use stream=false for short commands that should finish quickly, such as pwd, ls, cat, head, tail, git status, or docker ps.
12
12
  5. Use stream=true for commands that may take longer or where incremental output is useful.
13
13
  6. Use upload and download for single-file SFTP transfers. Use transfer for recursive directory transfers or cross-server relay.
@@ -19,7 +19,7 @@ Server targeting:
19
19
 
20
20
  Behavior notes:
21
21
  - A tool may automatically establish the SSH connection on first use.
22
- - Command execution is restricted by the configured whitelist and blacklist. If a command is rejected, inspect the whitelist rather than retrying the same command repeatedly.
22
+ - Command execution uses blacklist mode by default, with a built-in dangerous-command blacklist. Servers can opt into whitelist mode through YAML. If a command is rejected, inspect the command policy rather than retrying the same command repeatedly.
23
23
  - Some commands return no output on success; this is normal.
24
24
  - File transfer tools use SFTP and can fail if the remote path is missing or permissions are insufficient.
25
25
  - OpenSSH/YAML config changes are hot-reloaded without restarting the server. Connection-field changes close the old client so the next tool call reconnects with fresh settings.`;
@@ -148,11 +148,6 @@ export class SshMcpServer {
148
148
  const parsedArgs = this.loadConfig();
149
149
  this.sshManager.setConfig(parsedArgs.configs, parsedArgs.enabledServers);
150
150
  this.sshManager.setOutputLogRoot(parsedArgs.outputLogDir);
151
- // Security warning
152
- const allConfigs = Object.values(parsedArgs.configs);
153
- if (allConfigs.some((c) => !c.commandWhitelist || c.commandWhitelist.length === 0)) {
154
- Logger.log("WARNING: Running without a command whitelist is strongly discouraged. Please configure a whitelist to restrict the commands that can be executed.", "info");
155
- }
156
151
  // Pre-connect to enabled servers if flag is set
157
152
  if (parsedArgs.preConnect) {
158
153
  Logger.log("Pre-connecting to enabled SSH servers...", "info");
@@ -20,78 +20,33 @@ const CONNECTION_RESET_FIELDS = [
20
20
  "authOptional",
21
21
  "socksProxy",
22
22
  ];
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
- // ============================================
23
+ export const BUILT_IN_COMMAND_BLACKLIST = [
24
+ { regex: /^\s*(?:sudo\s+)?(?:reboot|shutdown|halt|poweroff)(?:\s|$)/i, reason: "system power command" },
25
+ { regex: /\b(?:Restart-Computer|Stop-Computer)\b/i, reason: "Windows power command" },
26
+ { regex: /^\s*(?:sudo\s+)?(?:init\s+[06]|telinit\s+[06])(?:\s|$)/i, reason: "system runlevel power command" },
27
+ { regex: /^\s*(?:sudo\s+)?rm\b(?=.*(?:\s--recursive\b|\s-\S*r))(?=.*(?:\s--force\b|\s-\S*f))/i, reason: "recursive force rm" },
28
+ { regex: /\bRemove-Item\b(?=.*\s-Recurse(?:\s|$))(?=.*\s-Force(?:\s|$))/i, reason: "recursive force Remove-Item" },
29
+ { regex: /^\s*(?:del|erase|rd)\b(?=.*(?:\/s\b|\s-Recurse(?:\s|$)))(?=.*(?:\/q\b|\s-Force(?:\s|$)))/i, reason: "recursive quiet Windows delete" },
30
+ { regex: /^\s*(?:sudo\s+)?rmdir\s+(?:\/|\*|~|\$HOME|%USERPROFILE%|[A-Za-z]:\\)(?:\s|$)/i, reason: "dangerous rmdir target" },
31
+ { regex: /^\s*(?:sudo\s+)?dd\b.*\bof=/i, reason: "dd with of= (can overwrite devices/files)" },
32
+ { regex: /^\s*(?:sudo\s+)?mkfs(?:\.\S+)?\b/i, reason: "filesystem formatting command" },
33
+ { regex: /^\s*(?:sudo\s+)?(?:diskpart|format)(?:\s|$)/i, reason: "disk formatting command" },
34
+ { regex: /\b(?:Format-Volume|Clear-Disk|Remove-Partition)\b/i, reason: "Windows disk destructive command" },
35
+ { regex: /^\s*(?:sudo\s+)?chmod\s+-R\s+777\b/i, reason: "recursive world-writable chmod" },
36
+ { regex: /^\s*(?:sudo\s+)?chown\s+-R\s+\S+\s+\/(?:\s|$)/i, reason: "recursive chown on root" },
37
+ ];
38
+ export const BUILT_IN_DESTRUCTIVE_GUARDS = [
39
+ { regex: /\brm\b/, reason: "rm in command chain" },
40
+ { regex: /\brmdir\b/, reason: "rmdir in command chain" },
41
+ { regex: /\bunlink\b/, reason: "unlink in command chain" },
42
+ { regex: /\bshred\b/, reason: "shred in command chain" },
43
+ { regex: /\btruncate\b/, reason: "truncate in command chain" },
44
+ { regex: /-delete\b/, reason: "find -delete detected" },
45
+ { regex: /-exec\s+rm\b/, reason: "find -exec rm detected" },
46
+ { regex: /\bmv\b/, reason: "mv detected (can overwrite)" },
47
+ { regex: /\bcp\b.*-f/, reason: "cp -f detected (force overwrite)" },
48
+ { regex: /(?<![0-9])>\s*\/(?!dev\/null)/, reason: "output redirection to absolute path" },
49
+ { regex: />\s*~/, reason: "output redirection to home path" },
95
50
  ];
96
51
  /**
97
52
  * SSH Connection Manager class
@@ -183,52 +138,6 @@ export class SSHConnectionManager {
183
138
  getOutputLogRoot() {
184
139
  return this.outputLogRoot ?? path.join(process.cwd(), ".handfree-output");
185
140
  }
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
141
  connectionFieldsChanged(oldConfig, nextConfig) {
233
142
  return CONNECTION_RESET_FIELDS.some((key) => oldConfig[key] !== nextConfig[key]);
234
143
  }
@@ -604,23 +513,7 @@ export class SSHConnectionManager {
604
513
  */
605
514
  getDestructiveMatch(command) {
606
515
  // 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) {
516
+ for (const { regex, reason } of BUILT_IN_DESTRUCTIVE_GUARDS) {
624
517
  if (regex.test(command)) {
625
518
  return reason;
626
519
  }
@@ -742,32 +635,19 @@ export class SSHConnectionManager {
742
635
  }
743
636
  }
744
637
  // ========================================
745
- // LAYER 2: Whitelist check for non-destructive commands
638
+ // LAYER 2: Built-in blacklist for high-risk operations
746
639
  // ========================================
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;
640
+ for (const { regex, reason } of BUILT_IN_COMMAND_BLACKLIST) {
641
+ if (regex.test(command)) {
642
+ Logger.log(`Command blocked by built-in blacklist (${reason}): ${command}`, "info");
643
+ return {
644
+ isAllowed: false,
645
+ reason: `Command blocked by built-in blacklist: ${reason}`,
646
+ };
760
647
  }
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
648
  }
769
649
  // ========================================
770
- // LAYER 3: Blacklist check
650
+ // LAYER 3: User blacklist check
771
651
  // ========================================
772
652
  if (config.commandBlacklist && config.commandBlacklist.length > 0) {
773
653
  const matchesBlacklist = config.commandBlacklist.some((pattern) => {
@@ -788,6 +668,31 @@ export class SSHConnectionManager {
788
668
  };
789
669
  }
790
670
  }
671
+ // ========================================
672
+ // LAYER 4: Optional whitelist mode
673
+ // ========================================
674
+ const commandMode = config.commandMode
675
+ ?? ((config.commandWhitelist && config.commandWhitelist.length > 0) ? "whitelist" : "blacklist");
676
+ if (commandMode === "whitelist") {
677
+ const whitelist = config.commandWhitelist ?? [];
678
+ const matchesWhitelist = whitelist.some((pattern) => {
679
+ try {
680
+ const regex = new RegExp(pattern);
681
+ return regex.test(command);
682
+ }
683
+ catch (e) {
684
+ Logger.log(`Invalid whitelist regex pattern: ${pattern}`, "error");
685
+ return false;
686
+ }
687
+ });
688
+ if (!matchesWhitelist) {
689
+ Logger.log(`Command blocked by whitelist: ${command}`, "info");
690
+ return {
691
+ isAllowed: false,
692
+ reason: `Command not in whitelist, execution forbidden. Command: "${command}"`,
693
+ };
694
+ }
695
+ }
791
696
  // Validation passed
792
697
  return {
793
698
  isAllowed: true,
@@ -914,7 +819,7 @@ export class SSHConnectionManager {
914
819
  * Execute SSH command with auto-retry on connection errors
915
820
  *
916
821
  * Features:
917
- * - Validates command against whitelist/blacklist before execution
822
+ * - Validates command against command policy before execution
918
823
  * - Auto-reconnects and retries on connection failures
919
824
  * - Exponential backoff between retries (500ms, 1000ms, 2000ms)
920
825
  * - Configurable timeout per command
@@ -993,7 +898,7 @@ export class SSHConnectionManager {
993
898
  * Execute SSH command with real-time streaming output via progress callback
994
899
  *
995
900
  * Features:
996
- * - Validates command against whitelist/blacklist before execution
901
+ * - Validates command against command policy before execution
997
902
  * - Streams stdout/stderr chunks to the onProgress callback in real-time
998
903
  * - Auto-reconnects and retries on connection failures
999
904
  * - Longer default timeout suitable for long-running tasks
@@ -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