@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 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, not policy-level. Editing it in `servers.yaml` while the server is running will NOT take effect the hot-reloader only refreshes whitelist / blacklist / `safeDirectory` / `allowed*Directories`. Restart the MCP server to pick up `jumpHost` changes.
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
  }
@@ -1,6 +1,6 @@
1
1
  export const SERVER_CONFIG = {
2
2
  name: "ssh-mcp-server",
3
- version: "1.0.2",
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
- const changedConnections = [];
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
- this.closeClient(name, true);
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
- `${changedConnections.length} connection(s) reset`, "info");
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 = this.getDestructiveMatch(command);
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
- 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
- };
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.2",
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
+ }