@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 +30 -30
- package/build/config/config-loader.js +8 -0
- package/build/config/server.js +4 -4
- package/build/core/mcp-server.js +0 -5
- package/build/services/ssh-connection-manager.js +64 -159
- package/build/tools/execute-command.js +1 -1
- package/build/tools/help.js +4 -4
- package/build/tools/show-whitelist.js +67 -42
- package/package.json +15 -6
- 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
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
|
|
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
|
-
|
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
- "^
|
|
72
|
-
|
|
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
|
-
|
|
83
|
-
- "^
|
|
84
|
-
- "^
|
|
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
|
|
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
|
|
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
|
|
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
|
-
#
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
- "^
|
|
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
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
- **
|
|
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,
|
package/build/config/server.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
export const SERVER_CONFIG = {
|
|
2
2
|
name: "ssh-mcp-server",
|
|
3
|
-
version: "1.
|
|
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
|
|
10
|
-
3. Use execute-command for remote shell commands. Prefer a single command per call. Compound commands
|
|
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
|
|
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.`;
|
package/build/core/mcp-server.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
// ============================================
|
|
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
|
|
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:
|
|
638
|
+
// LAYER 2: Built-in blacklist for high-risk operations
|
|
746
639
|
// ========================================
|
|
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;
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|