@aaarc/handfree-ssh-mcp 1.0.4 → 1.0.5

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
@@ -216,7 +216,8 @@ servers:
216
216
 
217
217
  # SFTP path policy — applies ONLY to upload / download / transfer.
218
218
  # execute-command is NOT affected by these lists.
219
- # If `allowedRemoteDirectories` is unset or empty, SFTP is DISABLED for the server.
219
+ # Default is OPEN: with `allowedRemoteDirectories` unset/empty, any absolute
220
+ # POSIX remote path is allowed. Configure it to opt into an allowlist instead.
220
221
  allowedRemoteDirectories:
221
222
  - /home/user
222
223
  - /tmp
@@ -224,20 +225,23 @@ servers:
224
225
  # The MCP working directory is always permitted implicitly.
225
226
  allowedLocalDirectories:
226
227
  - /path/to/extra/local/dir
228
+ # Bypasses both the remote allowlist and the local directory check entirely.
229
+ # disableSftpPathPolicy: true
227
230
  ```
228
231
 
229
232
  ### Security note: command policy
230
233
 
231
- `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.
234
+ `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. Set `disableBuiltinGuards: true` and/or `disableBuiltinBlacklist: true` to turn off the built-in guards/blacklist for a server (your own `blacklist:`/`whitelist:` still apply).
232
235
 
233
236
  ### SFTP path policy
234
237
 
235
238
  | Field | Scope | Default behavior |
236
239
  |---|---|---|
237
- | `allowedRemoteDirectories` | `upload` / `download` / `transfer` only | **Unset = SFTP disabled.** Must list absolute POSIX directories. |
240
+ | `allowedRemoteDirectories` | `upload` / `download` / `transfer` only | **Unset/empty = open.** Any absolute POSIX path is allowed. Configuring this list opts into restricting SFTP to those directories. |
238
241
  | `allowedLocalDirectories` | `upload` / `download` only | Unset = only the MCP working directory is allowed. |
242
+ | `disableSftpPathPolicy` | all SFTP tools | When `true`, bypasses both checks above entirely — any remote path, any local path. |
239
243
 
240
- Path matching is exact-equal or `dir + separator` prefix. `..` segments and null bytes are rejected. Use `show-whitelist` to inspect a server's current SFTP policy.
244
+ Path matching (when an allowlist is configured) is exact-equal or `dir + separator` prefix. `..` segments and null bytes are always rejected regardless of policy. Use `show-whitelist` to inspect a server's current SFTP policy.
241
245
 
242
246
  ### Jump host (ProxyJump-style)
243
247
 
@@ -163,6 +163,9 @@ function buildYamlServerConfig(name, serverConfig, options = {}) {
163
163
  if (serverConfig.allowedLocalDirectories !== undefined) {
164
164
  merged.allowedLocalDirectories = normalizeAllowedLocalDirectories(name, serverConfig.allowedLocalDirectories);
165
165
  }
166
+ if (serverConfig.disableSftpPathPolicy !== undefined) {
167
+ merged.disableSftpPathPolicy = serverConfig.disableSftpPathPolicy === true;
168
+ }
166
169
  if (!allowPartial) {
167
170
  Logger.log(`Loaded server config: ${name} -> ${merged.username}@${merged.host}:${merged.port}`, "info");
168
171
  }
@@ -183,6 +186,7 @@ function mergeConfigMaps(sshConfigs, yamlConfigs) {
183
186
  commandBlacklist: yamlConfig.commandBlacklist ?? merged[name]?.commandBlacklist,
184
187
  disableBuiltinGuards: yamlConfig.disableBuiltinGuards ?? merged[name]?.disableBuiltinGuards,
185
188
  disableBuiltinBlacklist: yamlConfig.disableBuiltinBlacklist ?? merged[name]?.disableBuiltinBlacklist,
189
+ disableSftpPathPolicy: yamlConfig.disableSftpPathPolicy ?? merged[name]?.disableSftpPathPolicy,
186
190
  safeDirectory: yamlConfig.safeDirectory ?? merged[name]?.safeDirectory,
187
191
  };
188
192
  }
@@ -1,6 +1,6 @@
1
1
  export const SERVER_CONFIG = {
2
2
  name: "ssh-mcp-server",
3
- version: "1.0.4",
3
+ version: "1.0.5",
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
 
@@ -1204,14 +1204,16 @@ export class SSHConnectionManager {
1204
1204
  */
1205
1205
  validateLocalPath(localPath, name) {
1206
1206
  const resolvedPath = path.resolve(localPath);
1207
+ const config = name ? this.getServerConfig(name) : undefined;
1208
+ // disableSftpPathPolicy fully opens the local side too (any path allowed).
1209
+ if (config?.disableSftpPathPolicy) {
1210
+ return resolvedPath;
1211
+ }
1207
1212
  const allowedRoots = new Set([process.cwd()]);
1208
1213
  // Add per-server allowedLocalDirectories if a server is targeted
1209
- if (name) {
1210
- const config = this.getServerConfig(name);
1211
- if (config?.allowedLocalDirectories) {
1212
- for (const dir of config.allowedLocalDirectories) {
1213
- allowedRoots.add(dir);
1214
- }
1214
+ if (config?.allowedLocalDirectories) {
1215
+ for (const dir of config.allowedLocalDirectories) {
1216
+ allowedRoots.add(dir);
1215
1217
  }
1216
1218
  }
1217
1219
  const isAllowed = Array.from(allowedRoots).some((root) => resolvedPath === root || resolvedPath.startsWith(root + path.sep));
@@ -1249,9 +1251,11 @@ export class SSHConnectionManager {
1249
1251
  const normalized = path.posix.normalize(remotePath);
1250
1252
  const config = this.getConfig(name);
1251
1253
  const allowedRoots = config.allowedRemoteDirectories ?? [];
1252
- if (allowedRoots.length === 0) {
1253
- throw new ToolError("REMOTE_PATH_NOT_ALLOWED", `SFTP is disabled for server '${name}': no 'allowedRemoteDirectories' configured. ` +
1254
- `Add at least one absolute POSIX directory to 'allowedRemoteDirectories' in the YAML config to permit upload/download.`, false);
1254
+ // Default is OPEN: with no 'allowedRemoteDirectories' configured (or
1255
+ // disableSftpPathPolicy set), any absolute POSIX path is allowed. Configure
1256
+ // 'allowedRemoteDirectories' to opt into an allowlist for this server.
1257
+ if (config.disableSftpPathPolicy || allowedRoots.length === 0) {
1258
+ return normalized;
1255
1259
  }
1256
1260
  const isAllowed = allowedRoots.some((root) => normalized === root || normalized.startsWith(root === "/" ? "/" : root + "/"));
1257
1261
  if (!isAllowed) {
@@ -7,8 +7,8 @@ import { formatToolErrorResponse, toToolError } from "../utils/tool-error.js";
7
7
  */
8
8
  export function registerDownloadTool(server) {
9
9
  const sshManager = SSHConnectionManager.getInstance();
10
- server.tool("download", "Download a file from the remote server to the MCP host over SFTP. Use this when you need to inspect or save a remote file locally. The remote source must live inside one of the server's allowedRemoteDirectories; call show-whitelist to discover the allowed paths.", {
11
- remotePath: z.string().describe("Path to the file on the remote server. Must be an absolute POSIX path inside one of the server's allowedRemoteDirectories (e.g. /var/log/app.log)."),
10
+ server.tool("download", "Download a file from the remote server to the MCP host over SFTP. Use this when you need to inspect or save a remote file locally. By default any absolute remote path is allowed; if the server configures allowedRemoteDirectories, the source must live inside one of those entries call show-whitelist to check.", {
11
+ remotePath: z.string().describe("Path to the file on the remote server. Must be an absolute POSIX path (e.g. /var/log/app.log); restricted to allowedRemoteDirectories only if the server configures that list."),
12
12
  localPath: z.string().describe("Destination path on the MCP host. Must be inside the MCP working directory or one of the server's allowedLocalDirectories."),
13
13
  connectionName: z.string().optional().describe("Target server name from list-servers. Required when multiple servers are enabled; optional when only one server is enabled."),
14
14
  }, async ({ remotePath, localPath, connectionName }) => {
@@ -100,15 +100,18 @@ export function registerShowWhitelistTool(server) {
100
100
  const allowedLocalDirs = config.allowedLocalDirectories ?? [];
101
101
  const mcpCwd = process.cwd();
102
102
  output += `## 📂 SFTP Path Policy (upload / download / transfer)\n\n`;
103
+ if (config.disableSftpPathPolicy) {
104
+ output += `**\`disableSftpPathPolicy\` is set — all path containment checks below are bypassed.** ` +
105
+ `Any absolute remote path and any local path is allowed for upload/download/transfer.\n\n`;
106
+ }
103
107
  // ----- Remote directories -----
104
108
  output += `### Allowed remote directories\n\n`;
105
- if (allowedRemoteDirs.length === 0) {
106
- output += `⚠️ **\`allowedRemoteDirectories\` is NOT configured.** ` +
107
- `SFTP upload/download/transfer is **disabled** for this server every call will be rejected with \`REMOTE_PATH_NOT_ALLOWED\`. ` +
108
- `To enable file transfer, add absolute POSIX directories under \`allowedRemoteDirectories\` in the server's YAML config.\n\n`;
109
+ if (config.disableSftpPathPolicy || allowedRemoteDirs.length === 0) {
110
+ output += `\`allowedRemoteDirectories\` is not configured, so **any absolute POSIX path is allowed** (default is open). ` +
111
+ `To restrict SFTP to specific directories, add them under \`allowedRemoteDirectories\` in the server's YAML config.\n\n`;
109
112
  }
110
113
  else {
111
- output += `${allowedRemoteDirs.length} entr${allowedRemoteDirs.length === 1 ? "y" : "ies"}:\n\n`;
114
+ output += `${allowedRemoteDirs.length} entr${allowedRemoteDirs.length === 1 ? "y" : "ies"} (restricting SFTP to these paths only):\n\n`;
112
115
  for (const dir of allowedRemoteDirs) {
113
116
  output += `- \`${dir}\`\n`;
114
117
  }
@@ -116,24 +119,31 @@ export function registerShowWhitelistTool(server) {
116
119
  }
117
120
  // ----- Local directories -----
118
121
  output += `### Allowed local directories\n\n`;
119
- output += `Always implicitly allowed: \`${mcpCwd}\` _(the MCP working directory)_\n\n`;
120
- if (allowedLocalDirs.length === 0) {
121
- output += `⚠️ **\`allowedLocalDirectories\` is NOT configured.** ` +
122
- `Only paths inside the MCP working directory above can be used as the local side of upload/download — any other path will be rejected with \`LOCAL_PATH_NOT_ALLOWED\`. ` +
123
- `To allow more locations, add absolute host paths under \`allowedLocalDirectories\` in the server's YAML config.\n\n`;
122
+ if (config.disableSftpPathPolicy) {
123
+ output += `\`disableSftpPathPolicy\` is set, so **any local path is allowed**.\n\n`;
124
124
  }
125
125
  else {
126
- output += `Additional entries (${allowedLocalDirs.length}):\n\n`;
127
- for (const dir of allowedLocalDirs) {
128
- output += `- \`${dir}\`\n`;
126
+ output += `Always implicitly allowed: \`${mcpCwd}\` _(the MCP working directory)_\n\n`;
127
+ if (allowedLocalDirs.length === 0) {
128
+ output += `\`allowedLocalDirectories\` is not configured. ` +
129
+ `Only paths inside the MCP working directory above can be used as the local side of upload/download — any other path will be rejected with \`LOCAL_PATH_NOT_ALLOWED\`. ` +
130
+ `To allow more locations, add absolute host paths under \`allowedLocalDirectories\`, or set \`disableSftpPathPolicy: true\` to allow any local path.\n\n`;
131
+ }
132
+ else {
133
+ output += `Additional entries (${allowedLocalDirs.length}):\n\n`;
134
+ for (const dir of allowedLocalDirs) {
135
+ output += `- \`${dir}\`\n`;
136
+ }
137
+ output += `\n`;
129
138
  }
130
- output += `\n`;
131
139
  }
132
140
  // ----- Matching rules -----
133
141
  output += `### Matching rules\n\n`;
134
- output += `- A path is allowed iff it equals an entry above, or starts with \`<entry> + separator\`.\n`;
135
- output += `- Remote paths must be absolute POSIX (\`/...\`); \`..\` segments and null bytes are rejected up-front.\n`;
136
- output += `- Local paths are resolved on the MCP host first, then matched.\n`;
142
+ output += `- Default is open: with no \`allowedRemoteDirectories\` configured, any absolute POSIX remote path is allowed.\n`;
143
+ output += `- Configuring \`allowedRemoteDirectories\` opts into an allowlist a path is then allowed iff it equals an entry, or starts with \`<entry> + separator\`.\n`;
144
+ output += `- \`disableSftpPathPolicy: true\` bypasses both the remote allowlist and the local directory check entirely.\n`;
145
+ output += `- Remote paths must be absolute POSIX (\`/...\`); \`..\` segments and null bytes are rejected up-front regardless of policy.\n`;
146
+ output += `- Local paths are resolved on the MCP host first, then matched (unless \`disableSftpPathPolicy\` is set).\n`;
137
147
  output += `- These path lists do **not** affect \`execute-command\`. They only restrict SFTP file transfers.\n\n`;
138
148
  // ----- Output log policy (execute-command full-output persistence) -----
139
149
  const outputLogRoot = sshManager.getOutputLogRoot();
@@ -26,12 +26,12 @@ Set recursive=true when transferring a directory (upload/download only).
26
26
  For relay mode, specify sourceServer, sourceRemotePath, destServer, destRemotePath.`, {
27
27
  mode: z.enum(["upload", "download", "relay"]).describe("'upload' pushes local → remote. 'download' pulls remote → local. 'relay' copies remote-A → remote-B through the MCP host."),
28
28
  localPath: z.string().optional().describe("(upload/download only) Path on the MCP host. Must be inside the MCP working directory or one of the server's allowedLocalDirectories."),
29
- remotePath: z.string().optional().describe("(upload/download only) Absolute POSIX path on the remote server. Must live inside one of the server's allowedRemoteDirectories — call show-whitelist to discover the allowed paths."),
29
+ remotePath: z.string().optional().describe("(upload/download only) Absolute POSIX path on the remote server. Any path is allowed by default; restricted to allowedRemoteDirectories only if the server configures that list — call show-whitelist to check."),
30
30
  connectionName: z.string().optional().describe("(upload/download only) Target server name from list-servers. Required when multiple servers are enabled."),
31
31
  sourceServer: z.string().optional().describe("(relay only) Server name to download the file from."),
32
- sourceRemotePath: z.string().optional().describe("(relay only) Absolute POSIX file path on the source server. Must live inside the source server's allowedRemoteDirectories."),
32
+ sourceRemotePath: z.string().optional().describe("(relay only) Absolute POSIX file path on the source server. Any path is allowed by default unless the source server configures allowedRemoteDirectories."),
33
33
  destServer: z.string().optional().describe("(relay only) Server name to upload the file to."),
34
- destRemotePath: z.string().optional().describe("(relay only) Absolute POSIX destination path on the target server. Must live inside the destination server's allowedRemoteDirectories."),
34
+ destRemotePath: z.string().optional().describe("(relay only) Absolute POSIX destination path on the target server. Any path is allowed by default unless the destination server configures allowedRemoteDirectories."),
35
35
  recursive: z.boolean().optional().describe("(upload/download only) When true, transfers an entire directory tree recursively. Default false."),
36
36
  skipIfIdentical: z.boolean().optional().describe("When true (default), skip the transfer if the destination already matches the source. " +
37
37
  "Upload: byte-compare for files \u2264 256 MiB, MD5 otherwise; shell scripts (.sh / .bash / .zsh) ignore CRLF\u2194LF differences. " +
@@ -7,11 +7,11 @@ import { formatToolErrorResponse, toToolError } from "../utils/tool-error.js";
7
7
  */
8
8
  export function registerUploadTool(server) {
9
9
  const sshManager = SSHConnectionManager.getInstance();
10
- server.tool("upload", "Upload a local file from the MCP host to the remote server over SFTP. Use this when the file already exists locally and must be copied to the selected SSH server. The remote destination must live inside one of the server's allowedRemoteDirectories; call show-whitelist to discover the allowed paths. " +
10
+ server.tool("upload", "Upload a local file from the MCP host to the remote server over SFTP. Use this when the file already exists locally and must be copied to the selected SSH server. By default any absolute remote path is allowed; if the server configures allowedRemoteDirectories, the destination must live inside one of those entries call show-whitelist to check. " +
11
11
  "Shell scripts (.sh / .bash / .zsh) with CRLF line endings are auto-converted to LF before upload (the response notes when this happens). " +
12
12
  "By default the upload is skipped if the remote file is already identical to the local one (byte-compare for files \u2264 256 MiB, MD5 otherwise; shell scripts are compared in a line-ending-agnostic way so CRLF\u2194LF differences alone do not trigger a re-upload) \u2014 pass skipIfIdentical=false to force a re-upload.", {
13
13
  localPath: z.string().describe("Path to a local file on the MCP host. Must be inside the MCP working directory or one of the server's allowedLocalDirectories."),
14
- remotePath: z.string().describe("Destination path on the remote server. Must be an absolute POSIX path inside one of the server's allowedRemoteDirectories (e.g. /home/user/uploads/file.txt)."),
14
+ remotePath: z.string().describe("Destination path on the remote server. Must be an absolute POSIX path (e.g. /home/user/uploads/file.txt); restricted to allowedRemoteDirectories only if the server configures that list."),
15
15
  connectionName: z.string().optional().describe("Target server name from list-servers. Required when multiple servers are enabled; optional when only one server is enabled."),
16
16
  skipIfIdentical: z.boolean().optional().describe("When true (default), skip the upload if the remote file is already identical (byte-compare for files \u2264 256 MiB, MD5 otherwise; shell scripts ignore CRLF\u2194LF differences). Set to false to force re-upload."),
17
17
  }, async ({ localPath, remotePath, connectionName, skipIfIdentical }) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaarc/handfree-ssh-mcp",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "Handfree SSH MCP Server - A hands-free SSH automation tool via MCP protocol",
5
5
  "main": "build/index.js",
6
6
  "type": "module",