@aaarc/handfree-ssh-mcp 1.0.2 → 1.0.4
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 +1 -1
- package/build/config/config-loader.js +8 -0
- package/build/config/server.js +1 -1
- package/build/services/ssh-connection-manager.js +51 -26
- package/package.json +61 -61
package/README.md
CHANGED
|
@@ -268,7 +268,7 @@ Rules (enforced at config load — bad configs fail fast):
|
|
|
268
268
|
- **Independent policy.** The target authenticates with its OWN `username` / `password` / `privateKey`, and its own `whitelist` / `blacklist` / `safeDirectory` / `allowed*Directories` apply. The jump host is purely transport.
|
|
269
269
|
- **Jump host is still a normal server.** You can run tools against `bastion` directly; its connection is separate from the tunneling one.
|
|
270
270
|
|
|
271
|
-
**Hot-reload note:** `jumpHost` is connection-level,
|
|
271
|
+
**Hot-reload note:** `jumpHost` is a connection-level field, so it hot-reloads. Editing a server's `jumpHost` (or any hop's connection fields) in `servers.yaml` resets the affected connection on the fly — the change takes effect on the next tool call, no restart needed. This is chain-aware: if an intermediate hop's host / port / credentials change, every target that tunnels through it is reset too.
|
|
272
272
|
|
|
273
273
|
### Connection lifecycle (connect / reconnect)
|
|
274
274
|
|
|
@@ -148,6 +148,12 @@ function buildYamlServerConfig(name, serverConfig, options = {}) {
|
|
|
148
148
|
if (serverConfig.blacklist !== undefined) {
|
|
149
149
|
merged.commandBlacklist = serverConfig.blacklist ?? undefined;
|
|
150
150
|
}
|
|
151
|
+
if (serverConfig.disableBuiltinGuards !== undefined) {
|
|
152
|
+
merged.disableBuiltinGuards = serverConfig.disableBuiltinGuards === true;
|
|
153
|
+
}
|
|
154
|
+
if (serverConfig.disableBuiltinBlacklist !== undefined) {
|
|
155
|
+
merged.disableBuiltinBlacklist = serverConfig.disableBuiltinBlacklist === true;
|
|
156
|
+
}
|
|
151
157
|
if (serverConfig.safeDirectory !== undefined) {
|
|
152
158
|
merged.safeDirectory = serverConfig.safeDirectory ?? undefined;
|
|
153
159
|
}
|
|
@@ -175,6 +181,8 @@ function mergeConfigMaps(sshConfigs, yamlConfigs) {
|
|
|
175
181
|
commandMode: yamlConfig.commandMode ?? merged[name]?.commandMode,
|
|
176
182
|
commandWhitelist: yamlConfig.commandWhitelist ?? merged[name]?.commandWhitelist,
|
|
177
183
|
commandBlacklist: yamlConfig.commandBlacklist ?? merged[name]?.commandBlacklist,
|
|
184
|
+
disableBuiltinGuards: yamlConfig.disableBuiltinGuards ?? merged[name]?.disableBuiltinGuards,
|
|
185
|
+
disableBuiltinBlacklist: yamlConfig.disableBuiltinBlacklist ?? merged[name]?.disableBuiltinBlacklist,
|
|
178
186
|
safeDirectory: yamlConfig.safeDirectory ?? merged[name]?.safeDirectory,
|
|
179
187
|
};
|
|
180
188
|
}
|
package/build/config/server.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export const SERVER_CONFIG = {
|
|
2
2
|
name: "ssh-mcp-server",
|
|
3
|
-
version: "1.0.
|
|
3
|
+
version: "1.0.4",
|
|
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
|
|
|
@@ -29,23 +29,10 @@ export const BUILT_IN_COMMAND_BLACKLIST = [
|
|
|
29
29
|
{ regex: /\bRemove-Item\b(?=.*\s-Recurse(?:\s|$))(?=.*\s-Force(?:\s|$))/i, reason: "recursive force Remove-Item" },
|
|
30
30
|
{ regex: /^\s*(?:del|erase|rd)\b(?=.*(?:\/s\b|\s-Recurse(?:\s|$)))(?=.*(?:\/q\b|\s-Force(?:\s|$)))/i, reason: "recursive quiet Windows delete" },
|
|
31
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
32
|
{ regex: /^\s*(?:sudo\s+)?chmod\s+-R\s+777\b/i, reason: "recursive world-writable chmod" },
|
|
37
33
|
{ regex: /^\s*(?:sudo\s+)?chown\s+-R\s+\S+\s+\/(?:\s|$)/i, reason: "recursive chown on root" },
|
|
38
34
|
];
|
|
39
35
|
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
36
|
{ regex: /(?<![0-9])>\s*\/(?!dev\/null)/, reason: "output redirection to absolute path" },
|
|
50
37
|
{ regex: />\s*~/, reason: "output redirection to home path" },
|
|
51
38
|
];
|
|
@@ -116,18 +103,52 @@ export class SSHConnectionManager {
|
|
|
116
103
|
*/
|
|
117
104
|
replaceConfig(configs, enabledServers) {
|
|
118
105
|
const previous = this.configs;
|
|
119
|
-
|
|
106
|
+
// Pass 1: servers whose own connection fields changed (or that vanished).
|
|
107
|
+
const directlyChanged = new Set();
|
|
120
108
|
for (const [name, oldConfig] of Object.entries(previous)) {
|
|
121
109
|
const nextConfig = configs[name];
|
|
122
110
|
if (!nextConfig || this.connectionFieldsChanged(oldConfig, nextConfig)) {
|
|
123
|
-
|
|
124
|
-
this.statusCache.delete(name);
|
|
125
|
-
changedConnections.push(name);
|
|
111
|
+
directlyChanged.add(name);
|
|
126
112
|
}
|
|
127
113
|
}
|
|
114
|
+
// Pass 2: a target must ALSO reset if any hop in its jump chain directly
|
|
115
|
+
// changed — otherwise it keeps tunneling through a stale intermediate hop.
|
|
116
|
+
// Walk both the old and new chains so topology shifts (added/removed hops)
|
|
117
|
+
// are covered too.
|
|
118
|
+
const toReset = new Set(directlyChanged);
|
|
119
|
+
for (const name of Object.keys(previous)) {
|
|
120
|
+
if (toReset.has(name))
|
|
121
|
+
continue;
|
|
122
|
+
if (this.jumpChainTouchesChanged(name, previous, directlyChanged) ||
|
|
123
|
+
this.jumpChainTouchesChanged(name, configs, directlyChanged)) {
|
|
124
|
+
toReset.add(name);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
for (const name of toReset) {
|
|
128
|
+
this.closeClient(name, true);
|
|
129
|
+
this.statusCache.delete(name);
|
|
130
|
+
}
|
|
128
131
|
this.setConfig(configs, enabledServers);
|
|
129
132
|
Logger.log(`Hot-reloaded SSH config: ${Object.keys(configs).length} server(s), ` +
|
|
130
|
-
`${
|
|
133
|
+
`${toReset.size} connection(s) reset`, "info");
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Walk `name`'s jump chain in `configMap` and report whether any hop is in the
|
|
137
|
+
* `changed` set. Guards against cycles defensively (config load rejects them).
|
|
138
|
+
* @private
|
|
139
|
+
*/
|
|
140
|
+
jumpChainTouchesChanged(name, configMap, changed) {
|
|
141
|
+
const seen = new Set([name]);
|
|
142
|
+
let hop = configMap[name]?.jumpHost;
|
|
143
|
+
while (hop !== undefined) {
|
|
144
|
+
if (changed.has(hop))
|
|
145
|
+
return true;
|
|
146
|
+
if (seen.has(hop))
|
|
147
|
+
break;
|
|
148
|
+
seen.add(hop);
|
|
149
|
+
hop = configMap[hop]?.jumpHost;
|
|
150
|
+
}
|
|
151
|
+
return false;
|
|
131
152
|
}
|
|
132
153
|
/**
|
|
133
154
|
* Set the root directory under which execute-command full-output logs are
|
|
@@ -790,7 +811,9 @@ export class SSHConnectionManager {
|
|
|
790
811
|
|| (config.username
|
|
791
812
|
? (config.username === 'root' ? '/root' : `/home/${config.username}`)
|
|
792
813
|
: SSHConnectionManager.DEFAULT_SAFE_DIRECTORY);
|
|
793
|
-
const destructiveReason =
|
|
814
|
+
const destructiveReason = config.disableBuiltinGuards
|
|
815
|
+
? null
|
|
816
|
+
: this.getDestructiveMatch(command);
|
|
794
817
|
if (destructiveReason) {
|
|
795
818
|
// Command contains rm/rmdir - check if it's a simple rm command or hidden in a chain
|
|
796
819
|
const trimmed = command.trim();
|
|
@@ -818,13 +841,15 @@ export class SSHConnectionManager {
|
|
|
818
841
|
// ========================================
|
|
819
842
|
// LAYER 2: Built-in blacklist for high-risk operations
|
|
820
843
|
// ========================================
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
844
|
+
if (!config.disableBuiltinBlacklist) {
|
|
845
|
+
for (const { regex, reason } of BUILT_IN_COMMAND_BLACKLIST) {
|
|
846
|
+
if (regex.test(command)) {
|
|
847
|
+
Logger.log(`Command blocked by built-in blacklist (${reason}): ${command}`, "info");
|
|
848
|
+
return {
|
|
849
|
+
isAllowed: false,
|
|
850
|
+
reason: `Command blocked by built-in blacklist: ${reason}`,
|
|
851
|
+
};
|
|
852
|
+
}
|
|
828
853
|
}
|
|
829
854
|
}
|
|
830
855
|
// ========================================
|
package/package.json
CHANGED
|
@@ -1,61 +1,61 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@aaarc/handfree-ssh-mcp",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "Handfree SSH MCP Server - A hands-free SSH automation tool via MCP protocol",
|
|
5
|
-
"main": "build/index.js",
|
|
6
|
-
"type": "module",
|
|
7
|
-
"bin": {
|
|
8
|
-
"handfree-ssh-mcp": "build/index.js"
|
|
9
|
-
},
|
|
10
|
-
"repository": {
|
|
11
|
-
"type": "git",
|
|
12
|
-
"url": "git+https://github.com/Ironboxplus/handfree-ssh-mcp.git"
|
|
13
|
-
},
|
|
14
|
-
"bugs": {
|
|
15
|
-
"url": "https://github.com/Ironboxplus/handfree-ssh-mcp/issues"
|
|
16
|
-
},
|
|
17
|
-
"homepage": "https://github.com/Ironboxplus/handfree-ssh-mcp#readme",
|
|
18
|
-
"publishConfig": {
|
|
19
|
-
"access": "public"
|
|
20
|
-
},
|
|
21
|
-
"scripts": {
|
|
22
|
-
"test": "npm run build && node --test build/tests/config.test.js build/tests/command-validation.test.js build/tests/tool-error.test.js build/tests/tools.test.js build/tests/output-collector.test.js build/tests/output-log-writer.test.js build/tests/recent-fixes.test.js",
|
|
23
|
-
"test:config": "npm run build && node --test build/tests/config.test.js",
|
|
24
|
-
"test:cmd": "npm run build && node --test build/tests/command-validation.test.js",
|
|
25
|
-
"test:tools": "npm run build && node --test build/tests/tools.test.js",
|
|
26
|
-
"test:integration": "npm run build && node build/tests/integration.test.js",
|
|
27
|
-
"test:all": "npm run test && npm run test:integration",
|
|
28
|
-
"build": "node scripts/build.js",
|
|
29
|
-
"prepublishOnly": "npm run build"
|
|
30
|
-
},
|
|
31
|
-
"keywords": [
|
|
32
|
-
"ssh",
|
|
33
|
-
"mcp",
|
|
34
|
-
"server",
|
|
35
|
-
"cli"
|
|
36
|
-
],
|
|
37
|
-
"author": "Junki",
|
|
38
|
-
"license": "ISC",
|
|
39
|
-
"dependencies": {
|
|
40
|
-
"@modelcontextprotocol/sdk": "1.17.5",
|
|
41
|
-
"js-yaml": "^4.1.0",
|
|
42
|
-
"socks": "^2.8.6",
|
|
43
|
-
"ssh2": "^1.16.0",
|
|
44
|
-
"zod": "^3.24.2"
|
|
45
|
-
},
|
|
46
|
-
"devDependencies": {
|
|
47
|
-
"@types/js-yaml": "^4.0.9",
|
|
48
|
-
"@types/node": "^22.13.10",
|
|
49
|
-
"@types/ssh2": "^1.15.5",
|
|
50
|
-
"typescript": "^5.8.2"
|
|
51
|
-
},
|
|
52
|
-
"files": [
|
|
53
|
-
"build/config/**/*",
|
|
54
|
-
"build/core/**/*",
|
|
55
|
-
"build/models/**/*",
|
|
56
|
-
"build/services/**/*",
|
|
57
|
-
"build/tools/**/*",
|
|
58
|
-
"build/utils/**/*",
|
|
59
|
-
"build/index.js"
|
|
60
|
-
]
|
|
61
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@aaarc/handfree-ssh-mcp",
|
|
3
|
+
"version": "1.0.4",
|
|
4
|
+
"description": "Handfree SSH MCP Server - A hands-free SSH automation tool via MCP protocol",
|
|
5
|
+
"main": "build/index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"handfree-ssh-mcp": "build/index.js"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/Ironboxplus/handfree-ssh-mcp.git"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/Ironboxplus/handfree-ssh-mcp/issues"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://github.com/Ironboxplus/handfree-ssh-mcp#readme",
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "npm run build && node --test build/tests/config.test.js build/tests/command-validation.test.js build/tests/tool-error.test.js build/tests/tools.test.js build/tests/output-collector.test.js build/tests/output-log-writer.test.js build/tests/recent-fixes.test.js",
|
|
23
|
+
"test:config": "npm run build && node --test build/tests/config.test.js",
|
|
24
|
+
"test:cmd": "npm run build && node --test build/tests/command-validation.test.js",
|
|
25
|
+
"test:tools": "npm run build && node --test build/tests/tools.test.js",
|
|
26
|
+
"test:integration": "npm run build && node build/tests/integration.test.js",
|
|
27
|
+
"test:all": "npm run test && npm run test:integration",
|
|
28
|
+
"build": "node scripts/build.js",
|
|
29
|
+
"prepublishOnly": "npm run build"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"ssh",
|
|
33
|
+
"mcp",
|
|
34
|
+
"server",
|
|
35
|
+
"cli"
|
|
36
|
+
],
|
|
37
|
+
"author": "Junki",
|
|
38
|
+
"license": "ISC",
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@modelcontextprotocol/sdk": "1.17.5",
|
|
41
|
+
"js-yaml": "^4.1.0",
|
|
42
|
+
"socks": "^2.8.6",
|
|
43
|
+
"ssh2": "^1.16.0",
|
|
44
|
+
"zod": "^3.24.2"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/js-yaml": "^4.0.9",
|
|
48
|
+
"@types/node": "^22.13.10",
|
|
49
|
+
"@types/ssh2": "^1.15.5",
|
|
50
|
+
"typescript": "^5.8.2"
|
|
51
|
+
},
|
|
52
|
+
"files": [
|
|
53
|
+
"build/config/**/*",
|
|
54
|
+
"build/core/**/*",
|
|
55
|
+
"build/models/**/*",
|
|
56
|
+
"build/services/**/*",
|
|
57
|
+
"build/tools/**/*",
|
|
58
|
+
"build/utils/**/*",
|
|
59
|
+
"build/index.js"
|
|
60
|
+
]
|
|
61
|
+
}
|