@aaarc/handfree-ssh-mcp 1.0.0 → 1.0.2
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 +166 -133
- package/build/config/config-loader.js +60 -0
- package/build/config/server.js +13 -13
- package/build/config/ssh-config-loader.js +45 -2
- package/build/core/mcp-server.js +0 -5
- package/build/services/ssh-connection-manager.js +274 -164
- package/build/tools/execute-command.js +1 -1
- package/build/tools/help.js +4 -4
- package/build/tools/list-servers.js +6 -4
- package/build/tools/show-whitelist.js +67 -42
- package/package.json +20 -11
- 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
|
@@ -1,617 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* handfree-ssh-mcp Test Suite
|
|
3
|
-
*
|
|
4
|
-
* Run: npm test
|
|
5
|
-
*
|
|
6
|
-
* Tests config loading, argument parsing, and tool registration
|
|
7
|
-
* without needing an MCP client.
|
|
8
|
-
*/
|
|
9
|
-
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
10
|
-
import assert from "node:assert";
|
|
11
|
-
import fs from "node:fs";
|
|
12
|
-
import path from "node:path";
|
|
13
|
-
import os from "node:os";
|
|
14
|
-
// Import modules to test
|
|
15
|
-
import { getConfigPath, getEnabledServersArg, getLoadUserSshConfigFlag, getNoSshConfigFlag, getSshConfigPathsArg, loadConfigFromSources, loadConfigFromYaml, } from "../config/config-loader.js";
|
|
16
|
-
import { loadSshConfigFiles } from "../config/ssh-config-loader.js";
|
|
17
|
-
describe("Config Loader", () => {
|
|
18
|
-
let tempDir;
|
|
19
|
-
let tempConfigPath;
|
|
20
|
-
beforeEach(() => {
|
|
21
|
-
// Create temp directory for test configs
|
|
22
|
-
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "handfree-ssh-mcp-test-"));
|
|
23
|
-
});
|
|
24
|
-
afterEach(() => {
|
|
25
|
-
// Cleanup temp files
|
|
26
|
-
if (fs.existsSync(tempDir)) {
|
|
27
|
-
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
28
|
-
}
|
|
29
|
-
});
|
|
30
|
-
it("should load valid YAML config", () => {
|
|
31
|
-
const configContent = `
|
|
32
|
-
defaultServer: dev
|
|
33
|
-
servers:
|
|
34
|
-
dev:
|
|
35
|
-
host: 192.168.1.1
|
|
36
|
-
port: 22
|
|
37
|
-
username: testuser
|
|
38
|
-
password: testpass
|
|
39
|
-
whitelist:
|
|
40
|
-
- "^ls.*$"
|
|
41
|
-
- "^pwd$"
|
|
42
|
-
`;
|
|
43
|
-
tempConfigPath = path.join(tempDir, "servers.yaml");
|
|
44
|
-
fs.writeFileSync(tempConfigPath, configContent);
|
|
45
|
-
const result = loadConfigFromYaml(tempConfigPath);
|
|
46
|
-
assert.ok(result.configs);
|
|
47
|
-
assert.ok(result.configs["dev"]);
|
|
48
|
-
assert.strictEqual(result.configs["dev"].host, "192.168.1.1");
|
|
49
|
-
assert.strictEqual(result.configs["dev"].port, 22);
|
|
50
|
-
assert.strictEqual(result.configs["dev"].username, "testuser");
|
|
51
|
-
assert.strictEqual(result.configs["dev"].password, "testpass");
|
|
52
|
-
assert.deepStrictEqual(result.configs["dev"].commandWhitelist, ["^ls.*$", "^pwd$"]);
|
|
53
|
-
});
|
|
54
|
-
it("should load multiple servers", () => {
|
|
55
|
-
const configContent = `
|
|
56
|
-
servers:
|
|
57
|
-
dev:
|
|
58
|
-
host: 10.0.0.1
|
|
59
|
-
username: dev
|
|
60
|
-
password: devpass
|
|
61
|
-
prod:
|
|
62
|
-
host: 10.0.0.2
|
|
63
|
-
username: prod
|
|
64
|
-
password: prodpass
|
|
65
|
-
staging:
|
|
66
|
-
host: 10.0.0.3
|
|
67
|
-
username: staging
|
|
68
|
-
privateKey: ~/.ssh/id_rsa
|
|
69
|
-
`;
|
|
70
|
-
tempConfigPath = path.join(tempDir, "multi.yaml");
|
|
71
|
-
fs.writeFileSync(tempConfigPath, configContent);
|
|
72
|
-
const result = loadConfigFromYaml(tempConfigPath);
|
|
73
|
-
assert.strictEqual(Object.keys(result.configs).length, 3);
|
|
74
|
-
assert.ok(result.configs["dev"]);
|
|
75
|
-
assert.ok(result.configs["prod"]);
|
|
76
|
-
assert.ok(result.configs["staging"]);
|
|
77
|
-
});
|
|
78
|
-
it("should throw on missing host", () => {
|
|
79
|
-
const configContent = `
|
|
80
|
-
servers:
|
|
81
|
-
broken:
|
|
82
|
-
username: test
|
|
83
|
-
password: test
|
|
84
|
-
`;
|
|
85
|
-
tempConfigPath = path.join(tempDir, "broken.yaml");
|
|
86
|
-
fs.writeFileSync(tempConfigPath, configContent);
|
|
87
|
-
assert.throws(() => loadConfigFromYaml(tempConfigPath), /host.*required/i);
|
|
88
|
-
});
|
|
89
|
-
it("should throw on missing username", () => {
|
|
90
|
-
const configContent = `
|
|
91
|
-
servers:
|
|
92
|
-
broken:
|
|
93
|
-
host: 192.168.1.1
|
|
94
|
-
password: test
|
|
95
|
-
`;
|
|
96
|
-
tempConfigPath = path.join(tempDir, "broken.yaml");
|
|
97
|
-
fs.writeFileSync(tempConfigPath, configContent);
|
|
98
|
-
assert.throws(() => loadConfigFromYaml(tempConfigPath), /username.*required/i);
|
|
99
|
-
});
|
|
100
|
-
it("should throw on missing auth (no password or privateKey)", () => {
|
|
101
|
-
const configContent = `
|
|
102
|
-
servers:
|
|
103
|
-
broken:
|
|
104
|
-
host: 192.168.1.1
|
|
105
|
-
username: test
|
|
106
|
-
`;
|
|
107
|
-
tempConfigPath = path.join(tempDir, "broken.yaml");
|
|
108
|
-
fs.writeFileSync(tempConfigPath, configContent);
|
|
109
|
-
assert.throws(() => loadConfigFromYaml(tempConfigPath), /password.*privateKey.*required/i);
|
|
110
|
-
});
|
|
111
|
-
it("should expand ~ in privateKey path", () => {
|
|
112
|
-
const configContent = `
|
|
113
|
-
servers:
|
|
114
|
-
dev:
|
|
115
|
-
host: 192.168.1.1
|
|
116
|
-
username: test
|
|
117
|
-
privateKey: ~/.ssh/id_rsa
|
|
118
|
-
`;
|
|
119
|
-
tempConfigPath = path.join(tempDir, "key.yaml");
|
|
120
|
-
fs.writeFileSync(tempConfigPath, configContent);
|
|
121
|
-
const result = loadConfigFromYaml(tempConfigPath);
|
|
122
|
-
assert.ok(result.configs["dev"].privateKey);
|
|
123
|
-
assert.ok(!result.configs["dev"].privateKey.startsWith("~"));
|
|
124
|
-
assert.ok(result.configs["dev"].privateKey.includes(os.homedir()));
|
|
125
|
-
});
|
|
126
|
-
it("should handle preConnect flag", () => {
|
|
127
|
-
const configContent = `
|
|
128
|
-
preConnect: true
|
|
129
|
-
servers:
|
|
130
|
-
dev:
|
|
131
|
-
host: 192.168.1.1
|
|
132
|
-
username: test
|
|
133
|
-
password: test
|
|
134
|
-
`;
|
|
135
|
-
tempConfigPath = path.join(tempDir, "preconnect.yaml");
|
|
136
|
-
fs.writeFileSync(tempConfigPath, configContent);
|
|
137
|
-
const result = loadConfigFromYaml(tempConfigPath);
|
|
138
|
-
assert.strictEqual(result.preConnect, true);
|
|
139
|
-
});
|
|
140
|
-
it("should throw on non-existent config file", () => {
|
|
141
|
-
assert.throws(() => loadConfigFromYaml("/nonexistent/path/servers.yaml"), /not found/i);
|
|
142
|
-
});
|
|
143
|
-
it("should throw on invalid YAML", () => {
|
|
144
|
-
const configContent = `
|
|
145
|
-
servers:
|
|
146
|
-
dev:
|
|
147
|
-
host: 192.168.1.1
|
|
148
|
-
username: test
|
|
149
|
-
password: test
|
|
150
|
-
invalid yaml here [[[
|
|
151
|
-
`;
|
|
152
|
-
tempConfigPath = path.join(tempDir, "invalid.yaml");
|
|
153
|
-
fs.writeFileSync(tempConfigPath, configContent);
|
|
154
|
-
assert.throws(() => loadConfigFromYaml(tempConfigPath), /parse|yaml/i);
|
|
155
|
-
});
|
|
156
|
-
});
|
|
157
|
-
describe("Argument Parsing", () => {
|
|
158
|
-
it("should extract --config path", () => {
|
|
159
|
-
const args = ["--config", "/path/to/servers.yaml", "--enable-servers", "dev"];
|
|
160
|
-
const configPath = getConfigPath(args);
|
|
161
|
-
assert.strictEqual(configPath, "/path/to/servers.yaml");
|
|
162
|
-
});
|
|
163
|
-
it("should return null if --config not provided", () => {
|
|
164
|
-
const args = ["--enable-servers", "dev"];
|
|
165
|
-
const configPath = getConfigPath(args);
|
|
166
|
-
assert.strictEqual(configPath, null);
|
|
167
|
-
});
|
|
168
|
-
it("should extract --enable-servers list", () => {
|
|
169
|
-
const args = ["--config", "test.yaml", "--enable-servers", "dev,prod,staging"];
|
|
170
|
-
const servers = getEnabledServersArg(args);
|
|
171
|
-
assert.deepStrictEqual(servers, ["dev", "prod", "staging"]);
|
|
172
|
-
});
|
|
173
|
-
it("should return null if --enable-servers not provided", () => {
|
|
174
|
-
const args = ["--config", "test.yaml"];
|
|
175
|
-
const servers = getEnabledServersArg(args);
|
|
176
|
-
assert.strictEqual(servers, null);
|
|
177
|
-
});
|
|
178
|
-
it("should handle single server in --enable-servers", () => {
|
|
179
|
-
const args = ["--enable-servers", "dev"];
|
|
180
|
-
const servers = getEnabledServersArg(args);
|
|
181
|
-
assert.deepStrictEqual(servers, ["dev"]);
|
|
182
|
-
});
|
|
183
|
-
it("should trim whitespace from server names", () => {
|
|
184
|
-
const args = ["--enable-servers", "dev , prod , staging"];
|
|
185
|
-
const servers = getEnabledServersArg(args);
|
|
186
|
-
assert.deepStrictEqual(servers, ["dev", "prod", "staging"]);
|
|
187
|
-
});
|
|
188
|
-
it("should extract repeated --ssh-config paths", () => {
|
|
189
|
-
const args = ["--ssh-config", "a,b", "--config", "servers.yaml", "--ssh-config", "c"];
|
|
190
|
-
assert.deepStrictEqual(getSshConfigPathsArg(args), ["a", "b", "c"]);
|
|
191
|
-
});
|
|
192
|
-
it("should detect --no-ssh-config", () => {
|
|
193
|
-
assert.strictEqual(getNoSshConfigFlag(["--config", "servers.yaml", "--no-ssh-config"]), true);
|
|
194
|
-
assert.strictEqual(getNoSshConfigFlag(["--config", "servers.yaml"]), false);
|
|
195
|
-
});
|
|
196
|
-
it("should leave OpenSSH config loading to YAML unless CLI disables it", () => {
|
|
197
|
-
assert.strictEqual(getLoadUserSshConfigFlag(["--config", "servers.yaml"]), undefined);
|
|
198
|
-
assert.strictEqual(getLoadUserSshConfigFlag(["--config", "servers.yaml", "--no-ssh-config"]), false);
|
|
199
|
-
});
|
|
200
|
-
});
|
|
201
|
-
describe("OpenSSH Config Loading", () => {
|
|
202
|
-
let tempDir;
|
|
203
|
-
beforeEach(() => {
|
|
204
|
-
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "handfree-ssh-mcp-test-"));
|
|
205
|
-
});
|
|
206
|
-
afterEach(() => {
|
|
207
|
-
if (fs.existsSync(tempDir)) {
|
|
208
|
-
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
209
|
-
}
|
|
210
|
-
});
|
|
211
|
-
it("should load concrete Host entries from an OpenSSH config", () => {
|
|
212
|
-
const keyPath = path.join(tempDir, "id_ed25519");
|
|
213
|
-
const sshConfigPath = path.join(tempDir, "ssh_config");
|
|
214
|
-
fs.writeFileSync(keyPath, "fake-key");
|
|
215
|
-
fs.writeFileSync(sshConfigPath, `
|
|
216
|
-
Host dev
|
|
217
|
-
HostName dev.example.com
|
|
218
|
-
User deploy
|
|
219
|
-
Port 2202
|
|
220
|
-
IdentityFile "${keyPath}"
|
|
221
|
-
|
|
222
|
-
Host *
|
|
223
|
-
User fallback
|
|
224
|
-
`);
|
|
225
|
-
const result = loadSshConfigFiles([sshConfigPath]);
|
|
226
|
-
assert.ok(result.files.includes(fs.realpathSync(sshConfigPath)));
|
|
227
|
-
assert.strictEqual(result.configs.dev.host, "dev.example.com");
|
|
228
|
-
assert.strictEqual(result.configs.dev.username, "deploy");
|
|
229
|
-
assert.strictEqual(result.configs.dev.port, 2202);
|
|
230
|
-
assert.strictEqual(result.configs.dev.privateKey, path.normalize(keyPath));
|
|
231
|
-
assert.strictEqual(result.configs.dev.authOptional, true);
|
|
232
|
-
});
|
|
233
|
-
it("should apply wildcard defaults and token replacement", () => {
|
|
234
|
-
const sshConfigPath = path.join(tempDir, "ssh_config");
|
|
235
|
-
fs.writeFileSync(sshConfigPath, `
|
|
236
|
-
Host app
|
|
237
|
-
HostName %n.internal
|
|
238
|
-
|
|
239
|
-
Host *
|
|
240
|
-
User ops
|
|
241
|
-
Port 2222
|
|
242
|
-
IdentityFile ~/.ssh/%r-%n
|
|
243
|
-
`);
|
|
244
|
-
const result = loadSshConfigFiles([sshConfigPath]);
|
|
245
|
-
assert.strictEqual(result.configs.app.host, "app.internal");
|
|
246
|
-
assert.strictEqual(result.configs.app.username, "ops");
|
|
247
|
-
assert.strictEqual(result.configs.app.port, 2222);
|
|
248
|
-
assert.strictEqual(result.configs.app.privateKey, path.normalize(path.join(os.homedir(), ".ssh", "ops-app")));
|
|
249
|
-
});
|
|
250
|
-
it("should parse OpenSSH keyword=value syntax", () => {
|
|
251
|
-
const keyPath = path.join(tempDir, "id_ed25519");
|
|
252
|
-
const sshConfigPath = path.join(tempDir, "ssh_config");
|
|
253
|
-
fs.writeFileSync(keyPath, "fake-key");
|
|
254
|
-
fs.writeFileSync(sshConfigPath, `
|
|
255
|
-
Host eq
|
|
256
|
-
HostName=eq.example.com
|
|
257
|
-
User = deploy
|
|
258
|
-
Port=2202
|
|
259
|
-
IdentityFile = "${keyPath}"
|
|
260
|
-
`);
|
|
261
|
-
const result = loadSshConfigFiles([sshConfigPath]);
|
|
262
|
-
assert.strictEqual(result.configs.eq.host, "eq.example.com");
|
|
263
|
-
assert.strictEqual(result.configs.eq.username, "deploy");
|
|
264
|
-
assert.strictEqual(result.configs.eq.port, 2202);
|
|
265
|
-
assert.strictEqual(result.configs.eq.privateKey, path.normalize(keyPath));
|
|
266
|
-
});
|
|
267
|
-
it("should respect OpenSSH disabled identity and agent settings", () => {
|
|
268
|
-
const sshConfigPath = path.join(tempDir, "ssh_config");
|
|
269
|
-
fs.writeFileSync(sshConfigPath, `
|
|
270
|
-
Host locked
|
|
271
|
-
HostName locked.example.com
|
|
272
|
-
User deploy
|
|
273
|
-
IdentityFile none
|
|
274
|
-
IdentityAgent none
|
|
275
|
-
IdentitiesOnly yes
|
|
276
|
-
`);
|
|
277
|
-
const result = loadSshConfigFiles([sshConfigPath]);
|
|
278
|
-
assert.strictEqual(result.configs.locked.privateKey, undefined);
|
|
279
|
-
assert.strictEqual(result.configs.locked.agent, false);
|
|
280
|
-
assert.strictEqual(result.configs.locked.identitiesOnly, true);
|
|
281
|
-
});
|
|
282
|
-
it("should load Include files", () => {
|
|
283
|
-
const includedPath = path.join(tempDir, "included.conf");
|
|
284
|
-
const sshConfigPath = path.join(tempDir, "ssh_config");
|
|
285
|
-
fs.writeFileSync(includedPath, `
|
|
286
|
-
Host inc
|
|
287
|
-
HostName inc.example.com
|
|
288
|
-
User included
|
|
289
|
-
`);
|
|
290
|
-
fs.writeFileSync(sshConfigPath, `Include "${includedPath}"`);
|
|
291
|
-
const result = loadSshConfigFiles([sshConfigPath]);
|
|
292
|
-
assert.strictEqual(result.configs.inc.host, "inc.example.com");
|
|
293
|
-
assert.strictEqual(result.configs.inc.username, "included");
|
|
294
|
-
assert.ok(result.files.includes(fs.realpathSync(includedPath)));
|
|
295
|
-
});
|
|
296
|
-
it("should merge YAML server settings over OpenSSH config entries", () => {
|
|
297
|
-
const sshConfigPath = path.join(tempDir, "ssh_config");
|
|
298
|
-
const yamlPath = path.join(tempDir, "servers.yaml");
|
|
299
|
-
fs.writeFileSync(sshConfigPath, `
|
|
300
|
-
Host dev
|
|
301
|
-
HostName ssh.example.com
|
|
302
|
-
User sshuser
|
|
303
|
-
Port 22
|
|
304
|
-
`);
|
|
305
|
-
fs.writeFileSync(yamlPath, `
|
|
306
|
-
servers:
|
|
307
|
-
dev:
|
|
308
|
-
host: yaml.example.com
|
|
309
|
-
whitelist:
|
|
310
|
-
- "^pwd$"
|
|
311
|
-
allowedRemoteDirectories: []
|
|
312
|
-
`);
|
|
313
|
-
const result = loadConfigFromSources({
|
|
314
|
-
yamlConfigPath: yamlPath,
|
|
315
|
-
sshConfigPaths: [sshConfigPath],
|
|
316
|
-
});
|
|
317
|
-
assert.strictEqual(result.configs.dev.host, "yaml.example.com");
|
|
318
|
-
assert.strictEqual(result.configs.dev.username, "sshuser");
|
|
319
|
-
assert.deepStrictEqual(result.configs.dev.commandWhitelist, ["^pwd$"]);
|
|
320
|
-
assert.deepStrictEqual(result.configs.dev.allowedRemoteDirectories, []);
|
|
321
|
-
});
|
|
322
|
-
it("should still reject YAML-only servers without auth", () => {
|
|
323
|
-
const yamlPath = path.join(tempDir, "servers.yaml");
|
|
324
|
-
fs.writeFileSync(yamlPath, `
|
|
325
|
-
sshConfig: false
|
|
326
|
-
servers:
|
|
327
|
-
broken:
|
|
328
|
-
host: 192.168.1.1
|
|
329
|
-
username: test
|
|
330
|
-
`);
|
|
331
|
-
assert.throws(() => loadConfigFromYaml(yamlPath), /password.*privateKey.*required/i);
|
|
332
|
-
});
|
|
333
|
-
it("should reject partial YAML servers without a matching OpenSSH host", () => {
|
|
334
|
-
const yamlPath = path.join(tempDir, "servers.yaml");
|
|
335
|
-
fs.writeFileSync(yamlPath, `
|
|
336
|
-
sshConfig: false
|
|
337
|
-
servers:
|
|
338
|
-
missing-base:
|
|
339
|
-
whitelist:
|
|
340
|
-
- "^pwd$"
|
|
341
|
-
`);
|
|
342
|
-
assert.throws(() => loadConfigFromSources({ yamlConfigPath: yamlPath }), /host.*required/i);
|
|
343
|
-
});
|
|
344
|
-
it("should let explicit --ssh-config paths override YAML sshConfig false", () => {
|
|
345
|
-
const sshConfigPath = path.join(tempDir, "ssh_config");
|
|
346
|
-
const yamlPath = path.join(tempDir, "servers.yaml");
|
|
347
|
-
fs.writeFileSync(sshConfigPath, `
|
|
348
|
-
Host explicit
|
|
349
|
-
HostName explicit.example.com
|
|
350
|
-
User deploy
|
|
351
|
-
`);
|
|
352
|
-
fs.writeFileSync(yamlPath, `
|
|
353
|
-
sshConfig: false
|
|
354
|
-
`);
|
|
355
|
-
const result = loadConfigFromSources({
|
|
356
|
-
yamlConfigPath: yamlPath,
|
|
357
|
-
sshConfigPaths: [sshConfigPath],
|
|
358
|
-
});
|
|
359
|
-
assert.strictEqual(result.configs.explicit.host, "explicit.example.com");
|
|
360
|
-
});
|
|
361
|
-
});
|
|
362
|
-
describe("Whitelist/Blacklist Config", () => {
|
|
363
|
-
let tempDir;
|
|
364
|
-
beforeEach(() => {
|
|
365
|
-
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "handfree-ssh-mcp-test-"));
|
|
366
|
-
});
|
|
367
|
-
afterEach(() => {
|
|
368
|
-
if (fs.existsSync(tempDir)) {
|
|
369
|
-
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
370
|
-
}
|
|
371
|
-
});
|
|
372
|
-
it("should load whitelist patterns", () => {
|
|
373
|
-
const configContent = `
|
|
374
|
-
servers:
|
|
375
|
-
dev:
|
|
376
|
-
host: 192.168.1.1
|
|
377
|
-
username: test
|
|
378
|
-
password: test
|
|
379
|
-
whitelist:
|
|
380
|
-
- "^ls( .*)?$"
|
|
381
|
-
- "^cat .*$"
|
|
382
|
-
- "^docker ps.*$"
|
|
383
|
-
`;
|
|
384
|
-
const tempConfigPath = path.join(tempDir, "whitelist.yaml");
|
|
385
|
-
fs.writeFileSync(tempConfigPath, configContent);
|
|
386
|
-
const result = loadConfigFromYaml(tempConfigPath);
|
|
387
|
-
assert.deepStrictEqual(result.configs["dev"].commandWhitelist, [
|
|
388
|
-
"^ls( .*)?$",
|
|
389
|
-
"^cat .*$",
|
|
390
|
-
"^docker ps.*$"
|
|
391
|
-
]);
|
|
392
|
-
});
|
|
393
|
-
it("should load blacklist patterns", () => {
|
|
394
|
-
const configContent = `
|
|
395
|
-
servers:
|
|
396
|
-
dev:
|
|
397
|
-
host: 192.168.1.1
|
|
398
|
-
username: test
|
|
399
|
-
password: test
|
|
400
|
-
blacklist:
|
|
401
|
-
- "^rm .*$"
|
|
402
|
-
- "^shutdown.*$"
|
|
403
|
-
`;
|
|
404
|
-
const tempConfigPath = path.join(tempDir, "blacklist.yaml");
|
|
405
|
-
fs.writeFileSync(tempConfigPath, configContent);
|
|
406
|
-
const result = loadConfigFromYaml(tempConfigPath);
|
|
407
|
-
assert.deepStrictEqual(result.configs["dev"].commandBlacklist, [
|
|
408
|
-
"^rm .*$",
|
|
409
|
-
"^shutdown.*$"
|
|
410
|
-
]);
|
|
411
|
-
});
|
|
412
|
-
it("should load both whitelist and blacklist", () => {
|
|
413
|
-
const configContent = `
|
|
414
|
-
servers:
|
|
415
|
-
dev:
|
|
416
|
-
host: 192.168.1.1
|
|
417
|
-
username: test
|
|
418
|
-
password: test
|
|
419
|
-
whitelist:
|
|
420
|
-
- "^.*$"
|
|
421
|
-
blacklist:
|
|
422
|
-
- "^rm -rf.*$"
|
|
423
|
-
`;
|
|
424
|
-
const tempConfigPath = path.join(tempDir, "both.yaml");
|
|
425
|
-
fs.writeFileSync(tempConfigPath, configContent);
|
|
426
|
-
const result = loadConfigFromYaml(tempConfigPath);
|
|
427
|
-
assert.deepStrictEqual(result.configs["dev"].commandWhitelist, ["^.*$"]);
|
|
428
|
-
assert.deepStrictEqual(result.configs["dev"].commandBlacklist, ["^rm -rf.*$"]);
|
|
429
|
-
});
|
|
430
|
-
});
|
|
431
|
-
describe("SFTP Path Allowlist Config", () => {
|
|
432
|
-
let tempDir;
|
|
433
|
-
beforeEach(() => {
|
|
434
|
-
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "handfree-ssh-mcp-test-"));
|
|
435
|
-
});
|
|
436
|
-
afterEach(() => {
|
|
437
|
-
if (fs.existsSync(tempDir)) {
|
|
438
|
-
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
439
|
-
}
|
|
440
|
-
});
|
|
441
|
-
it("should load allowedRemoteDirectories", () => {
|
|
442
|
-
const configContent = `
|
|
443
|
-
servers:
|
|
444
|
-
dev:
|
|
445
|
-
host: 192.168.1.1
|
|
446
|
-
username: test
|
|
447
|
-
password: test
|
|
448
|
-
allowedRemoteDirectories:
|
|
449
|
-
- /home/test
|
|
450
|
-
- /tmp
|
|
451
|
-
`;
|
|
452
|
-
const p = path.join(tempDir, "ard.yaml");
|
|
453
|
-
fs.writeFileSync(p, configContent);
|
|
454
|
-
const result = loadConfigFromYaml(p);
|
|
455
|
-
assert.deepStrictEqual(result.configs["dev"].allowedRemoteDirectories, ["/home/test", "/tmp"]);
|
|
456
|
-
});
|
|
457
|
-
it("should strip trailing slashes from allowedRemoteDirectories", () => {
|
|
458
|
-
const configContent = `
|
|
459
|
-
servers:
|
|
460
|
-
dev:
|
|
461
|
-
host: 192.168.1.1
|
|
462
|
-
username: test
|
|
463
|
-
password: test
|
|
464
|
-
allowedRemoteDirectories:
|
|
465
|
-
- /home/test/
|
|
466
|
-
- /tmp/
|
|
467
|
-
- /
|
|
468
|
-
`;
|
|
469
|
-
const p = path.join(tempDir, "ard-slash.yaml");
|
|
470
|
-
fs.writeFileSync(p, configContent);
|
|
471
|
-
const result = loadConfigFromYaml(p);
|
|
472
|
-
assert.deepStrictEqual(result.configs["dev"].allowedRemoteDirectories, ["/home/test", "/tmp", "/"]);
|
|
473
|
-
});
|
|
474
|
-
it("should reject relative paths in allowedRemoteDirectories", () => {
|
|
475
|
-
const configContent = `
|
|
476
|
-
servers:
|
|
477
|
-
dev:
|
|
478
|
-
host: 192.168.1.1
|
|
479
|
-
username: test
|
|
480
|
-
password: test
|
|
481
|
-
allowedRemoteDirectories:
|
|
482
|
-
- home/test
|
|
483
|
-
`;
|
|
484
|
-
const p = path.join(tempDir, "ard-rel.yaml");
|
|
485
|
-
fs.writeFileSync(p, configContent);
|
|
486
|
-
assert.throws(() => loadConfigFromYaml(p), /absolute POSIX path/i);
|
|
487
|
-
});
|
|
488
|
-
it("should reject '..' segments in allowedRemoteDirectories", () => {
|
|
489
|
-
const configContent = `
|
|
490
|
-
servers:
|
|
491
|
-
dev:
|
|
492
|
-
host: 192.168.1.1
|
|
493
|
-
username: test
|
|
494
|
-
password: test
|
|
495
|
-
allowedRemoteDirectories:
|
|
496
|
-
- /home/test/../etc
|
|
497
|
-
`;
|
|
498
|
-
const p = path.join(tempDir, "ard-dotdot.yaml");
|
|
499
|
-
fs.writeFileSync(p, configContent);
|
|
500
|
-
assert.throws(() => loadConfigFromYaml(p), /'\.\.'/);
|
|
501
|
-
});
|
|
502
|
-
it("should reject empty string entries in allowedRemoteDirectories", () => {
|
|
503
|
-
const configContent = `
|
|
504
|
-
servers:
|
|
505
|
-
dev:
|
|
506
|
-
host: 192.168.1.1
|
|
507
|
-
username: test
|
|
508
|
-
password: test
|
|
509
|
-
allowedRemoteDirectories:
|
|
510
|
-
- ""
|
|
511
|
-
`;
|
|
512
|
-
const p = path.join(tempDir, "ard-empty.yaml");
|
|
513
|
-
fs.writeFileSync(p, configContent);
|
|
514
|
-
assert.throws(() => loadConfigFromYaml(p), /non-empty/i);
|
|
515
|
-
});
|
|
516
|
-
it("should resolve allowedLocalDirectories to absolute paths", () => {
|
|
517
|
-
const configContent = `
|
|
518
|
-
servers:
|
|
519
|
-
dev:
|
|
520
|
-
host: 192.168.1.1
|
|
521
|
-
username: test
|
|
522
|
-
password: test
|
|
523
|
-
allowedLocalDirectories:
|
|
524
|
-
- ~/uploads
|
|
525
|
-
`;
|
|
526
|
-
const p = path.join(tempDir, "ald.yaml");
|
|
527
|
-
fs.writeFileSync(p, configContent);
|
|
528
|
-
const result = loadConfigFromYaml(p);
|
|
529
|
-
const resolved = result.configs["dev"].allowedLocalDirectories;
|
|
530
|
-
assert.ok(resolved && resolved.length === 1);
|
|
531
|
-
assert.ok(path.isAbsolute(resolved[0]));
|
|
532
|
-
assert.ok(resolved[0].includes(os.homedir()));
|
|
533
|
-
});
|
|
534
|
-
it("should leave allowedRemoteDirectories undefined when not set", () => {
|
|
535
|
-
const configContent = `
|
|
536
|
-
servers:
|
|
537
|
-
dev:
|
|
538
|
-
host: 192.168.1.1
|
|
539
|
-
username: test
|
|
540
|
-
password: test
|
|
541
|
-
`;
|
|
542
|
-
const p = path.join(tempDir, "no-ard.yaml");
|
|
543
|
-
fs.writeFileSync(p, configContent);
|
|
544
|
-
const result = loadConfigFromYaml(p);
|
|
545
|
-
assert.strictEqual(result.configs["dev"].allowedRemoteDirectories, undefined);
|
|
546
|
-
assert.strictEqual(result.configs["dev"].allowedLocalDirectories, undefined);
|
|
547
|
-
});
|
|
548
|
-
});
|
|
549
|
-
describe("Output Log Dir Config", () => {
|
|
550
|
-
let tempDir;
|
|
551
|
-
beforeEach(() => {
|
|
552
|
-
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "handfree-ssh-mcp-test-"));
|
|
553
|
-
});
|
|
554
|
-
afterEach(() => {
|
|
555
|
-
if (fs.existsSync(tempDir)) {
|
|
556
|
-
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
557
|
-
}
|
|
558
|
-
});
|
|
559
|
-
it("should leave outputLogDir undefined when not set", () => {
|
|
560
|
-
const configContent = `
|
|
561
|
-
servers:
|
|
562
|
-
dev:
|
|
563
|
-
host: 192.168.1.1
|
|
564
|
-
username: test
|
|
565
|
-
password: test
|
|
566
|
-
`;
|
|
567
|
-
const p = path.join(tempDir, "no-log.yaml");
|
|
568
|
-
fs.writeFileSync(p, configContent);
|
|
569
|
-
const result = loadConfigFromYaml(p);
|
|
570
|
-
assert.strictEqual(result.outputLogDir, undefined);
|
|
571
|
-
});
|
|
572
|
-
it("should resolve outputLogDir to an absolute path", () => {
|
|
573
|
-
const configContent = `
|
|
574
|
-
outputLogDir: ./logs
|
|
575
|
-
servers:
|
|
576
|
-
dev:
|
|
577
|
-
host: 192.168.1.1
|
|
578
|
-
username: test
|
|
579
|
-
password: test
|
|
580
|
-
`;
|
|
581
|
-
const p = path.join(tempDir, "log.yaml");
|
|
582
|
-
fs.writeFileSync(p, configContent);
|
|
583
|
-
const result = loadConfigFromYaml(p);
|
|
584
|
-
assert.ok(result.outputLogDir);
|
|
585
|
-
assert.ok(path.isAbsolute(result.outputLogDir));
|
|
586
|
-
assert.ok(result.outputLogDir.endsWith(path.normalize("logs")));
|
|
587
|
-
});
|
|
588
|
-
it("should expand ~ in outputLogDir", () => {
|
|
589
|
-
const configContent = `
|
|
590
|
-
outputLogDir: ~/handfree-logs
|
|
591
|
-
servers:
|
|
592
|
-
dev:
|
|
593
|
-
host: 192.168.1.1
|
|
594
|
-
username: test
|
|
595
|
-
password: test
|
|
596
|
-
`;
|
|
597
|
-
const p = path.join(tempDir, "log-tilde.yaml");
|
|
598
|
-
fs.writeFileSync(p, configContent);
|
|
599
|
-
const result = loadConfigFromYaml(p);
|
|
600
|
-
assert.ok(result.outputLogDir);
|
|
601
|
-
assert.ok(result.outputLogDir.includes(os.homedir()));
|
|
602
|
-
});
|
|
603
|
-
it("should reject non-string outputLogDir", () => {
|
|
604
|
-
const configContent = `
|
|
605
|
-
outputLogDir: 42
|
|
606
|
-
servers:
|
|
607
|
-
dev:
|
|
608
|
-
host: 192.168.1.1
|
|
609
|
-
username: test
|
|
610
|
-
password: test
|
|
611
|
-
`;
|
|
612
|
-
const p = path.join(tempDir, "log-bad.yaml");
|
|
613
|
-
fs.writeFileSync(p, configContent);
|
|
614
|
-
assert.throws(() => loadConfigFromYaml(p), /non-empty string/i);
|
|
615
|
-
});
|
|
616
|
-
});
|
|
617
|
-
console.log("\n🧪 Running handfree-ssh-mcp tests...\n");
|