@akms/mcp-ssh 0.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/lib/cli.cjs ADDED
@@ -0,0 +1,3280 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ var stdio_js = require('@modelcontextprotocol/sdk/server/stdio.js');
5
+ var os = require('os');
6
+ var path2 = require('path');
7
+ var crypto = require('crypto');
8
+ var fs4 = require('fs');
9
+ var string_decoder = require('string_decoder');
10
+ var ssh2 = require('ssh2');
11
+ var timers = require('timers');
12
+ var mcp_js = require('@modelcontextprotocol/sdk/server/mcp.js');
13
+ var zod = require('zod');
14
+
15
+ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
16
+
17
+ var os__default = /*#__PURE__*/_interopDefault(os);
18
+ var path2__default = /*#__PURE__*/_interopDefault(path2);
19
+ var fs4__default = /*#__PURE__*/_interopDefault(fs4);
20
+ var ssh2__default = /*#__PURE__*/_interopDefault(ssh2);
21
+
22
+ // src/_defs/defaults.ts
23
+ var CONFIG_PATH_ENV = "SSH_MCP_CONFIG";
24
+ var LOG_LEVEL_ENV = "SSH_MCP_LOG_LEVEL";
25
+ var DEFAULT_CONFIG_FILENAMES = [
26
+ ".akms-ssh.json",
27
+ ".config/akms-ssh/config.json"
28
+ ];
29
+ var DEFAULT_SSH_PORT = 22;
30
+ var DEFAULT_EXEC_TIMEOUT_MS = 6e4;
31
+ var DEFAULT_CONNECT_TIMEOUT_MS = 2e4;
32
+ var DEFAULT_MAX_OUTPUT_CHARACTERS = 1e5;
33
+ var DEFAULT_MAX_READ_FILE_BYTES = 2e5;
34
+ var KEEPALIVE_INTERVAL_MS = 15e3;
35
+ var SESSION_IDLE_TIMEOUT_MS = 15 * 60 * 1e3;
36
+ var SESSION_SWEEP_INTERVAL_MS = 6e4;
37
+ var CWD_MARKER = "__AKMS_SSH_CWD__";
38
+ var MAX_DIRECTORY_ENTRIES = 1e3;
39
+ var SERVER_NAME = "akms-ssh";
40
+ var SERVER_VERSION = "0.0.1";
41
+
42
+ // src/_defs/command-rules.ts
43
+ var CRITICAL_PATH_PATTERN = new RegExp(
44
+ "^(?:/|//|/\\*|/\\.|/\\.\\.|/\\*/|//\\*|~|~/|~/\\*|\\$HOME|\\$HOME/|\\$HOME/\\*|\\$\\{HOME\\}/?\\*?|/(?:bin|boot|dev|etc|home|lib|lib32|lib64|libx32|media|mnt|opt|proc|root|run|sbin|srv|sys|usr|var|data)(?:/\\*)?/?)$",
45
+ "i"
46
+ );
47
+ var CRITICAL_FILE_PATTERN = new RegExp(
48
+ "^/(?:etc/(?:passwd|shadow|group|sudoers|fstab|hostname|hosts|resolv\\.conf)|boot/(?:vmlinuz|initrd|grub).*|etc/ssh/sshd_config)$",
49
+ "i"
50
+ );
51
+ var COMMAND_WRAPPER_BINARIES = [
52
+ "sudo",
53
+ "doas",
54
+ "env",
55
+ "eval",
56
+ "nohup",
57
+ "time",
58
+ "nice",
59
+ "ionice",
60
+ "timeout",
61
+ "stdbuf",
62
+ "command",
63
+ "exec",
64
+ "builtin",
65
+ "watch",
66
+ "xargs",
67
+ "setsid",
68
+ "unbuffer",
69
+ "busybox",
70
+ "toybox"
71
+ ];
72
+ var NUMERIC_OPERAND_PATTERN = /^\d+(?:\.\d+)?[smhd]?$/i;
73
+ var WRAPPER_VALUE_FLAGS = {
74
+ env: ["-u", "--unset", "-C", "--chdir", "-S", "--split-string"],
75
+ sudo: ["-u", "--user", "-g", "--group", "-p", "--prompt", "-C", "--close-from", "-h", "--host", "-D", "--chdir", "-R", "--chroot"],
76
+ doas: ["-u", "-C"],
77
+ timeout: ["-s", "--signal", "-k", "--kill-after"],
78
+ nice: ["-n", "--adjustment"],
79
+ ionice: ["-c", "--class", "-n", "--classdata", "-p", "--pid"],
80
+ stdbuf: ["-i", "--input", "-o", "--output", "-e", "--error"],
81
+ xargs: ["-a", "--arg-file", "-d", "--delimiter", "-E", "-I", "--replace", "-L", "-n", "--max-args", "-P", "--max-procs", "-s", "--max-chars"],
82
+ watch: ["-n", "--interval"],
83
+ setsid: [],
84
+ nohup: [],
85
+ "systemd-run": ["-u", "--unit", "-p", "--property", "--uid", "--gid", "--setenv"],
86
+ runuser: ["-u", "--user", "-g", "--group", "-s", "--shell"],
87
+ su: ["-s", "--shell", "-g", "--group"]
88
+ };
89
+ var PRIVILEGE_ESCALATION_BINARIES = [
90
+ "sudo",
91
+ "su",
92
+ "doas",
93
+ "pkexec",
94
+ "runuser",
95
+ "chroot",
96
+ "nsenter",
97
+ "systemd-run"
98
+ ];
99
+ var SHELL_PAYLOAD_BINARIES = [
100
+ "sh",
101
+ "bash",
102
+ "zsh",
103
+ "ksh",
104
+ "dash",
105
+ "ash",
106
+ "mksh",
107
+ "csh",
108
+ "tcsh",
109
+ "fish",
110
+ "su",
111
+ "runuser",
112
+ "chroot",
113
+ "nsenter",
114
+ "systemd-run"
115
+ ];
116
+ var DANGEROUS_COMMAND_RULES = [
117
+ {
118
+ pattern: /^mkfs(\.\w+)?\b/i,
119
+ reason: "formats a filesystem"
120
+ },
121
+ {
122
+ pattern: />\s*\/dev\/(sd|nvme|hd|vd|xvd|mapper)/i,
123
+ reason: "redirects output onto a block device"
124
+ },
125
+ {
126
+ pattern: /^(shutdown|reboot|halt|poweroff)\b/i,
127
+ reason: "powers off or reboots the host, ending this session"
128
+ },
129
+ {
130
+ pattern: /^init\s+[06]\b/i,
131
+ reason: "changes runlevel to halt or reboot"
132
+ },
133
+ {
134
+ pattern: /^systemctl\s+(poweroff|reboot|halt|emergency|rescue)\b/i,
135
+ reason: "powers off, reboots or degrades the host"
136
+ },
137
+ {
138
+ // Anchored on `/` as the whole search root: `find /tmp -delete` is ordinary
139
+ // cleanup, `find / -delete` empties the machine.
140
+ pattern: /^find\s+\/\s+.*(-delete|-exec(dir)?\s+(\/\S+\/)?rm)/i,
141
+ reason: "deletes files found from the filesystem root"
142
+ },
143
+ {
144
+ pattern: /^crontab\s+(-\S+\s+)*-r\b/i,
145
+ reason: "removes every cron job for the user"
146
+ },
147
+ {
148
+ pattern: /^(iptables|ip6tables|nft)\s+(-F|--flush|flush)\b/i,
149
+ reason: "flushes firewall rules and can lock this session out"
150
+ },
151
+ {
152
+ pattern: /--no-preserve-root/i,
153
+ reason: "explicitly disables the root-deletion safeguard"
154
+ }
155
+ ];
156
+ var DANGEROUS_ARGUMENT_RULES = [
157
+ {
158
+ binaries: ["rm"],
159
+ requiredFlagPattern: /^--?(?:[a-z]*[rRf][a-z]*|recursive|force)$/,
160
+ argumentPattern: CRITICAL_PATH_PATTERN,
161
+ reason: "deletes the filesystem root, a system directory or an entire home directory"
162
+ },
163
+ {
164
+ binaries: ["rm", "unlink", "shred", "srm", "wipe"],
165
+ argumentPattern: CRITICAL_FILE_PATTERN,
166
+ reason: "deletes a file the host cannot boot or authenticate without"
167
+ },
168
+ {
169
+ binaries: ["shred", "srm", "wipe"],
170
+ argumentPattern: CRITICAL_PATH_PATTERN,
171
+ reason: "irreversibly destroys a system path"
172
+ },
173
+ {
174
+ binaries: ["chmod", "chown", "chgrp"],
175
+ requiredFlagPattern: /^--?(R|recursive)$/,
176
+ argumentPattern: CRITICAL_PATH_PATTERN,
177
+ reason: "recursively rewrites ownership or permissions on a system path"
178
+ },
179
+ {
180
+ binaries: ["dd"],
181
+ argumentPattern: /^of=(\/dev\/(?!null|zero|stdout|stderr|tty)|\/etc\/|\/boot\/|\/bin\/|\/sbin\/|\/usr\/|\/lib)/i,
182
+ reason: "writes raw bytes over a device or system file"
183
+ },
184
+ {
185
+ binaries: ["mkfs", "fdisk", "parted", "sgdisk", "wipefs"],
186
+ argumentPattern: /^\/dev\//i,
187
+ reason: "repartitions or formats a block device"
188
+ }
189
+ ];
190
+ var DANGEROUS_PIPELINE_RULES = [
191
+ {
192
+ pattern: /\{\s*:\s*\|\s*:\s*&\s*\}/,
193
+ reason: "fork bomb"
194
+ }
195
+ ];
196
+ var WRITE_COMMAND_BINARIES = [
197
+ // filesystem mutation
198
+ "rm",
199
+ "rmdir",
200
+ "mv",
201
+ "cp",
202
+ "dd",
203
+ "mkdir",
204
+ "touch",
205
+ "ln",
206
+ "chmod",
207
+ "chown",
208
+ "chgrp",
209
+ "truncate",
210
+ "shred",
211
+ "srm",
212
+ "wipe",
213
+ "install",
214
+ "tee",
215
+ "patch",
216
+ "rename",
217
+ "chattr",
218
+ "setfacl",
219
+ // storage / mounts
220
+ "mkfs",
221
+ "fdisk",
222
+ "parted",
223
+ "sgdisk",
224
+ "wipefs",
225
+ "mount",
226
+ "umount",
227
+ "swapon",
228
+ "swapoff",
229
+ // accounts and scheduling
230
+ "useradd",
231
+ "usermod",
232
+ "userdel",
233
+ "groupadd",
234
+ "groupmod",
235
+ "groupdel",
236
+ "passwd",
237
+ "chpasswd",
238
+ "visudo",
239
+ "crontab",
240
+ "at",
241
+ "batch",
242
+ // process and power control
243
+ "kill",
244
+ "killall",
245
+ "pkill",
246
+ "reboot",
247
+ "shutdown",
248
+ "halt",
249
+ "poweroff",
250
+ "init",
251
+ "service",
252
+ // package managers
253
+ "apt",
254
+ "apt-get",
255
+ "aptitude",
256
+ "dpkg",
257
+ "yum",
258
+ "dnf",
259
+ "rpm",
260
+ "zypper",
261
+ "pacman",
262
+ "apk",
263
+ "snap",
264
+ "brew",
265
+ "npm",
266
+ "pnpm",
267
+ "yarn",
268
+ "npx",
269
+ "pip",
270
+ "pip3",
271
+ "gem",
272
+ "cargo",
273
+ "composer",
274
+ "bundle",
275
+ "make",
276
+ "cmake",
277
+ // editors — interactive and write-oriented
278
+ "vi",
279
+ "vim",
280
+ "nvim",
281
+ "nano",
282
+ "emacs",
283
+ "ex",
284
+ "ed",
285
+ // transfer to/from other hosts, and archive extraction
286
+ "scp",
287
+ "rsync",
288
+ "sftp",
289
+ "ftp",
290
+ "ssh",
291
+ "ssh-keygen",
292
+ "ssh-copy-id",
293
+ "wget",
294
+ "unzip",
295
+ "gunzip",
296
+ "bunzip2",
297
+ "unxz",
298
+ "cpio",
299
+ // firewall
300
+ "iptables",
301
+ "ip6tables",
302
+ "ufw",
303
+ "firewall-cmd",
304
+ "nft"
305
+ ];
306
+ var WRITE_USAGE_RULES = [
307
+ {
308
+ pattern: /^sed\b.*(?:(\s|^)-[a-zA-Z]*i([a-zA-Z]|\s|$|\.)|--in-place)/,
309
+ reason: "sed edits files in place"
310
+ },
311
+ {
312
+ pattern: /^sort\b.*(\s|^)(-o|--output)\b/i,
313
+ reason: "sort writes its result to a file"
314
+ },
315
+ {
316
+ pattern: /^find\b.*(-delete|-exec|-execdir|-ok|-okdir|-fls|-fprint)/i,
317
+ reason: "find action deletes files or runs an arbitrary command"
318
+ },
319
+ {
320
+ pattern: /^git\s+(push|reset|checkout|switch|restore|clean|rebase|merge|commit|add|rm|mv|stash|apply|revert|cherry-pick|pull|remote|tag|gc|prune|init|clone)\b/i,
321
+ reason: "git subcommand mutates the working tree or the remote"
322
+ },
323
+ {
324
+ pattern: /^systemctl\s+(start|stop|restart|reload|enable|disable|mask|unmask|kill|isolate|set-property|edit|daemon-reload|daemon-reexec)\b/i,
325
+ reason: "systemctl subcommand changes service state"
326
+ },
327
+ {
328
+ pattern: /^docker\s+(run|rm|rmi|stop|start|restart|kill|exec|create|build|push|pull|cp|commit|prune|load|import|tag|update|rename|volume|network|swarm)\b/i,
329
+ reason: "docker subcommand mutates containers, images or volumes"
330
+ },
331
+ {
332
+ pattern: /^(kubectl|oc)\s+(apply|delete|create|edit|scale|patch|replace|rollout|drain|cordon|uncordon|exec|cp|set|annotate|label|taint)\b/i,
333
+ reason: "kubectl subcommand mutates cluster state"
334
+ },
335
+ {
336
+ pattern: /^tar\b.*(--extract|--create|--delete|(\s|^)-{1,2}[a-zA-Z]*[xc][a-zA-Z]*(\s|$))/i,
337
+ reason: "tar extract/create writes files"
338
+ },
339
+ {
340
+ pattern: /^curl\b.*(\s|^)(-o|-O|--output|--remote-name|--upload-file|-T)\b/i,
341
+ reason: "curl writes a file or uploads one"
342
+ },
343
+ {
344
+ pattern: /^(zip|gzip|bzip2|xz)\b/i,
345
+ reason: "compression tool rewrites files on disk"
346
+ }
347
+ ];
348
+ var BENIGN_REDIRECT_PATTERN = /(\d?>>?\s*&\s*\d|&>>?\s*\/dev\/null|\d?>>?\s*\/dev\/null)/g;
349
+ var OUTPUT_REDIRECT_PATTERN = />/;
350
+ var REDIRECTION_TOKEN_PATTERN = /^(?:\d*>>?|<|&>>?)/;
351
+ var BARE_REDIRECTION_OPERATOR_PATTERN = /^(?:\d*>>?|<|&>>?)$/;
352
+ var SHELL_TOKEN_PATTERN = /\d*>>?&?\d*|&>>?|<&?\d*|(?:[^\s'"\\<>&|]|\\.|'[^']*'|"[^"]*")+/g;
353
+ var SHELL_KEYWORD_TOKENS = [
354
+ "if",
355
+ "then",
356
+ "else",
357
+ "elif",
358
+ "fi",
359
+ "for",
360
+ "while",
361
+ "until",
362
+ "do",
363
+ "done",
364
+ "case",
365
+ "esac",
366
+ "in",
367
+ "select",
368
+ "function",
369
+ "{",
370
+ "}",
371
+ "(",
372
+ ")",
373
+ "!",
374
+ "[[",
375
+ "]]",
376
+ "[",
377
+ "]"
378
+ ];
379
+ var UNSAFE_PATH_CHARACTER_PATTERN = /[`$;|&<>(){}!*?\[\]\\"'\n\r\t]/;
380
+
381
+ // src/_defs/protected-paths.ts
382
+ var PROTECTED_LOCAL_PATH_RULES = [
383
+ {
384
+ pattern: /(^|\/)\.ssh(\/|$)/i,
385
+ reason: "SSH key directory"
386
+ },
387
+ {
388
+ pattern: /(^|\/)authorized_keys$/i,
389
+ reason: "SSH authorized_keys"
390
+ },
391
+ {
392
+ pattern: /(^|\/)id_(rsa|dsa|ecdsa|ed25519)/i,
393
+ reason: "private key file"
394
+ },
395
+ {
396
+ pattern: /\.(pem|ppk|p12|pfx|key)$/i,
397
+ reason: "private key or certificate file"
398
+ },
399
+ {
400
+ pattern: /(^|\/)\.gnupg(\/|$)/i,
401
+ reason: "GPG keyring"
402
+ },
403
+ {
404
+ pattern: /(^|\/)\.aws(\/|$)/i,
405
+ reason: "AWS credentials"
406
+ },
407
+ {
408
+ pattern: /(^|\/)\.kube(\/|$)/i,
409
+ reason: "kubeconfig"
410
+ },
411
+ {
412
+ pattern: /(^|\/)\.docker(\/|$)/i,
413
+ reason: "docker credentials"
414
+ },
415
+ {
416
+ pattern: /(^|\/)\.npmrc$/i,
417
+ reason: "npm credentials"
418
+ },
419
+ {
420
+ pattern: /(^|\/)\.netrc$/i,
421
+ reason: "netrc credentials"
422
+ },
423
+ {
424
+ pattern: /(^|\/)\.?akms-ssh(\.json)?(\/|$)/i,
425
+ reason: "this server's own host profile configuration"
426
+ },
427
+ {
428
+ pattern: /(^|\/)\.claude(\.json)?(\/|$)/i,
429
+ reason: "MCP client configuration"
430
+ },
431
+ {
432
+ pattern: /(^|\/)\.mcp\.json$/i,
433
+ reason: "MCP server configuration"
434
+ },
435
+ {
436
+ pattern: /(^|\/)claude_desktop_config\.json$/i,
437
+ reason: "MCP client configuration"
438
+ },
439
+ {
440
+ pattern: /(^|\/)start ?menu\/programs\/startup(\/|$)/i,
441
+ reason: "Windows startup folder"
442
+ },
443
+ {
444
+ pattern: /(^|\/)\.config\/autostart(\/|$)/i,
445
+ reason: "desktop autostart directory"
446
+ }
447
+ ];
448
+
449
+ // src/_defs/env-vars.ts
450
+ var SINGLE_HOST_ENV = {
451
+ /** Presence of this variable selects single-host mode. */
452
+ HOST: "SSH_HOST",
453
+ PORT: "SSH_PORT",
454
+ USER: "SSH_USER",
455
+ /** Path to a private key file. */
456
+ KEY: "SSH_KEY",
457
+ PASSPHRASE: "SSH_PASSPHRASE",
458
+ PASSWORD: "SSH_PASSWORD",
459
+ /** Pinned host key, `SHA256:…`. */
460
+ HOST_KEY: "SSH_HOST_KEY",
461
+ /** Alias shown to the model; defaults to the hostname. */
462
+ NAME: "SSH_NAME",
463
+ DESCRIPTION: "SSH_DESCRIPTION",
464
+ /** Directory new sessions start in. */
465
+ CWD: "SSH_CWD",
466
+ READONLY: "SSH_READONLY",
467
+ ALLOW_SUDO: "SSH_ALLOW_SUDO",
468
+ /** Comma-separated binary names. */
469
+ ALLOW_COMMANDS: "SSH_ALLOW_COMMANDS",
470
+ /** Comma-separated regex sources. */
471
+ DENY_PATTERNS: "SSH_DENY_PATTERNS",
472
+ /** Comma-separated absolute path prefixes. */
473
+ ALLOWED_PATHS: "SSH_ALLOWED_PATHS",
474
+ EXEC_TIMEOUT_MS: "SSH_EXEC_TIMEOUT_MS",
475
+ CONNECT_TIMEOUT_MS: "SSH_CONNECT_TIMEOUT_MS",
476
+ MAX_OUTPUT_CHARACTERS: "SSH_MAX_OUTPUT",
477
+ MAX_READ_FILE_BYTES: "SSH_MAX_READ_BYTES"
478
+ };
479
+ var TRUTHY_ENV_VALUES = ["true", "1", "yes", "on"];
480
+ var FALSY_ENV_VALUES = ["false", "0", "no", "off"];
481
+
482
+ // src/_libs/logger.ts
483
+ var LEVEL_WEIGHTS = {
484
+ silent: 0,
485
+ debug: 1,
486
+ info: 2,
487
+ warn: 3,
488
+ error: 4
489
+ };
490
+ var SECRET_META_KEYS = ["password", "passphrase", "privatekey", "privatekeypath", "secret", "token"];
491
+ function resolveThreshold() {
492
+ const configured = process.env[LOG_LEVEL_ENV];
493
+ if (configured == null) {
494
+ return "info";
495
+ }
496
+ const normalized = configured.toLowerCase();
497
+ if (Object.hasOwn(LEVEL_WEIGHTS, normalized) == false) {
498
+ return "info";
499
+ }
500
+ return normalized;
501
+ }
502
+ function maskMeta(meta) {
503
+ const masked = {};
504
+ for (const [key, value] of Object.entries(meta)) {
505
+ if (SECRET_META_KEYS.includes(key.toLowerCase()) == true) {
506
+ masked[key] = "***";
507
+ continue;
508
+ }
509
+ masked[key] = value;
510
+ }
511
+ return masked;
512
+ }
513
+ function write(level, message, meta) {
514
+ const threshold = resolveThreshold();
515
+ if (threshold === "silent") {
516
+ return;
517
+ }
518
+ if (LEVEL_WEIGHTS[level] < LEVEL_WEIGHTS[threshold]) {
519
+ return;
520
+ }
521
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
522
+ const masked = maskMeta(meta);
523
+ let line = `${timestamp} [${level}] ${message}`;
524
+ if (Object.keys(masked).length > 0) {
525
+ line += ` ${JSON.stringify(masked)}`;
526
+ }
527
+ process.stderr.write(`${line}
528
+ `);
529
+ }
530
+ function createLogger(boundMeta) {
531
+ return {
532
+ child(meta) {
533
+ return createLogger({ ...boundMeta, ...meta });
534
+ },
535
+ debug(message, meta) {
536
+ write("debug", message, { ...boundMeta, ...meta });
537
+ },
538
+ info(message, meta) {
539
+ write("info", message, { ...boundMeta, ...meta });
540
+ },
541
+ warn(message, meta) {
542
+ write("warn", message, { ...boundMeta, ...meta });
543
+ },
544
+ error(error, message, meta) {
545
+ const errorMeta = { ...boundMeta, ...meta };
546
+ if (error instanceof Error) {
547
+ errorMeta.error_name = error.name;
548
+ errorMeta.error_message = error.message;
549
+ errorMeta.stack = error.stack;
550
+ } else if (error != null) {
551
+ errorMeta.error_message = String(error);
552
+ }
553
+ write("error", message, errorMeta);
554
+ }
555
+ };
556
+ }
557
+ var $logger = createLogger({});
558
+
559
+ // src/_libs/text.ts
560
+ function truncateOutput(text, maxCharacters) {
561
+ if (text.length <= maxCharacters) {
562
+ return { text, truncated: false };
563
+ }
564
+ let cutAt = maxCharacters;
565
+ const lastKeptCode = text.charCodeAt(cutAt - 1);
566
+ if (lastKeptCode >= 55296 && lastKeptCode <= 56319) {
567
+ cutAt -= 1;
568
+ }
569
+ const droppedCharacters = text.length - cutAt;
570
+ const kept = text.slice(0, cutAt);
571
+ return {
572
+ text: `${kept}
573
+ ... [truncated ${droppedCharacters} characters of ${text.length}]`,
574
+ truncated: true
575
+ };
576
+ }
577
+ function quoteShellArgument(value) {
578
+ const escaped = value.replace(/'/g, `'\\''`);
579
+ return `'${escaped}'`;
580
+ }
581
+ function formatBytes(bytes) {
582
+ if (bytes < 1024) {
583
+ return `${bytes} B`;
584
+ }
585
+ if (bytes < 1024 * 1024) {
586
+ const kilobytes = bytes / 1024;
587
+ return `${kilobytes.toFixed(1)} KB`;
588
+ }
589
+ const megabytes = bytes / (1024 * 1024);
590
+ return `${megabytes.toFixed(1)} MB`;
591
+ }
592
+ function expandHomePath(inputPath) {
593
+ let expanded = inputPath;
594
+ if (inputPath === "~") {
595
+ expanded = os__default.default.homedir();
596
+ } else if (inputPath.startsWith("~/") == true || inputPath.startsWith("~\\") == true) {
597
+ expanded = path2__default.default.join(os__default.default.homedir(), inputPath.slice(2));
598
+ }
599
+ return path2__default.default.resolve(expanded);
600
+ }
601
+ function isAbsoluteRemotePath(remotePath) {
602
+ return remotePath.startsWith("/");
603
+ }
604
+ function normalizeRemotePath(remotePath) {
605
+ return path2__default.default.posix.normalize(remotePath);
606
+ }
607
+ function isRemotePathWithin(candidate, allowedPrefix) {
608
+ const normalizedCandidate = normalizeRemotePath(candidate);
609
+ const normalizedPrefix = normalizeRemotePath(allowedPrefix).replace(/\/+$/, "");
610
+ if (normalizedPrefix === "" || normalizedPrefix === "/") {
611
+ return true;
612
+ }
613
+ if (normalizedCandidate === normalizedPrefix) {
614
+ return true;
615
+ }
616
+ return normalizedCandidate.startsWith(`${normalizedPrefix}/`);
617
+ }
618
+
619
+ // src/modules/guard/command-guard.ts
620
+ var WRAPPER_BINARY_SET = new Set(COMMAND_WRAPPER_BINARIES);
621
+ var PRIVILEGE_BINARY_SET = new Set(PRIVILEGE_ESCALATION_BINARIES);
622
+ var SHELL_PAYLOAD_BINARY_SET = new Set(SHELL_PAYLOAD_BINARIES);
623
+ var WRITE_BINARY_SET = new Set(WRITE_COMMAND_BINARIES);
624
+ var SHELL_KEYWORD_SET = new Set(SHELL_KEYWORD_TOKENS);
625
+ var MAX_INSPECTION_DEPTH = 3;
626
+ var ASSIGNMENT_TOKEN_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*=/;
627
+ var SHELL_COMMAND_FLAG_PATTERN = /^-[a-z]*c$/i;
628
+ function normalizeCommandText(command) {
629
+ return command.replace(/\\\r?\n/g, "").replace(/\$\{IFS\}/g, " ").replace(/\$IFS/g, " ");
630
+ }
631
+ function canonicalizeCommand(command) {
632
+ return normalizeCommandText(command);
633
+ }
634
+ function splitCommandSegments(command) {
635
+ const segments = [];
636
+ let current = "";
637
+ let openQuote = null;
638
+ for (let index = 0; index < command.length; index++) {
639
+ const character = command[index];
640
+ const nextCharacter = command[index + 1];
641
+ if (openQuote != null) {
642
+ current += character;
643
+ if (character === "\\" && openQuote === `"` && nextCharacter != null) {
644
+ current += nextCharacter;
645
+ index++;
646
+ continue;
647
+ }
648
+ if (character === openQuote) {
649
+ openQuote = null;
650
+ }
651
+ continue;
652
+ }
653
+ if (character === "'" || character === `"`) {
654
+ openQuote = character;
655
+ current += character;
656
+ continue;
657
+ }
658
+ if (character === "\\") {
659
+ current += character;
660
+ if (nextCharacter != null) {
661
+ current += nextCharacter;
662
+ index++;
663
+ }
664
+ continue;
665
+ }
666
+ if (character === "&" && current.trimEnd().endsWith(">") == true) {
667
+ current += character;
668
+ continue;
669
+ }
670
+ const isDoubleOperator = character === "&" && nextCharacter === "&" || character === "|" && nextCharacter === "|";
671
+ if (isDoubleOperator == true) {
672
+ segments.push(current);
673
+ current = "";
674
+ index++;
675
+ continue;
676
+ }
677
+ if (character === ";" || character === "\n" || character === "|" || character === "&") {
678
+ segments.push(current);
679
+ current = "";
680
+ continue;
681
+ }
682
+ current += character;
683
+ }
684
+ segments.push(current);
685
+ return segments.map((segment) => segment.trim()).filter((segment) => segment !== "");
686
+ }
687
+ function stripQuotedText(segment) {
688
+ let result = "";
689
+ let openQuote = null;
690
+ for (let index = 0; index < segment.length; index++) {
691
+ const character = segment[index];
692
+ if (openQuote === "'") {
693
+ if (character === "'") {
694
+ openQuote = null;
695
+ }
696
+ continue;
697
+ }
698
+ if (openQuote === `"`) {
699
+ if (character === "\\") {
700
+ index++;
701
+ continue;
702
+ }
703
+ if (character === `"`) {
704
+ openQuote = null;
705
+ }
706
+ continue;
707
+ }
708
+ if (character === "\\") {
709
+ index++;
710
+ continue;
711
+ }
712
+ if (character === "'" || character === `"`) {
713
+ openQuote = character;
714
+ continue;
715
+ }
716
+ result += character;
717
+ }
718
+ return result;
719
+ }
720
+ function tokenizeSegment(segment) {
721
+ const tokens = segment.match(SHELL_TOKEN_PATTERN);
722
+ if (tokens == null) {
723
+ return [];
724
+ }
725
+ return tokens;
726
+ }
727
+ function unquoteToken(token) {
728
+ if (token.includes("'") == false && token.includes(`"`) == false && token.includes("\\") == false) {
729
+ return token;
730
+ }
731
+ return token.replace(/\$'([^']*)'/g, "$1").replace(/'([^']*)'/g, "$1").replace(/"([^"]*)"/g, "$1").replace(/\\(.)/g, "$1");
732
+ }
733
+ function normalizeOperand(operand) {
734
+ if (operand.startsWith("/") == false) {
735
+ return operand;
736
+ }
737
+ let globSuffix = "";
738
+ if (operand.endsWith("*") == true) {
739
+ globSuffix = "*";
740
+ }
741
+ const withoutGlob = operand.slice(0, operand.length - globSuffix.length);
742
+ const collapsed = withoutGlob.replace(/\/{2,}/g, "/").replace(/\/\.(?=\/|$)/g, "/").replace(/\/{2,}/g, "/");
743
+ if (collapsed === "") {
744
+ return `/${globSuffix}`;
745
+ }
746
+ return `${collapsed}${globSuffix}`;
747
+ }
748
+ function extractSubstitutions(command) {
749
+ const found = [];
750
+ const nestedPattern = /(?:\$|<|>)\(([^()]*(?:\([^()]*\)[^()]*)*)\)/g;
751
+ let nestedMatch = nestedPattern.exec(command);
752
+ while (nestedMatch != null) {
753
+ found.push(nestedMatch[1]);
754
+ nestedMatch = nestedPattern.exec(command);
755
+ }
756
+ const backtickPattern = /`([^`]*)`/g;
757
+ let backtickMatch = backtickPattern.exec(command);
758
+ while (backtickMatch != null) {
759
+ found.push(backtickMatch[1]);
760
+ backtickMatch = backtickPattern.exec(command);
761
+ }
762
+ return found.filter((substitution) => substitution.trim() !== "");
763
+ }
764
+ function analyzeSegment(segment) {
765
+ const tokens = tokenizeSegment(segment);
766
+ const wrappers = [];
767
+ const flags = [];
768
+ const operands = [];
769
+ let binary = "";
770
+ let binaryIndex = -1;
771
+ let expectRedirectionTarget = false;
772
+ let expectFlagValue = false;
773
+ for (let index = 0; index < tokens.length; index++) {
774
+ const token = tokens[index];
775
+ if (expectRedirectionTarget == true) {
776
+ expectRedirectionTarget = false;
777
+ continue;
778
+ }
779
+ if (expectFlagValue == true) {
780
+ expectFlagValue = false;
781
+ continue;
782
+ }
783
+ if (ASSIGNMENT_TOKEN_PATTERN.test(token) == true) {
784
+ continue;
785
+ }
786
+ if (REDIRECTION_TOKEN_PATTERN.test(token) == true) {
787
+ if (BARE_REDIRECTION_OPERATOR_PATTERN.test(token) == true) {
788
+ expectRedirectionTarget = true;
789
+ }
790
+ continue;
791
+ }
792
+ if (token.startsWith("-") == true) {
793
+ const currentWrapper = wrappers[wrappers.length - 1];
794
+ if (currentWrapper != null) {
795
+ const valueFlags = WRAPPER_VALUE_FLAGS[currentWrapper];
796
+ if (valueFlags != null && valueFlags.includes(token) == true) {
797
+ expectFlagValue = true;
798
+ }
799
+ }
800
+ continue;
801
+ }
802
+ if (NUMERIC_OPERAND_PATTERN.test(token) == true && wrappers.length > 0) {
803
+ continue;
804
+ }
805
+ const unquoted = unquoteToken(token);
806
+ const trimmed = unquoted.replace(/^[({!]+|[)}]+$/g, "");
807
+ if (SHELL_KEYWORD_SET.has(trimmed) == true || trimmed === "") {
808
+ continue;
809
+ }
810
+ const basename = trimmed.split("/").pop() ?? trimmed;
811
+ if (basename === "") {
812
+ continue;
813
+ }
814
+ if (WRAPPER_BINARY_SET.has(basename) == true) {
815
+ wrappers.push(basename);
816
+ continue;
817
+ }
818
+ binary = basename;
819
+ binaryIndex = index;
820
+ break;
821
+ }
822
+ if (binaryIndex >= 0) {
823
+ for (const token of tokens.slice(binaryIndex + 1)) {
824
+ const unquoted = unquoteToken(token);
825
+ if (unquoted.startsWith("-") == true) {
826
+ flags.push(unquoted);
827
+ continue;
828
+ }
829
+ operands.push(normalizeOperand(unquoted));
830
+ }
831
+ }
832
+ if (binary === "" && wrappers.length > 0) {
833
+ binary = wrappers[wrappers.length - 1];
834
+ }
835
+ let normalizedText = segment;
836
+ if (binaryIndex >= 0) {
837
+ normalizedText = [binary, ...tokens.slice(binaryIndex + 1).map(unquoteToken)].join(" ");
838
+ }
839
+ return {
840
+ wrappers,
841
+ binary,
842
+ flags,
843
+ operands,
844
+ normalizedText,
845
+ tokens
846
+ };
847
+ }
848
+ function extractShellPayloads(analysis) {
849
+ if (SHELL_PAYLOAD_BINARY_SET.has(analysis.binary) == false) {
850
+ return [];
851
+ }
852
+ const tokens = analysis.tokens;
853
+ const payloads = [];
854
+ for (let index = 0; index < tokens.length - 1; index++) {
855
+ if (SHELL_COMMAND_FLAG_PATTERN.test(tokens[index]) == true || tokens[index] === "--command") {
856
+ payloads.push(unquoteToken(tokens[index + 1]));
857
+ }
858
+ }
859
+ const separatorIndex = tokens.indexOf("--");
860
+ if (separatorIndex >= 0 && separatorIndex < tokens.length - 1) {
861
+ payloads.push(tokens.slice(separatorIndex + 1).map(unquoteToken).join(" "));
862
+ }
863
+ return payloads;
864
+ }
865
+ function inspectCommand(command, policy, depth = 0) {
866
+ const normalizedCommand = normalizeCommandText(command);
867
+ const trimmedCommand = normalizedCommand.trim();
868
+ if (trimmedCommand === "") {
869
+ return { allowed: false, reason: "command is empty" };
870
+ }
871
+ if (depth > MAX_INSPECTION_DEPTH) {
872
+ return {
873
+ allowed: false,
874
+ reason: "command nests shells or substitutions too deeply to screen reliably",
875
+ offendingText: trimmedCommand
876
+ };
877
+ }
878
+ for (const rule of DANGEROUS_PIPELINE_RULES) {
879
+ if (rule.pattern.test(trimmedCommand) == true) {
880
+ return {
881
+ allowed: false,
882
+ reason: `blocked as dangerous: ${rule.reason}`,
883
+ offendingText: trimmedCommand
884
+ };
885
+ }
886
+ }
887
+ for (const substitution of extractSubstitutions(trimmedCommand)) {
888
+ const verdict = inspectCommand(substitution, policy, depth + 1);
889
+ if (verdict.allowed == false) {
890
+ return {
891
+ allowed: false,
892
+ reason: `${verdict.reason} (inside a command substitution)`,
893
+ offendingText: substitution
894
+ };
895
+ }
896
+ }
897
+ for (const segment of splitCommandSegments(trimmedCommand)) {
898
+ const verdict = inspectSegment(segment, policy, depth);
899
+ if (verdict.allowed == false) {
900
+ return verdict;
901
+ }
902
+ }
903
+ return { allowed: true };
904
+ }
905
+ function inspectSegment(segment, policy, depth) {
906
+ const analysis = analyzeSegment(segment);
907
+ for (const pattern of policy.denyPatterns) {
908
+ if (pattern.test(segment) == true || pattern.test(analysis.normalizedText) == true) {
909
+ return {
910
+ allowed: false,
911
+ reason: `blocked by the profile's denyPatterns rule /${pattern.source}/`,
912
+ offendingText: segment
913
+ };
914
+ }
915
+ }
916
+ if (policy.allowSudo == false) {
917
+ const escalation = [...analysis.wrappers, analysis.binary].find((name) => PRIVILEGE_BINARY_SET.has(name) == true);
918
+ if (escalation != null) {
919
+ return {
920
+ allowed: false,
921
+ reason: `privilege escalation via '${escalation}' is disabled for this profile (set allowSudo: true to permit it)`,
922
+ offendingText: segment
923
+ };
924
+ }
925
+ }
926
+ const shouldTestRawSegment = analysis.normalizedText !== segment;
927
+ for (const rule of DANGEROUS_COMMAND_RULES) {
928
+ const matched = rule.pattern.test(analysis.normalizedText) == true || shouldTestRawSegment == true && rule.pattern.test(segment) == true;
929
+ if (matched == true) {
930
+ return {
931
+ allowed: false,
932
+ reason: `blocked as dangerous: ${rule.reason}`,
933
+ offendingText: segment
934
+ };
935
+ }
936
+ }
937
+ for (const rule of DANGEROUS_ARGUMENT_RULES) {
938
+ if (rule.binaries.includes(analysis.binary) == false) {
939
+ continue;
940
+ }
941
+ if (rule.requiredFlagPattern != null) {
942
+ const hasRequiredFlag = analysis.flags.some((flag) => rule.requiredFlagPattern?.test(flag) == true);
943
+ if (hasRequiredFlag == false) {
944
+ continue;
945
+ }
946
+ }
947
+ const offendingOperand = analysis.operands.find((operand) => rule.argumentPattern.test(operand) == true);
948
+ if (offendingOperand != null) {
949
+ return {
950
+ allowed: false,
951
+ reason: `blocked as dangerous: ${rule.reason}`,
952
+ offendingText: `${analysis.binary} \u2026 ${offendingOperand}`
953
+ };
954
+ }
955
+ }
956
+ if (policy.readonly == true) {
957
+ const readonlyVerdict = inspectReadOnly(segment, analysis, shouldTestRawSegment);
958
+ if (readonlyVerdict.allowed == false) {
959
+ return readonlyVerdict;
960
+ }
961
+ }
962
+ if (policy.allowCommands.length > 0) {
963
+ if (analysis.binary === "") {
964
+ return {
965
+ allowed: false,
966
+ reason: `this profile only permits ${policy.allowCommands.join(", ")}, and this segment runs no listed command`,
967
+ offendingText: segment
968
+ };
969
+ }
970
+ if (policy.allowCommands.includes(analysis.binary) == false) {
971
+ return {
972
+ allowed: false,
973
+ reason: `'${analysis.binary}' is not in this profile's allowCommands list (${policy.allowCommands.join(", ")})`,
974
+ offendingText: segment
975
+ };
976
+ }
977
+ }
978
+ for (const payload of extractShellPayloads(analysis)) {
979
+ const verdict = inspectCommand(payload, policy, depth + 1);
980
+ if (verdict.allowed == false) {
981
+ return {
982
+ allowed: false,
983
+ reason: `${verdict.reason} (inside a '${analysis.binary} -c' payload)`,
984
+ offendingText: payload
985
+ };
986
+ }
987
+ }
988
+ return { allowed: true };
989
+ }
990
+ function inspectReadOnly(segment, analysis, shouldTestRawSegment) {
991
+ if (WRITE_BINARY_SET.has(analysis.binary) == true) {
992
+ return {
993
+ allowed: false,
994
+ reason: `profile is read-only and '${analysis.binary}' can modify the host`,
995
+ offendingText: segment
996
+ };
997
+ }
998
+ for (const rule of WRITE_USAGE_RULES) {
999
+ const matched = rule.pattern.test(analysis.normalizedText) == true || shouldTestRawSegment == true && rule.pattern.test(segment) == true;
1000
+ if (matched == true) {
1001
+ return {
1002
+ allowed: false,
1003
+ reason: `profile is read-only: ${rule.reason}`,
1004
+ offendingText: segment
1005
+ };
1006
+ }
1007
+ }
1008
+ const withoutQuotes = stripQuotedText(segment);
1009
+ const withoutBenignRedirects = withoutQuotes.replace(BENIGN_REDIRECT_PATTERN, "");
1010
+ if (OUTPUT_REDIRECT_PATTERN.test(withoutBenignRedirects) == true) {
1011
+ return {
1012
+ allowed: false,
1013
+ reason: "profile is read-only and the command redirects output into a file",
1014
+ offendingText: segment
1015
+ };
1016
+ }
1017
+ return { allowed: true };
1018
+ }
1019
+ function inspectRemotePath(remotePath, policy, access) {
1020
+ const trimmedPath = remotePath.trim();
1021
+ if (trimmedPath === "") {
1022
+ return { allowed: false, reason: "path is empty" };
1023
+ }
1024
+ if (access === "write" && policy.readonly == true) {
1025
+ return {
1026
+ allowed: false,
1027
+ reason: "profile is read-only, remote writes are not permitted",
1028
+ offendingText: trimmedPath
1029
+ };
1030
+ }
1031
+ if (policy.allowedPaths.length === 0) {
1032
+ return { allowed: true };
1033
+ }
1034
+ if (isAbsoluteRemotePath(trimmedPath) == false) {
1035
+ return {
1036
+ allowed: false,
1037
+ reason: `this profile restricts file access to ${policy.allowedPaths.join(", ")}, so the path must be absolute`,
1038
+ offendingText: trimmedPath
1039
+ };
1040
+ }
1041
+ const normalizedPath = normalizeRemotePath(trimmedPath);
1042
+ const matched = policy.allowedPaths.some((allowedPath) => isRemotePathWithin(normalizedPath, allowedPath) == true);
1043
+ if (matched == false) {
1044
+ return {
1045
+ allowed: false,
1046
+ reason: `path is outside this profile's allowedPaths (${policy.allowedPaths.join(", ")})`,
1047
+ offendingText: normalizedPath
1048
+ };
1049
+ }
1050
+ return { allowed: true };
1051
+ }
1052
+ function toRealPath(inputPath) {
1053
+ const _logger = $logger.child({ context: "toRealPath" });
1054
+ try {
1055
+ return fs4__default.default.realpathSync.native(inputPath);
1056
+ } catch (ex) {
1057
+ _logger.debug("path does not resolve, falling back to its parent directory", {
1058
+ reason: ex instanceof Error ? ex.name : "unknown"
1059
+ });
1060
+ }
1061
+ try {
1062
+ const parentRealPath = fs4__default.default.realpathSync.native(path2__default.default.dirname(inputPath));
1063
+ return path2__default.default.join(parentRealPath, path2__default.default.basename(inputPath));
1064
+ } catch (ex) {
1065
+ _logger.debug("parent directory does not resolve either, comparing lexically", {
1066
+ reason: ex instanceof Error ? ex.name : "unknown"
1067
+ });
1068
+ return path2__default.default.resolve(inputPath);
1069
+ }
1070
+ }
1071
+ function toComparablePath(inputPath) {
1072
+ return toRealPath(inputPath).replace(/\\/g, "/");
1073
+ }
1074
+ function inspectLocalPath(resolvedLocalPath, access, extraProtectedPaths = []) {
1075
+ const trimmedPath = resolvedLocalPath.trim();
1076
+ if (trimmedPath === "") {
1077
+ return { allowed: false, reason: "local path is empty" };
1078
+ }
1079
+ const comparablePath = toComparablePath(trimmedPath);
1080
+ for (const rule of PROTECTED_LOCAL_PATH_RULES) {
1081
+ if (rule.pattern.test(comparablePath) == true) {
1082
+ let verb = "read from";
1083
+ if (access === "write") {
1084
+ verb = "written to";
1085
+ }
1086
+ return {
1087
+ allowed: false,
1088
+ reason: `local path is protected and cannot be ${verb}: ${rule.reason}`,
1089
+ offendingText: trimmedPath
1090
+ };
1091
+ }
1092
+ }
1093
+ for (const protectedPath of extraProtectedPaths) {
1094
+ if (protectedPath === "") {
1095
+ continue;
1096
+ }
1097
+ if (comparablePath.toLowerCase() === toComparablePath(protectedPath).toLowerCase()) {
1098
+ return {
1099
+ allowed: false,
1100
+ reason: "local path is this server's own configuration file",
1101
+ offendingText: trimmedPath
1102
+ };
1103
+ }
1104
+ }
1105
+ return { allowed: true };
1106
+ }
1107
+ function inspectWorkingDirectory(cwd) {
1108
+ const trimmedCwd = cwd.trim();
1109
+ if (trimmedCwd === "") {
1110
+ return { allowed: true };
1111
+ }
1112
+ const unsafeMatch = UNSAFE_PATH_CHARACTER_PATTERN.exec(trimmedCwd);
1113
+ if (unsafeMatch != null) {
1114
+ const displayCharacter = unsafeMatch[0].replace(/\n/, "\\n").replace(/\r/, "\\r").replace(/\t/, "\\t");
1115
+ return {
1116
+ allowed: false,
1117
+ reason: `cwd contains the shell metacharacter '${displayCharacter}', which is not allowed in a directory argument`,
1118
+ offendingText: trimmedCwd
1119
+ };
1120
+ }
1121
+ return { allowed: true };
1122
+ }
1123
+
1124
+ // src/modules/guard/guard-error.ts
1125
+ var GuardRejectionError = class extends Error {
1126
+ constructor(verdict) {
1127
+ super(verdict.reason ?? "rejected by the host profile's guard policy");
1128
+ this.verdict = verdict;
1129
+ this.name = "GuardRejectionError";
1130
+ }
1131
+ };
1132
+
1133
+ // src/modules/ssh/ssh-connection.ts
1134
+ var { Client } = ssh2__default.default;
1135
+ var OUTPUT_HARD_CAP_MULTIPLIER = 4;
1136
+ var PRIVATE_KEY_CACHE = /* @__PURE__ */ new Map();
1137
+ var WINDOWS_AGENT_PIPE = "\\\\.\\pipe\\openssh-ssh-agent";
1138
+ function resolveAgentPath() {
1139
+ const authSocket = process.env.SSH_AUTH_SOCK;
1140
+ if (authSocket != null && authSocket !== "") {
1141
+ return authSocket;
1142
+ }
1143
+ if (process.platform === "win32") {
1144
+ return WINDOWS_AGENT_PIPE;
1145
+ }
1146
+ return void 0;
1147
+ }
1148
+ function readPrivateKey(profile, keyPath) {
1149
+ const cached = PRIVATE_KEY_CACHE.get(keyPath);
1150
+ if (cached != null) {
1151
+ return cached;
1152
+ }
1153
+ try {
1154
+ const contents = fs4__default.default.readFileSync(keyPath);
1155
+ PRIVATE_KEY_CACHE.set(keyPath, contents);
1156
+ return contents;
1157
+ } catch (ex) {
1158
+ const _logger = $logger.child({ context: "readPrivateKey", profile: profile.name });
1159
+ _logger.error(ex, "cannot read the profile's private key file");
1160
+ throw new Error(`cannot read the private key configured for profile '${profile.name}' (see the server log for the path)`);
1161
+ }
1162
+ }
1163
+ function toHostKeyFingerprint(hostKey) {
1164
+ const digest = crypto.createHash("sha256").update(hostKey).digest("base64").replace(/=+$/, "");
1165
+ return `SHA256:${digest}`;
1166
+ }
1167
+ function buildHostVerifier(profile, onHostKeySeen) {
1168
+ return (hostKey) => {
1169
+ const fingerprint = toHostKeyFingerprint(hostKey);
1170
+ onHostKeySeen(fingerprint);
1171
+ const _logger = $logger.child({ context: "hostVerifier", profile: profile.name, host: profile.host });
1172
+ if (profile.hostKeyFingerprint == null) {
1173
+ _logger.info("host key accepted unpinned; add hostKeyFingerprint to the profile to pin it", {
1174
+ host_key_fingerprint: fingerprint
1175
+ });
1176
+ return true;
1177
+ }
1178
+ if (profile.hostKeyFingerprint === fingerprint) {
1179
+ _logger.debug("host key matches the pinned fingerprint");
1180
+ return true;
1181
+ }
1182
+ _logger.error(null, "host key does not match the pinned fingerprint", {
1183
+ expected: profile.hostKeyFingerprint,
1184
+ actual: fingerprint
1185
+ });
1186
+ return false;
1187
+ };
1188
+ }
1189
+ function buildConnectConfig(profile, onHostKeySeen) {
1190
+ const connectConfig = {
1191
+ host: profile.host,
1192
+ port: profile.port,
1193
+ username: profile.username,
1194
+ readyTimeout: profile.policy.connectTimeoutMs,
1195
+ keepaliveInterval: KEEPALIVE_INTERVAL_MS,
1196
+ hostVerifier: buildHostVerifier(profile, onHostKeySeen)
1197
+ };
1198
+ if (profile.privateKeyPath != null) {
1199
+ connectConfig.privateKey = readPrivateKey(profile, profile.privateKeyPath);
1200
+ } else if (profile.privateKey != null) {
1201
+ connectConfig.privateKey = profile.privateKey;
1202
+ }
1203
+ if (profile.passphrase != null) {
1204
+ connectConfig.passphrase = profile.passphrase;
1205
+ }
1206
+ if (profile.password != null) {
1207
+ connectConfig.password = profile.password;
1208
+ }
1209
+ if (connectConfig.privateKey == null && connectConfig.password == null) {
1210
+ connectConfig.agent = resolveAgentPath();
1211
+ }
1212
+ return connectConfig;
1213
+ }
1214
+ function buildRemoteCommand(command, cwd, trackCwd) {
1215
+ const lines = [];
1216
+ if (cwd != null && cwd !== "") {
1217
+ const quotedCwd = quoteShellArgument(cwd);
1218
+ lines.push(`cd ${quotedCwd} 2>/dev/null || { echo 'akms-ssh: cannot enter the requested working directory' >&2; exit 1; }`);
1219
+ }
1220
+ lines.push(command);
1221
+ if (trackCwd == true) {
1222
+ lines.push("__akms_exit=$?");
1223
+ lines.push(`printf '\\n${CWD_MARKER}%s' "$PWD"`);
1224
+ lines.push("exit $__akms_exit");
1225
+ }
1226
+ return lines.join("\n");
1227
+ }
1228
+ function extractCwdMarker(stdout) {
1229
+ const markerIndex = stdout.lastIndexOf(CWD_MARKER);
1230
+ if (markerIndex < 0) {
1231
+ return { output: stdout };
1232
+ }
1233
+ const markerPayload = stdout.slice(markerIndex + CWD_MARKER.length);
1234
+ const cwd = (markerPayload.split("\n")[0] ?? "").trim();
1235
+ let output = stdout.slice(0, markerIndex);
1236
+ if (output.endsWith("\n") == true) {
1237
+ output = output.slice(0, -1);
1238
+ }
1239
+ if (cwd === "") {
1240
+ return { output };
1241
+ }
1242
+ return { output, cwd };
1243
+ }
1244
+ var SshConnection = class {
1245
+ constructor(profile) {
1246
+ this.profile = profile;
1247
+ this.client = null;
1248
+ this.pendingClient = null;
1249
+ this.connecting = null;
1250
+ this.disposed = false;
1251
+ /** Set when the peer closed a previously established connection. */
1252
+ this.droppedAfterReady = false;
1253
+ this.observedFingerprint = null;
1254
+ }
1255
+ get isConnected() {
1256
+ return this.client != null;
1257
+ }
1258
+ /** Host key the server presented, once a handshake has been attempted. */
1259
+ get hostKeyFingerprint() {
1260
+ return this.observedFingerprint;
1261
+ }
1262
+ /** True when a connection was established and then lost, so shell state is gone. */
1263
+ get wasDropped() {
1264
+ return this.droppedAfterReady;
1265
+ }
1266
+ /**
1267
+ * Opens the connection, or joins the in-flight attempt when one is already running.
1268
+ *
1269
+ * @throws {Error} If the connection was closed, authentication fails, the host is
1270
+ * unreachable, or the handshake times out.
1271
+ */
1272
+ async connect() {
1273
+ if (this.disposed == true) {
1274
+ throw new Error(`ssh connection for '${this.profile.name}' has been closed; open a new session`);
1275
+ }
1276
+ if (this.client != null) {
1277
+ return;
1278
+ }
1279
+ if (this.connecting != null) {
1280
+ await this.connecting;
1281
+ return;
1282
+ }
1283
+ const _logger = $logger.child({
1284
+ context: "SshConnection.connect",
1285
+ profile: this.profile.name,
1286
+ host: this.profile.host,
1287
+ port: this.profile.port,
1288
+ username: this.profile.username
1289
+ });
1290
+ const connectConfig = buildConnectConfig(this.profile, (fingerprint) => {
1291
+ this.observedFingerprint = fingerprint;
1292
+ });
1293
+ this.connecting = new Promise((resolve, reject) => {
1294
+ const client = new Client();
1295
+ this.pendingClient = client;
1296
+ let settled = false;
1297
+ client.on("error", (error) => {
1298
+ if (settled == false) {
1299
+ settled = true;
1300
+ this.pendingClient = null;
1301
+ client.destroy();
1302
+ reject(error);
1303
+ return;
1304
+ }
1305
+ _logger.error(error, "ssh connection error after ready");
1306
+ });
1307
+ client.on("close", () => {
1308
+ if (this.client === client) {
1309
+ _logger.debug("ssh connection closed by the peer");
1310
+ this.client = null;
1311
+ this.droppedAfterReady = true;
1312
+ }
1313
+ });
1314
+ client.once("ready", () => {
1315
+ if (settled == true) {
1316
+ return;
1317
+ }
1318
+ settled = true;
1319
+ this.pendingClient = null;
1320
+ if (this.disposed == true) {
1321
+ client.destroy();
1322
+ reject(new Error(`ssh connection for '${this.profile.name}' was closed during the handshake`));
1323
+ return;
1324
+ }
1325
+ this.client = client;
1326
+ resolve();
1327
+ });
1328
+ client.connect(connectConfig);
1329
+ });
1330
+ try {
1331
+ await this.connecting;
1332
+ _logger.info("ssh connection established");
1333
+ } catch (ex) {
1334
+ _logger.error(ex, "ssh connection failed");
1335
+ let detail = String(ex);
1336
+ if (ex instanceof Error) {
1337
+ detail = ex.message;
1338
+ }
1339
+ throw new Error(`ssh connect failed for '${this.profile.name}' (${this.profile.username}@${this.profile.host}:${this.profile.port}): ${detail}`);
1340
+ } finally {
1341
+ this.connecting = null;
1342
+ }
1343
+ }
1344
+ /**
1345
+ * Screens a command against this profile's policy, then runs it on its own channel.
1346
+ *
1347
+ * The canonical form is what gets screened *and* what gets sent — validating one
1348
+ * string while executing another is a bypass by construction.
1349
+ *
1350
+ * @throws {GuardRejectionError} If the policy refuses the command or the cwd.
1351
+ * @throws {Error} If the connection is closed or the channel cannot be opened.
1352
+ */
1353
+ async exec(options) {
1354
+ const canonicalCommand = canonicalizeCommand(options.command);
1355
+ const verdict = inspectCommand(canonicalCommand, this.profile.policy);
1356
+ if (verdict.allowed == false) {
1357
+ throw new GuardRejectionError(verdict);
1358
+ }
1359
+ if (options.cwd != null) {
1360
+ const cwdVerdict = inspectWorkingDirectory(options.cwd);
1361
+ if (cwdVerdict.allowed == false) {
1362
+ throw new GuardRejectionError(cwdVerdict);
1363
+ }
1364
+ }
1365
+ await this.connect();
1366
+ const client = this.client;
1367
+ if (client == null) {
1368
+ throw new Error(`ssh connection for '${this.profile.name}' is not open`);
1369
+ }
1370
+ const _logger = $logger.child({
1371
+ context: "SshConnection.exec",
1372
+ profile: this.profile.name,
1373
+ cwd: options.cwd
1374
+ });
1375
+ const remoteCommand = buildRemoteCommand(canonicalCommand, options.cwd, options.trackCwd);
1376
+ const hardCap = options.maxOutputCharacters * OUTPUT_HARD_CAP_MULTIPLIER;
1377
+ const startedAt = Date.now();
1378
+ const raw = await new Promise((resolve, reject) => {
1379
+ client.exec(remoteCommand, (error, channel) => {
1380
+ if (error != null) {
1381
+ reject(error);
1382
+ return;
1383
+ }
1384
+ let stdout = "";
1385
+ let stderr = "";
1386
+ let exitCode = null;
1387
+ let signal = void 0;
1388
+ let timedOut = false;
1389
+ let settled = false;
1390
+ let capExceeded = false;
1391
+ const stdoutDecoder = new string_decoder.StringDecoder("utf8");
1392
+ const stderrDecoder = new string_decoder.StringDecoder("utf8");
1393
+ const timeoutTimer = setTimeout(() => {
1394
+ timedOut = true;
1395
+ _logger.warn("command timed out, closing the channel", { timeout_ms: options.timeoutMs });
1396
+ channel.close();
1397
+ }, options.timeoutMs);
1398
+ timeoutTimer.unref();
1399
+ const finish = () => {
1400
+ if (settled == true) {
1401
+ return;
1402
+ }
1403
+ settled = true;
1404
+ clearTimeout(timeoutTimer);
1405
+ stdout += stdoutDecoder.end();
1406
+ stderr += stderrDecoder.end();
1407
+ resolve({ stdout, stderr, exitCode, signal, timedOut });
1408
+ };
1409
+ channel.on("data", (chunk) => {
1410
+ if (capExceeded == true) {
1411
+ return;
1412
+ }
1413
+ stdout += stdoutDecoder.write(chunk);
1414
+ if (stdout.length > hardCap) {
1415
+ capExceeded = true;
1416
+ _logger.warn("stdout exceeded the hard cap, closing the channel", { hard_cap: hardCap });
1417
+ channel.close();
1418
+ }
1419
+ });
1420
+ channel.stderr.on("data", (chunk) => {
1421
+ if (capExceeded == true) {
1422
+ return;
1423
+ }
1424
+ stderr += stderrDecoder.write(chunk);
1425
+ if (stderr.length > hardCap) {
1426
+ capExceeded = true;
1427
+ channel.close();
1428
+ }
1429
+ });
1430
+ channel.on("exit", (code, exitSignal) => {
1431
+ exitCode = code;
1432
+ signal = exitSignal;
1433
+ });
1434
+ channel.on("close", finish);
1435
+ channel.on("error", (channelError) => {
1436
+ clearTimeout(timeoutTimer);
1437
+ if (settled == true) {
1438
+ return;
1439
+ }
1440
+ settled = true;
1441
+ reject(channelError);
1442
+ });
1443
+ channel.eof();
1444
+ });
1445
+ });
1446
+ let output = raw.stdout;
1447
+ let cwd = void 0;
1448
+ if (options.trackCwd == true) {
1449
+ const parsed = extractCwdMarker(raw.stdout);
1450
+ output = parsed.output;
1451
+ cwd = parsed.cwd;
1452
+ }
1453
+ const truncatedStdout = truncateOutput(output, options.maxOutputCharacters);
1454
+ const truncatedStderr = truncateOutput(raw.stderr, options.maxOutputCharacters);
1455
+ const result = {
1456
+ stdout: truncatedStdout.text,
1457
+ stderr: truncatedStderr.text,
1458
+ exitCode: raw.exitCode,
1459
+ timedOut: raw.timedOut,
1460
+ truncated: truncatedStdout.truncated == true || truncatedStderr.truncated == true,
1461
+ durationMs: Date.now() - startedAt
1462
+ };
1463
+ if (raw.signal != null) {
1464
+ result.signal = raw.signal;
1465
+ }
1466
+ if (cwd != null) {
1467
+ result.cwd = cwd;
1468
+ }
1469
+ _logger.debug("command finished", {
1470
+ exit_code: result.exitCode,
1471
+ duration_ms: result.durationMs,
1472
+ timed_out: result.timedOut
1473
+ });
1474
+ return result;
1475
+ }
1476
+ /**
1477
+ * Opens an SFTP channel, hands it to `handler`, and closes it afterwards.
1478
+ *
1479
+ * A channel per operation costs one round trip but keeps a broken transfer from
1480
+ * poisoning later ones — SFTP channels do not recover from a mid-stream error.
1481
+ *
1482
+ * The channel's own `error` event is wired into the returned promise: ssh2 strips its
1483
+ * setup listeners before handing the wrapper over, so an unhandled SFTP protocol error
1484
+ * would otherwise crash the process rather than fail this one call.
1485
+ *
1486
+ * @throws {Error} If the SFTP subsystem cannot be started or the channel errors.
1487
+ */
1488
+ async withSftp(handler) {
1489
+ await this.connect();
1490
+ const client = this.client;
1491
+ if (client == null) {
1492
+ throw new Error(`ssh connection for '${this.profile.name}' is not open`);
1493
+ }
1494
+ const sftp = await new Promise((resolve, reject) => {
1495
+ client.sftp((error, sftpWrapper) => {
1496
+ if (error != null) {
1497
+ reject(error);
1498
+ return;
1499
+ }
1500
+ resolve(sftpWrapper);
1501
+ });
1502
+ });
1503
+ return await new Promise((resolve, reject) => {
1504
+ let settled = false;
1505
+ const finish = (settle) => {
1506
+ if (settled == true) {
1507
+ return;
1508
+ }
1509
+ settled = true;
1510
+ try {
1511
+ sftp.end();
1512
+ } catch (ex) {
1513
+ const _logger = $logger.child({ context: "SshConnection.withSftp", profile: this.profile.name });
1514
+ _logger.error(ex, "failed to close the sftp channel");
1515
+ }
1516
+ settle();
1517
+ };
1518
+ sftp.on("error", (error) => {
1519
+ finish(() => {
1520
+ reject(error);
1521
+ });
1522
+ });
1523
+ handler(sftp).then(
1524
+ (value) => {
1525
+ finish(() => {
1526
+ resolve(value);
1527
+ });
1528
+ },
1529
+ (error) => {
1530
+ finish(() => {
1531
+ reject(error);
1532
+ });
1533
+ }
1534
+ );
1535
+ });
1536
+ }
1537
+ /** Closes the connection for good; a disposed connection will not re-dial. */
1538
+ disconnect() {
1539
+ this.disposed = true;
1540
+ if (this.pendingClient != null) {
1541
+ this.pendingClient.destroy();
1542
+ this.pendingClient = null;
1543
+ }
1544
+ if (this.client == null) {
1545
+ return;
1546
+ }
1547
+ this.client.end();
1548
+ this.client = null;
1549
+ }
1550
+ };
1551
+ var MAX_OPEN_SESSIONS = 32;
1552
+ var SshSession = class {
1553
+ constructor(sessionId, connection) {
1554
+ this.sessionId = sessionId;
1555
+ this.connection = connection;
1556
+ this.execCount = 0;
1557
+ this.openedAt = /* @__PURE__ */ new Date();
1558
+ /** Tail of the serialized operation chain; see `runExclusive`. */
1559
+ this.operationChain = Promise.resolve();
1560
+ this.activeOperationCount = 0;
1561
+ this.cwd = connection.profile.defaultCwd ?? "";
1562
+ this.lastUsedAt = /* @__PURE__ */ new Date();
1563
+ }
1564
+ get profile() {
1565
+ return this.connection.profile;
1566
+ }
1567
+ /** True while an operation is running, so the idle sweeper leaves it alone. */
1568
+ get isBusy() {
1569
+ return this.activeOperationCount > 0;
1570
+ }
1571
+ /** Marks the session as in use. */
1572
+ touch() {
1573
+ this.lastUsedAt = /* @__PURE__ */ new Date();
1574
+ }
1575
+ /**
1576
+ * Runs `operation` with exclusive use of this session.
1577
+ *
1578
+ * Serialized because `cwd` is read-modify-write across a network round trip: two
1579
+ * concurrent calls both read the old directory, and the one that finishes second
1580
+ * writes it back — silently undoing the other's `cd`. Bracketing with `touch()` also
1581
+ * keeps the idle sweeper off a session that is mid-transfer.
1582
+ */
1583
+ async runExclusive(operation) {
1584
+ const previous = this.operationChain;
1585
+ let releaseChain = () => {
1586
+ };
1587
+ this.operationChain = new Promise((resolve) => {
1588
+ releaseChain = resolve;
1589
+ });
1590
+ await previous.catch(() => {
1591
+ });
1592
+ this.activeOperationCount += 1;
1593
+ this.touch();
1594
+ try {
1595
+ return await operation(this.connection);
1596
+ } finally {
1597
+ this.activeOperationCount -= 1;
1598
+ this.touch();
1599
+ releaseChain();
1600
+ }
1601
+ }
1602
+ /**
1603
+ * Runs a command in this session's working directory and adopts wherever it ended up,
1604
+ * so a `cd` in one call is still in effect on the next.
1605
+ *
1606
+ * Passing `cwd` runs this command elsewhere *and* moves the session there — same
1607
+ * result as prefixing `cd <cwd> &&`, which is what a shell user would expect.
1608
+ *
1609
+ * @throws {Error} If the connection was dropped since the session opened. A silent
1610
+ * re-dial would hand back a fresh login shell while the model still
1611
+ * believes it holds the old one — environment, sudo cache and
1612
+ * background jobs all gone with no signal.
1613
+ */
1614
+ async exec(command, options) {
1615
+ if (this.connection.wasDropped == true) {
1616
+ throw new Error(`session ${this.sessionId} lost its connection to ${this.profile.host}; its shell state is gone \u2014 open a new session with ssh_connect`);
1617
+ }
1618
+ const policy = this.profile.policy;
1619
+ const targetCwd = options?.cwd ?? this.cwd;
1620
+ return await this.runExclusive(async (connection) => {
1621
+ const result = await connection.exec({
1622
+ command,
1623
+ cwd: targetCwd,
1624
+ timeoutMs: options?.timeoutMs ?? policy.execTimeoutMs,
1625
+ maxOutputCharacters: policy.maxOutputCharacters,
1626
+ trackCwd: true
1627
+ });
1628
+ if (result.cwd != null && result.cwd !== "") {
1629
+ const verdict = inspectWorkingDirectory(result.cwd);
1630
+ if (verdict.allowed == true) {
1631
+ this.cwd = result.cwd;
1632
+ } else {
1633
+ const _logger = $logger.child({ context: "SshSession.exec", session_id: this.sessionId });
1634
+ _logger.warn("remote working directory contains unsafe characters, keeping the previous cwd", {
1635
+ reported_cwd: result.cwd
1636
+ });
1637
+ }
1638
+ }
1639
+ this.execCount += 1;
1640
+ return result;
1641
+ });
1642
+ }
1643
+ toInfo() {
1644
+ let displayCwd = this.cwd;
1645
+ if (displayCwd === "") {
1646
+ displayCwd = "(login default)";
1647
+ }
1648
+ return {
1649
+ sessionId: this.sessionId,
1650
+ profileName: this.profile.name,
1651
+ host: this.profile.host,
1652
+ username: this.profile.username,
1653
+ cwd: displayCwd,
1654
+ openedAt: this.openedAt.toISOString(),
1655
+ lastUsedAt: this.lastUsedAt.toISOString(),
1656
+ execCount: this.execCount
1657
+ };
1658
+ }
1659
+ };
1660
+ var SshSessionManager = class {
1661
+ constructor() {
1662
+ this.sessions = /* @__PURE__ */ new Map();
1663
+ this.sequence = 0;
1664
+ this.pendingOpenCount = 0;
1665
+ this.sweepTimer = null;
1666
+ }
1667
+ /**
1668
+ * Opens a connection and registers it as a session.
1669
+ *
1670
+ * `pendingOpenCount` reserves the slot across the `await`: checking `sessions.size`
1671
+ * alone let N concurrent calls all observe the same pre-connect count and blow past
1672
+ * the ceiling together.
1673
+ *
1674
+ * @throws {Error} If the session ceiling is reached or the connection fails.
1675
+ */
1676
+ async open(profile) {
1677
+ if (this.sessions.size + this.pendingOpenCount >= MAX_OPEN_SESSIONS) {
1678
+ throw new Error(`too many open ssh sessions (${MAX_OPEN_SESSIONS}), close some with ssh_disconnect first`);
1679
+ }
1680
+ this.sequence += 1;
1681
+ const sessionId = `${profile.name}#${this.sequence}`;
1682
+ const connection = new SshConnection(profile);
1683
+ this.pendingOpenCount += 1;
1684
+ try {
1685
+ await connection.connect();
1686
+ } finally {
1687
+ this.pendingOpenCount -= 1;
1688
+ }
1689
+ const session = new SshSession(sessionId, connection);
1690
+ this.sessions.set(sessionId, session);
1691
+ const _logger = $logger.child({ context: "SshSessionManager.open", session_id: sessionId, profile: profile.name });
1692
+ _logger.info("ssh session opened", { open_sessions: this.sessions.size });
1693
+ return session;
1694
+ }
1695
+ /**
1696
+ * Looks up a session, failing with the list of live ids so the caller can self-correct.
1697
+ *
1698
+ * @throws {Error} If no session matches `sessionId`.
1699
+ */
1700
+ require(sessionId) {
1701
+ const session = this.sessions.get(sessionId);
1702
+ if (session == null) {
1703
+ const openIds = Array.from(this.sessions.keys());
1704
+ let openSummary = "none";
1705
+ if (openIds.length > 0) {
1706
+ openSummary = openIds.join(", ");
1707
+ }
1708
+ throw new Error(`unknown ssh session '${sessionId}' (open sessions: ${openSummary})`);
1709
+ }
1710
+ return session;
1711
+ }
1712
+ close(sessionId) {
1713
+ const session = this.sessions.get(sessionId);
1714
+ if (session == null) {
1715
+ return false;
1716
+ }
1717
+ session.connection.disconnect();
1718
+ this.sessions.delete(sessionId);
1719
+ const _logger = $logger.child({ context: "SshSessionManager.close", session_id: sessionId });
1720
+ _logger.info("ssh session closed", { open_sessions: this.sessions.size });
1721
+ return true;
1722
+ }
1723
+ closeAll() {
1724
+ for (const sessionId of Array.from(this.sessions.keys())) {
1725
+ this.close(sessionId);
1726
+ }
1727
+ }
1728
+ list() {
1729
+ return Array.from(this.sessions.values()).map((session) => session.toInfo());
1730
+ }
1731
+ /** Starts the idle sweeper; the timer is unref'd so it never holds the process open. */
1732
+ startIdleSweeper() {
1733
+ if (this.sweepTimer != null) {
1734
+ return;
1735
+ }
1736
+ this.sweepTimer = timers.setInterval(() => {
1737
+ const expiredAt = Date.now() - SESSION_IDLE_TIMEOUT_MS;
1738
+ for (const session of Array.from(this.sessions.values())) {
1739
+ if (session.isBusy == true) {
1740
+ continue;
1741
+ }
1742
+ if (session.lastUsedAt.getTime() > expiredAt) {
1743
+ continue;
1744
+ }
1745
+ const _logger = $logger.child({
1746
+ context: "SshSessionManager.sweep",
1747
+ session_id: session.sessionId,
1748
+ idle_ms: Date.now() - session.lastUsedAt.getTime()
1749
+ });
1750
+ _logger.info("closing idle ssh session");
1751
+ this.close(session.sessionId);
1752
+ }
1753
+ }, SESSION_SWEEP_INTERVAL_MS);
1754
+ this.sweepTimer.unref();
1755
+ }
1756
+ stopIdleSweeper() {
1757
+ if (this.sweepTimer == null) {
1758
+ return;
1759
+ }
1760
+ timers.clearInterval(this.sweepTimer);
1761
+ this.sweepTimer = null;
1762
+ }
1763
+ };
1764
+ function requirePermittedPath(connection, remotePath, access) {
1765
+ const verdict = inspectRemotePath(remotePath, connection.profile.policy, access);
1766
+ if (verdict.allowed == false) {
1767
+ throw new GuardRejectionError(verdict);
1768
+ }
1769
+ }
1770
+ function toEntryType(attrs) {
1771
+ if (typeof attrs.mode !== "number") {
1772
+ return "other";
1773
+ }
1774
+ if (attrs.isDirectory() == true) {
1775
+ return "directory";
1776
+ }
1777
+ if (attrs.isSymbolicLink() == true) {
1778
+ return "symlink";
1779
+ }
1780
+ if (attrs.isFile() == true) {
1781
+ return "file";
1782
+ }
1783
+ return "other";
1784
+ }
1785
+ function toRemoteEntry(entry) {
1786
+ const attrs = entry.attrs;
1787
+ let mode = "?";
1788
+ if (typeof attrs.mode === "number") {
1789
+ mode = `0${(attrs.mode & 4095).toString(8)}`;
1790
+ }
1791
+ let sizeBytes = 0;
1792
+ if (typeof attrs.size === "number" && Number.isFinite(attrs.size) == true) {
1793
+ sizeBytes = attrs.size;
1794
+ }
1795
+ let modifiedAt = "unknown";
1796
+ if (typeof attrs.mtime === "number" && Number.isFinite(attrs.mtime) == true) {
1797
+ modifiedAt = new Date(attrs.mtime * 1e3).toISOString();
1798
+ }
1799
+ return {
1800
+ name: entry.filename,
1801
+ type: toEntryType(attrs),
1802
+ sizeBytes,
1803
+ mode,
1804
+ modifiedAt
1805
+ };
1806
+ }
1807
+ async function listRemoteDirectory(connection, remotePath) {
1808
+ requirePermittedPath(connection, remotePath, "read");
1809
+ return await connection.withSftp(async (sftp) => {
1810
+ const entries = await new Promise((resolve, reject) => {
1811
+ sftp.readdir(remotePath, (error, list) => {
1812
+ if (error != null) {
1813
+ reject(error);
1814
+ return;
1815
+ }
1816
+ resolve(list);
1817
+ });
1818
+ });
1819
+ const mapped = entries.map(toRemoteEntry);
1820
+ mapped.sort((left, right) => {
1821
+ if (left.type === "directory" && right.type !== "directory") {
1822
+ return -1;
1823
+ }
1824
+ if (left.type !== "directory" && right.type === "directory") {
1825
+ return 1;
1826
+ }
1827
+ return left.name.localeCompare(right.name);
1828
+ });
1829
+ return {
1830
+ entries: mapped.slice(0, MAX_DIRECTORY_ENTRIES),
1831
+ totalCount: mapped.length,
1832
+ truncated: mapped.length > MAX_DIRECTORY_ENTRIES
1833
+ };
1834
+ });
1835
+ }
1836
+ async function readRemoteFile(connection, remotePath, maxBytes) {
1837
+ requirePermittedPath(connection, remotePath, "read");
1838
+ return await connection.withSftp(async (sftp) => {
1839
+ const attrs = await new Promise((resolve, reject) => {
1840
+ sftp.stat(remotePath, (error, stats) => {
1841
+ if (error != null) {
1842
+ reject(error);
1843
+ return;
1844
+ }
1845
+ resolve(stats);
1846
+ });
1847
+ });
1848
+ if (attrs.isDirectory() == true) {
1849
+ throw new Error(`${remotePath} is a directory, use ssh_list_dir instead`);
1850
+ }
1851
+ let declaredSize = 0;
1852
+ if (typeof attrs.size === "number" && Number.isFinite(attrs.size) == true) {
1853
+ declaredSize = attrs.size;
1854
+ }
1855
+ const isSizeKnown = declaredSize > 0;
1856
+ const streamOptions = {};
1857
+ if (isSizeKnown == true) {
1858
+ streamOptions.start = 0;
1859
+ streamOptions.end = Math.min(declaredSize, maxBytes) - 1;
1860
+ }
1861
+ const chunks = [];
1862
+ let collectedBytes = 0;
1863
+ await new Promise((resolve, reject) => {
1864
+ const stream = sftp.createReadStream(remotePath, streamOptions);
1865
+ stream.on("data", (chunk) => {
1866
+ chunks.push(chunk);
1867
+ collectedBytes += chunk.length;
1868
+ if (collectedBytes >= maxBytes) {
1869
+ stream.destroy();
1870
+ resolve();
1871
+ }
1872
+ });
1873
+ stream.on("error", (error) => {
1874
+ reject(error);
1875
+ });
1876
+ stream.on("end", () => {
1877
+ resolve();
1878
+ });
1879
+ stream.on("close", () => {
1880
+ resolve();
1881
+ });
1882
+ });
1883
+ const buffer = Buffer.concat(chunks, Math.min(collectedBytes, maxBytes));
1884
+ let truncated = false;
1885
+ if (isSizeKnown == true) {
1886
+ truncated = declaredSize > buffer.length;
1887
+ } else {
1888
+ truncated = buffer.length >= maxBytes;
1889
+ }
1890
+ const decoder = new string_decoder.StringDecoder("utf8");
1891
+ const content = decoder.write(buffer) + decoder.end();
1892
+ return {
1893
+ content,
1894
+ sizeBytes: declaredSize,
1895
+ readBytes: buffer.length,
1896
+ truncated
1897
+ };
1898
+ });
1899
+ }
1900
+ async function writeRemoteFile(connection, args) {
1901
+ const { remotePath, content, append } = args;
1902
+ requirePermittedPath(connection, remotePath, "write");
1903
+ const payload = Buffer.from(content, "utf8");
1904
+ await connection.withSftp(async (sftp) => {
1905
+ await new Promise((resolve, reject) => {
1906
+ const callback = (error) => {
1907
+ if (error != null) {
1908
+ reject(error);
1909
+ return;
1910
+ }
1911
+ resolve();
1912
+ };
1913
+ if (append == true) {
1914
+ sftp.appendFile(remotePath, payload, callback);
1915
+ return;
1916
+ }
1917
+ sftp.writeFile(remotePath, payload, callback);
1918
+ });
1919
+ });
1920
+ const _logger = $logger.child({
1921
+ context: "writeRemoteFile",
1922
+ profile: connection.profile.name,
1923
+ remote_path: remotePath,
1924
+ append
1925
+ });
1926
+ _logger.info("remote file written", { bytes: payload.length });
1927
+ return payload.length;
1928
+ }
1929
+ async function uploadFile(connection, localPath, remotePath) {
1930
+ requirePermittedPath(connection, remotePath, "write");
1931
+ const localStats = fs4__default.default.statSync(localPath);
1932
+ if (localStats.isFile() == false) {
1933
+ throw new Error(`local path is not a file: ${localPath}`);
1934
+ }
1935
+ await connection.withSftp(async (sftp) => {
1936
+ await new Promise((resolve, reject) => {
1937
+ sftp.fastPut(localPath, remotePath, (error) => {
1938
+ if (error != null) {
1939
+ reject(error);
1940
+ return;
1941
+ }
1942
+ resolve();
1943
+ });
1944
+ });
1945
+ });
1946
+ const _logger = $logger.child({
1947
+ context: "uploadFile",
1948
+ profile: connection.profile.name,
1949
+ local_path: localPath,
1950
+ remote_path: remotePath
1951
+ });
1952
+ _logger.info("file uploaded", { bytes: localStats.size });
1953
+ return localStats.size;
1954
+ }
1955
+ async function downloadFile(connection, remotePath, localPath) {
1956
+ requirePermittedPath(connection, remotePath, "read");
1957
+ await connection.withSftp(async (sftp) => {
1958
+ await new Promise((resolve, reject) => {
1959
+ sftp.fastGet(remotePath, localPath, (error) => {
1960
+ if (error != null) {
1961
+ reject(error);
1962
+ return;
1963
+ }
1964
+ resolve();
1965
+ });
1966
+ });
1967
+ });
1968
+ const localStats = fs4__default.default.statSync(localPath);
1969
+ const _logger = $logger.child({
1970
+ context: "downloadFile",
1971
+ profile: connection.profile.name,
1972
+ remote_path: remotePath,
1973
+ local_path: localPath
1974
+ });
1975
+ _logger.info("file downloaded", { bytes: localStats.size });
1976
+ return localStats.size;
1977
+ }
1978
+ var POLICY_INPUT_SCHEMA = zod.z.strictObject({
1979
+ readonly: zod.z.boolean().optional(),
1980
+ allowSudo: zod.z.boolean().optional(),
1981
+ execTimeoutMs: zod.z.number().int().positive().optional(),
1982
+ connectTimeoutMs: zod.z.number().int().positive().optional(),
1983
+ maxOutputCharacters: zod.z.number().int().positive().optional(),
1984
+ maxReadFileBytes: zod.z.number().int().positive().optional(),
1985
+ allowCommands: zod.z.array(zod.z.string()).optional(),
1986
+ denyPatterns: zod.z.array(zod.z.string()).optional(),
1987
+ allowedPaths: zod.z.array(zod.z.string()).optional()
1988
+ });
1989
+ var HOST_INPUT_SCHEMA = POLICY_INPUT_SCHEMA.extend({
1990
+ // Kept strict by `.extend()` on a strict base — verified by the config tests.
1991
+ host: zod.z.string().min(1),
1992
+ port: zod.z.number().int().min(1).max(65535).optional(),
1993
+ username: zod.z.string().min(1),
1994
+ description: zod.z.string().optional(),
1995
+ privateKeyPath: zod.z.string().optional(),
1996
+ privateKey: zod.z.string().optional(),
1997
+ passphrase: zod.z.string().optional(),
1998
+ /** Name of an env var holding the passphrase, preferred over inlining it. */
1999
+ passphraseEnv: zod.z.string().optional(),
2000
+ password: zod.z.string().optional(),
2001
+ /** Name of an env var holding the password, preferred over inlining it. */
2002
+ passwordEnv: zod.z.string().optional(),
2003
+ /** Pinned server host key, e.g. "SHA256:abc123…". */
2004
+ hostKeyFingerprint: zod.z.string().optional(),
2005
+ defaultCwd: zod.z.string().optional()
2006
+ });
2007
+ var CONFIG_INPUT_SCHEMA = zod.z.strictObject({
2008
+ /** Ignored at runtime; present so editors can attach a JSON schema. */
2009
+ $schema: zod.z.string().optional(),
2010
+ defaults: POLICY_INPUT_SCHEMA.optional(),
2011
+ hosts: zod.z.record(zod.z.string(), HOST_INPUT_SCHEMA)
2012
+ });
2013
+
2014
+ // src/modules/config/env-host.ts
2015
+ function readEnv(name) {
2016
+ const value = process.env[name];
2017
+ if (value == null) {
2018
+ return void 0;
2019
+ }
2020
+ const trimmed = value.trim();
2021
+ if (trimmed === "") {
2022
+ return void 0;
2023
+ }
2024
+ return trimmed;
2025
+ }
2026
+ function readBooleanEnv(name) {
2027
+ const value = readEnv(name);
2028
+ if (value == null) {
2029
+ return void 0;
2030
+ }
2031
+ const normalized = value.toLowerCase();
2032
+ if (TRUTHY_ENV_VALUES.includes(normalized) == true) {
2033
+ return true;
2034
+ }
2035
+ if (FALSY_ENV_VALUES.includes(normalized) == false) {
2036
+ throw new Error(`${name} must be one of ${[...TRUTHY_ENV_VALUES, ...FALSY_ENV_VALUES].join(", ")} (got "${value}")`);
2037
+ }
2038
+ return false;
2039
+ }
2040
+ function readIntegerEnv(name) {
2041
+ const value = readEnv(name);
2042
+ if (value == null) {
2043
+ return void 0;
2044
+ }
2045
+ const parsed = Number(value);
2046
+ if (Number.isInteger(parsed) == false || parsed <= 0) {
2047
+ throw new Error(`${name} must be a positive integer (got "${value}")`);
2048
+ }
2049
+ return parsed;
2050
+ }
2051
+ function readListEnv(name) {
2052
+ const value = readEnv(name);
2053
+ if (value == null) {
2054
+ return void 0;
2055
+ }
2056
+ const items = value.split(",").map((item) => item.trim()).filter((item) => item !== "");
2057
+ if (items.length === 0) {
2058
+ return void 0;
2059
+ }
2060
+ return items;
2061
+ }
2062
+ function buildConfigFromEnv() {
2063
+ const host = readEnv(SINGLE_HOST_ENV.HOST);
2064
+ if (host == null) {
2065
+ return null;
2066
+ }
2067
+ const username = readEnv(SINGLE_HOST_ENV.USER);
2068
+ if (username == null) {
2069
+ throw new Error(`${SINGLE_HOST_ENV.HOST} is set, so ${SINGLE_HOST_ENV.USER} is required`);
2070
+ }
2071
+ const hostInput = {
2072
+ host,
2073
+ username,
2074
+ port: readIntegerEnv(SINGLE_HOST_ENV.PORT),
2075
+ description: readEnv(SINGLE_HOST_ENV.DESCRIPTION),
2076
+ privateKeyPath: readEnv(SINGLE_HOST_ENV.KEY),
2077
+ passphrase: readEnv(SINGLE_HOST_ENV.PASSPHRASE),
2078
+ password: readEnv(SINGLE_HOST_ENV.PASSWORD),
2079
+ hostKeyFingerprint: readEnv(SINGLE_HOST_ENV.HOST_KEY),
2080
+ defaultCwd: readEnv(SINGLE_HOST_ENV.CWD),
2081
+ readonly: readBooleanEnv(SINGLE_HOST_ENV.READONLY),
2082
+ allowSudo: readBooleanEnv(SINGLE_HOST_ENV.ALLOW_SUDO),
2083
+ allowCommands: readListEnv(SINGLE_HOST_ENV.ALLOW_COMMANDS),
2084
+ denyPatterns: readListEnv(SINGLE_HOST_ENV.DENY_PATTERNS),
2085
+ allowedPaths: readListEnv(SINGLE_HOST_ENV.ALLOWED_PATHS),
2086
+ execTimeoutMs: readIntegerEnv(SINGLE_HOST_ENV.EXEC_TIMEOUT_MS),
2087
+ connectTimeoutMs: readIntegerEnv(SINGLE_HOST_ENV.CONNECT_TIMEOUT_MS),
2088
+ maxOutputCharacters: readIntegerEnv(SINGLE_HOST_ENV.MAX_OUTPUT_CHARACTERS),
2089
+ maxReadFileBytes: readIntegerEnv(SINGLE_HOST_ENV.MAX_READ_FILE_BYTES)
2090
+ };
2091
+ const name = readEnv(SINGLE_HOST_ENV.NAME) ?? host;
2092
+ const _logger = $logger.child({ context: "buildConfigFromEnv", profile: name });
2093
+ _logger.debug("single-host config assembled from environment variables");
2094
+ return { hosts: { [name]: hostInput } };
2095
+ }
2096
+
2097
+ // src/modules/config/load-config.ts
2098
+ function resolveConfigSource(explicitPath) {
2099
+ if (explicitPath != null && explicitPath.trim() !== "") {
2100
+ return { kind: "file", path: expandHomePath(explicitPath) };
2101
+ }
2102
+ const fromSingleHostEnv = buildConfigFromEnv();
2103
+ if (fromSingleHostEnv != null) {
2104
+ return { kind: "env", config: fromSingleHostEnv };
2105
+ }
2106
+ const fromEnv = process.env[CONFIG_PATH_ENV];
2107
+ if (fromEnv != null && fromEnv.trim() !== "") {
2108
+ const trimmedValue = fromEnv.trim();
2109
+ if (trimmedValue.startsWith("{") == true) {
2110
+ return { kind: "inline", json: trimmedValue };
2111
+ }
2112
+ return { kind: "file", path: expandHomePath(trimmedValue) };
2113
+ }
2114
+ for (const filename of DEFAULT_CONFIG_FILENAMES) {
2115
+ const candidate = path2__default.default.join(os__default.default.homedir(), filename);
2116
+ if (fs4__default.default.existsSync(candidate) == true) {
2117
+ return { kind: "file", path: candidate };
2118
+ }
2119
+ }
2120
+ return { kind: "none" };
2121
+ }
2122
+ function compileDenyPatterns(profileName, sources) {
2123
+ return sources.map((source) => {
2124
+ try {
2125
+ return new RegExp(source, "i");
2126
+ } catch (ex) {
2127
+ const _logger = $logger.child({ context: "compileDenyPatterns", profile: profileName });
2128
+ _logger.error(ex, "denyPatterns entry is not a valid regular expression");
2129
+ let detail = String(ex);
2130
+ if (ex instanceof Error) {
2131
+ detail = ex.message;
2132
+ }
2133
+ throw new Error(`profile '${profileName}' has an invalid denyPatterns entry /${source}/: ${detail}`);
2134
+ }
2135
+ });
2136
+ }
2137
+ function resolvePolicy(profileName, defaults, override) {
2138
+ const allowCommands = [...override.allowCommands ?? defaults?.allowCommands ?? []];
2139
+ if (allowCommands.length > 0 && allowCommands.includes("pwd") == false) {
2140
+ allowCommands.push("pwd");
2141
+ }
2142
+ const denyPatternSources = override.denyPatterns ?? defaults?.denyPatterns ?? [];
2143
+ const allowedPaths = override.allowedPaths ?? defaults?.allowedPaths ?? [];
2144
+ const _logger = $logger.child({ context: "resolvePolicy", profile: profileName });
2145
+ if (override.allowCommands?.length === 0 && (defaults?.allowCommands?.length ?? 0) > 0) {
2146
+ _logger.warn("host sets allowCommands to an empty list, which removes the restriction from defaults");
2147
+ }
2148
+ if (override.allowedPaths?.length === 0 && (defaults?.allowedPaths?.length ?? 0) > 0) {
2149
+ _logger.warn("host sets allowedPaths to an empty list, which removes the restriction from defaults");
2150
+ }
2151
+ return {
2152
+ readonly: override.readonly ?? defaults?.readonly ?? false,
2153
+ allowSudo: override.allowSudo ?? defaults?.allowSudo ?? true,
2154
+ execTimeoutMs: override.execTimeoutMs ?? defaults?.execTimeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS,
2155
+ connectTimeoutMs: override.connectTimeoutMs ?? defaults?.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
2156
+ maxOutputCharacters: override.maxOutputCharacters ?? defaults?.maxOutputCharacters ?? DEFAULT_MAX_OUTPUT_CHARACTERS,
2157
+ maxReadFileBytes: override.maxReadFileBytes ?? defaults?.maxReadFileBytes ?? DEFAULT_MAX_READ_FILE_BYTES,
2158
+ allowCommands,
2159
+ denyPatterns: compileDenyPatterns(profileName, denyPatternSources),
2160
+ allowedPaths
2161
+ };
2162
+ }
2163
+ function resolveSecret(profileName, inlineValue, envVarName) {
2164
+ if (envVarName == null) {
2165
+ return inlineValue;
2166
+ }
2167
+ const fromEnv = process.env[envVarName];
2168
+ if (fromEnv == null || fromEnv === "") {
2169
+ const _logger = $logger.child({ context: "resolveSecret", profile: profileName, env_var: envVarName });
2170
+ _logger.warn("env var referenced by the profile is empty; falling back to the inline value");
2171
+ return inlineValue;
2172
+ }
2173
+ return fromEnv;
2174
+ }
2175
+ function toHostProfile(name, raw, defaults) {
2176
+ const profile = {
2177
+ name,
2178
+ host: raw.host,
2179
+ port: raw.port ?? DEFAULT_SSH_PORT,
2180
+ username: raw.username,
2181
+ description: raw.description,
2182
+ privateKey: raw.privateKey,
2183
+ passphrase: resolveSecret(name, raw.passphrase, raw.passphraseEnv),
2184
+ password: resolveSecret(name, raw.password, raw.passwordEnv),
2185
+ hostKeyFingerprint: raw.hostKeyFingerprint,
2186
+ defaultCwd: raw.defaultCwd,
2187
+ policy: resolvePolicy(name, defaults, raw)
2188
+ };
2189
+ if (raw.privateKeyPath != null) {
2190
+ const expandedKeyPath = expandHomePath(raw.privateKeyPath);
2191
+ profile.privateKeyPath = expandedKeyPath;
2192
+ if (fs4__default.default.existsSync(expandedKeyPath) == false) {
2193
+ const _logger = $logger.child({ context: "toHostProfile", profile: name });
2194
+ _logger.warn("privateKeyPath does not exist; connections for this profile will fail", {
2195
+ private_key_path: expandedKeyPath
2196
+ });
2197
+ }
2198
+ }
2199
+ return profile;
2200
+ }
2201
+ function warnOnLoosePermissions(configPath) {
2202
+ if (process.platform === "win32") {
2203
+ return;
2204
+ }
2205
+ const stats = fs4__default.default.statSync(configPath);
2206
+ const permissionBits = stats.mode & 511;
2207
+ if ((permissionBits & 63) === 0) {
2208
+ return;
2209
+ }
2210
+ const _logger = $logger.child({ context: "warnOnLoosePermissions", config_path: configPath });
2211
+ _logger.warn(`config file is readable by other users, run: chmod 600 ${configPath}`, {
2212
+ mode: permissionBits.toString(8)
2213
+ });
2214
+ }
2215
+ function loadConfig(explicitPath) {
2216
+ let _logger = $logger.child({ context: "loadConfig" });
2217
+ const source = resolveConfigSource(explicitPath);
2218
+ if (source.kind === "none") {
2219
+ _logger.warn("no config found; no host profiles are registered", {
2220
+ searched: DEFAULT_CONFIG_FILENAMES,
2221
+ env_vars: [SINGLE_HOST_ENV.HOST, CONFIG_PATH_ENV]
2222
+ });
2223
+ return { profiles: /* @__PURE__ */ new Map(), sourcePath: null, sourceLabel: "none" };
2224
+ }
2225
+ let parsedJson = null;
2226
+ let sourcePath = null;
2227
+ let sourceLabel = `inline ${CONFIG_PATH_ENV}`;
2228
+ if (source.kind === "env") {
2229
+ _logger = _logger.child({ config_source: "env" });
2230
+ sourceLabel = `${SINGLE_HOST_ENV.HOST} environment variables`;
2231
+ parsedJson = source.config;
2232
+ } else {
2233
+ let rawText = "";
2234
+ if (source.kind === "inline") {
2235
+ _logger = _logger.child({ config_source: "inline" });
2236
+ rawText = source.json;
2237
+ } else {
2238
+ _logger = _logger.child({ config_path: source.path });
2239
+ sourcePath = source.path;
2240
+ sourceLabel = source.path;
2241
+ if (fs4__default.default.existsSync(source.path) == false) {
2242
+ _logger.error(null, "config file does not exist");
2243
+ throw new Error(`ssh mcp config not found: ${source.path}`);
2244
+ }
2245
+ rawText = fs4__default.default.readFileSync(source.path, "utf8");
2246
+ }
2247
+ try {
2248
+ parsedJson = JSON.parse(rawText);
2249
+ } catch (ex) {
2250
+ _logger.error(ex, "config is not valid json");
2251
+ throw new Error(`ssh mcp config is not valid JSON (${sourceLabel})`);
2252
+ }
2253
+ }
2254
+ const validated = CONFIG_INPUT_SCHEMA.safeParse(parsedJson);
2255
+ if (validated.success == false) {
2256
+ const issueSummary = validated.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ");
2257
+ _logger.error(validated.error, "config failed schema validation");
2258
+ throw new Error(`ssh mcp config is invalid (${sourceLabel}): ${issueSummary}`);
2259
+ }
2260
+ if (sourcePath != null) {
2261
+ warnOnLoosePermissions(sourcePath);
2262
+ }
2263
+ const profiles = /* @__PURE__ */ new Map();
2264
+ for (const [name, hostInput] of Object.entries(validated.data.hosts)) {
2265
+ profiles.set(name, toHostProfile(name, hostInput, validated.data.defaults));
2266
+ }
2267
+ _logger.info("config loaded", {
2268
+ profile_count: profiles.size,
2269
+ profiles: Array.from(profiles.keys())
2270
+ });
2271
+ return { profiles, sourcePath, sourceLabel };
2272
+ }
2273
+ function toHostSummary(profile) {
2274
+ let authMethod = "agent";
2275
+ if (profile.privateKeyPath != null || profile.privateKey != null) {
2276
+ authMethod = "privateKey";
2277
+ } else if (profile.password != null) {
2278
+ authMethod = "password";
2279
+ }
2280
+ return {
2281
+ name: profile.name,
2282
+ host: profile.host,
2283
+ port: profile.port,
2284
+ username: profile.username,
2285
+ description: profile.description,
2286
+ authMethod,
2287
+ readonly: profile.policy.readonly,
2288
+ allowSudo: profile.policy.allowSudo,
2289
+ allowedPaths: profile.policy.allowedPaths,
2290
+ allowCommands: profile.policy.allowCommands
2291
+ };
2292
+ }
2293
+
2294
+ // src/modules/tools/format.ts
2295
+ var ENTRY_TYPE_MARKERS = {
2296
+ file: "-",
2297
+ directory: "d",
2298
+ symlink: "l",
2299
+ other: "?"
2300
+ };
2301
+ function formatHostList(config) {
2302
+ if (config.profiles.size === 0) {
2303
+ return [
2304
+ "No SSH host profiles are registered.",
2305
+ "",
2306
+ `Put them in this server's MCP entry \u2014 ${CONFIG_PATH_ENV} takes the JSON itself:`,
2307
+ "",
2308
+ ` "env": {`,
2309
+ ` "${CONFIG_PATH_ENV}": "{\\"hosts\\":{\\"my-server\\":{\\"host\\":\\"10.0.0.5\\",\\"username\\":\\"deploy\\",\\"privateKeyPath\\":\\"~/.ssh/id_ed25519\\"}}}"`,
2310
+ " }",
2311
+ "",
2312
+ `It also accepts a path to a JSON file, and ~/${DEFAULT_CONFIG_FILENAMES[0]} is picked up automatically.`,
2313
+ "Restart this server after changing it."
2314
+ ].join("\n");
2315
+ }
2316
+ const lines = [];
2317
+ lines.push(`${config.profiles.size} SSH host profile(s) from ${config.sourceLabel}:`);
2318
+ for (const profile of config.profiles.values()) {
2319
+ const summary = toHostSummary(profile);
2320
+ let accessLabel = "writable";
2321
+ if (summary.readonly == true) {
2322
+ accessLabel = "READ-ONLY";
2323
+ }
2324
+ let sudoLabel = "sudo=disabled";
2325
+ if (summary.allowSudo == true) {
2326
+ sudoLabel = "sudo=enabled";
2327
+ }
2328
+ lines.push("");
2329
+ lines.push(`${summary.name} \u2192 ${summary.username}@${summary.host}:${summary.port} auth=${summary.authMethod} ${accessLabel} ${sudoLabel}`);
2330
+ if (summary.description != null) {
2331
+ lines.push(` ${summary.description}`);
2332
+ }
2333
+ if (summary.allowedPaths.length > 0) {
2334
+ lines.push(` file tools limited to: ${summary.allowedPaths.join(", ")} (does not restrict ssh_exec)`);
2335
+ }
2336
+ if (summary.allowCommands.length > 0) {
2337
+ lines.push(` commands limited to: ${summary.allowCommands.join(", ")}`);
2338
+ }
2339
+ }
2340
+ return lines.join("\n");
2341
+ }
2342
+ function formatExecResult(result, timeoutMs) {
2343
+ const headerParts = [];
2344
+ if (result.exitCode != null) {
2345
+ headerParts.push(`exit code: ${result.exitCode}`);
2346
+ } else {
2347
+ headerParts.push("exit code: none (killed)");
2348
+ }
2349
+ if (result.signal != null) {
2350
+ headerParts.push(`signal: ${result.signal}`);
2351
+ }
2352
+ headerParts.push(`${result.durationMs}ms`);
2353
+ if (result.cwd != null) {
2354
+ headerParts.push(`cwd: ${result.cwd}`);
2355
+ }
2356
+ const lines = [headerParts.join(" \xB7 ")];
2357
+ if (result.timedOut == true) {
2358
+ lines.push(`TIMED OUT after ${timeoutMs}ms \u2014 the channel was closed, but the remote process may still be running.`);
2359
+ }
2360
+ if (result.truncated == true) {
2361
+ lines.push("Output was truncated; narrow the command (grep / head / tail) or raise maxOutputCharacters for this profile.");
2362
+ }
2363
+ lines.push("");
2364
+ lines.push("--- stdout ---");
2365
+ if (result.stdout === "") {
2366
+ lines.push("(empty)");
2367
+ } else {
2368
+ lines.push(result.stdout);
2369
+ }
2370
+ if (result.stderr !== "") {
2371
+ lines.push("");
2372
+ lines.push("--- stderr ---");
2373
+ lines.push(result.stderr);
2374
+ }
2375
+ return lines.join("\n");
2376
+ }
2377
+ function formatGuardRejection(action, verdict) {
2378
+ const lines = [`Blocked by the host profile's guard policy: ${verdict.reason}`];
2379
+ if (verdict.offendingText != null && verdict.offendingText !== action) {
2380
+ lines.push(`Offending part: ${verdict.offendingText}`);
2381
+ }
2382
+ lines.push(`Requested: ${action}`);
2383
+ lines.push("Nothing was sent to the remote host. Adjust the request, or relax the profile's policy in the config file.");
2384
+ return lines.join("\n");
2385
+ }
2386
+ function formatSessionList(sessions) {
2387
+ if (sessions.length === 0) {
2388
+ return "No open SSH sessions. Open one with ssh_connect, or pass 'target' to run a one-off command.";
2389
+ }
2390
+ const lines = [`${sessions.length} open SSH session(s):`];
2391
+ const now = Date.now();
2392
+ for (const session of sessions) {
2393
+ const idleSeconds = Math.round((now - new Date(session.lastUsedAt).getTime()) / 1e3);
2394
+ lines.push("");
2395
+ lines.push(`${session.sessionId} \u2192 ${session.username}@${session.host} profile: ${session.profileName}`);
2396
+ lines.push(` cwd: ${session.cwd} \xB7 ${session.execCount} command(s) \xB7 idle ${idleSeconds}s \xB7 opened ${session.openedAt}`);
2397
+ }
2398
+ return lines.join("\n");
2399
+ }
2400
+ function formatDirectoryListing(remotePath, listing) {
2401
+ if (listing.totalCount === 0) {
2402
+ return `${remotePath} is empty.`;
2403
+ }
2404
+ let header = `${remotePath} \u2014 ${listing.totalCount} entrie(s):`;
2405
+ if (listing.truncated == true) {
2406
+ header = `${remotePath} \u2014 ${listing.totalCount} entrie(s), showing the first ${listing.entries.length}:`;
2407
+ }
2408
+ const lines = [header, ""];
2409
+ for (const entry of listing.entries) {
2410
+ const marker = ENTRY_TYPE_MARKERS[entry.type];
2411
+ const size = formatBytes(entry.sizeBytes).padStart(9);
2412
+ let name = entry.name;
2413
+ if (entry.type === "directory") {
2414
+ name = `${entry.name}/`;
2415
+ }
2416
+ lines.push(`${marker} ${entry.mode} ${size} ${entry.modifiedAt} ${name}`);
2417
+ }
2418
+ if (listing.truncated == true) {
2419
+ lines.push("");
2420
+ lines.push("Listing was capped; narrow the path or filter with ssh_exec if you need the rest.");
2421
+ }
2422
+ return lines.join("\n");
2423
+ }
2424
+
2425
+ // src/modules/tools/tool-context.ts
2426
+ var MAX_EPHEMERAL_CONNECTIONS = 32;
2427
+ var activeEphemeralCount = 0;
2428
+ function textResult(text) {
2429
+ return { content: [{ type: "text", text }] };
2430
+ }
2431
+ function errorResult(message) {
2432
+ return { content: [{ type: "text", text: message }], isError: true };
2433
+ }
2434
+ function describeError(error) {
2435
+ if (error instanceof Error) {
2436
+ return error.message;
2437
+ }
2438
+ return String(error);
2439
+ }
2440
+ function toToolError(error, toolName, action) {
2441
+ const _logger = $logger.child({ context: toolName });
2442
+ if (error instanceof GuardRejectionError) {
2443
+ _logger.warn("rejected by the guard", { reason: error.verdict.reason, action });
2444
+ const rendered = formatGuardRejection(action, error.verdict);
2445
+ return errorResult(rendered);
2446
+ }
2447
+ _logger.error(error, `${toolName} failed`);
2448
+ return errorResult(`${toolName} failed: ${describeError(error)}`);
2449
+ }
2450
+ function requireProfile(context, target) {
2451
+ const profile = context.config.profiles.get(target);
2452
+ if (profile != null) {
2453
+ return profile;
2454
+ }
2455
+ const available = Array.from(context.config.profiles.keys());
2456
+ if (available.length === 0) {
2457
+ throw new Error(`no host profiles are registered \u2014 set ${SINGLE_HOST_ENV.HOST} / ${SINGLE_HOST_ENV.USER} in this server's env block (or ${CONFIG_PATH_ENV} for several hosts), then restart it`);
2458
+ }
2459
+ throw new Error(`unknown host profile '${target}' (registered: ${available.join(", ")})`);
2460
+ }
2461
+ function resolveProfile(context, args) {
2462
+ const hasTarget = args.target != null && args.target.trim() !== "";
2463
+ const hasSession = args.session != null && args.session.trim() !== "";
2464
+ if (hasTarget == true && hasSession == true) {
2465
+ throw new Error("pass either 'target' or 'session', not both \u2014 they can name different hosts with different policies");
2466
+ }
2467
+ if (hasSession == true) {
2468
+ const session = context.sessions.require(args.session.trim());
2469
+ return { profile: session.profile, session };
2470
+ }
2471
+ if (hasTarget == true) {
2472
+ const profile = requireProfile(context, args.target.trim());
2473
+ return { profile, session: null };
2474
+ }
2475
+ const onlyProfile = context.config.profiles.values().next().value;
2476
+ if (context.config.profiles.size === 1 && onlyProfile != null) {
2477
+ return { profile: onlyProfile, session: null };
2478
+ }
2479
+ if (context.config.profiles.size === 0) {
2480
+ throw new Error(`no host profiles are registered \u2014 set ${SINGLE_HOST_ENV.HOST} / ${SINGLE_HOST_ENV.USER} in this server's env block, then restart it`);
2481
+ }
2482
+ const available = Array.from(context.config.profiles.keys());
2483
+ throw new Error(`this server fronts several hosts, so 'target' is required (registered: ${available.join(", ")})`);
2484
+ }
2485
+ async function withHostConnection(profile, session, operation) {
2486
+ if (session != null) {
2487
+ return await session.runExclusive(operation);
2488
+ }
2489
+ if (activeEphemeralCount >= MAX_EPHEMERAL_CONNECTIONS) {
2490
+ throw new Error(`too many concurrent one-off connections (${MAX_EPHEMERAL_CONNECTIONS}); open a session with ssh_connect and reuse it`);
2491
+ }
2492
+ activeEphemeralCount += 1;
2493
+ const connection = new SshConnection(profile);
2494
+ try {
2495
+ return await operation(connection);
2496
+ } finally {
2497
+ try {
2498
+ connection.disconnect();
2499
+ } finally {
2500
+ activeEphemeralCount -= 1;
2501
+ }
2502
+ }
2503
+ }
2504
+ function protectedLocalPathsOf(context) {
2505
+ if (context.config.sourcePath == null) {
2506
+ return [];
2507
+ }
2508
+ return [context.config.sourcePath];
2509
+ }
2510
+
2511
+ // src/modules/tools/exec-tools.ts
2512
+ function registerExecTools(server, context) {
2513
+ server.registerTool(
2514
+ "ssh_exec",
2515
+ {
2516
+ title: "Run a command over SSH",
2517
+ description: [
2518
+ "Run a shell command on a registered SSH host and return stdout, stderr and the exit code.",
2519
+ "With a single configured host, no target is needed. Pass 'session' to reuse an open session (which remembers its working directory), or 'target' to pick a host when several are registered \u2014 one or the other, never both.",
2520
+ "A non-zero exit code is reported as normal output, not as a tool error \u2014 read the exit code and stderr to judge the outcome.",
2521
+ "Almost everything is permitted: package installs, service restarts, sudo, interpreters. Only catastrophic operations are refused outright (wiping the filesystem root, formatting a disk, rebooting, flushing the firewall), and individual profiles may add their own limits."
2522
+ ].join(" "),
2523
+ inputSchema: {
2524
+ command: zod.z.string().min(1).describe("Shell command, exactly as it would be typed in a terminal. Chaining with ';', '&&', '||' and pipes is allowed; each part is screened separately."),
2525
+ target: zod.z.string().optional().describe("Host profile alias. Omit it when this server fronts a single host, which is the usual setup. Opens a one-off connection that is closed afterwards."),
2526
+ session: zod.z.string().optional().describe("Session id from ssh_connect. Reuses that connection and keeps the working directory across calls."),
2527
+ cwd: zod.z.string().optional().describe("Absolute directory to run in, with no shell metacharacters. With a session, the session also moves there."),
2528
+ timeoutMs: zod.z.number().int().positive().optional().describe("Lowers the command timeout for this call; it cannot exceed the profile's own limit. The channel is closed when it elapses; the remote process may survive.")
2529
+ },
2530
+ annotations: {
2531
+ readOnlyHint: false,
2532
+ destructiveHint: true,
2533
+ idempotentHint: false,
2534
+ openWorldHint: true
2535
+ }
2536
+ },
2537
+ async ({ command, target, session, cwd, timeoutMs }) => {
2538
+ let _logger = $logger.child({ context: "ssh_exec" });
2539
+ try {
2540
+ const { profile, session: openSession } = resolveProfile(context, { target, session });
2541
+ _logger = _logger.child({ profile: profile.name, session_id: openSession?.sessionId });
2542
+ const requestedTimeoutMs = timeoutMs ?? profile.policy.execTimeoutMs;
2543
+ const effectiveTimeoutMs = Math.min(requestedTimeoutMs, profile.policy.execTimeoutMs);
2544
+ let requestedCwd = void 0;
2545
+ if (cwd != null && cwd.trim() !== "") {
2546
+ requestedCwd = cwd.trim();
2547
+ }
2548
+ _logger.debug("running command", { command, timeout_ms: effectiveTimeoutMs });
2549
+ let result;
2550
+ if (openSession != null) {
2551
+ result = await openSession.exec(command, { cwd: requestedCwd, timeoutMs: effectiveTimeoutMs });
2552
+ } else {
2553
+ result = await withHostConnection(profile, null, async (connection) => {
2554
+ return await connection.exec({
2555
+ command,
2556
+ cwd: requestedCwd,
2557
+ timeoutMs: effectiveTimeoutMs,
2558
+ maxOutputCharacters: profile.policy.maxOutputCharacters,
2559
+ trackCwd: false
2560
+ });
2561
+ });
2562
+ }
2563
+ _logger.info("command executed", {
2564
+ exit_code: result.exitCode,
2565
+ duration_ms: result.durationMs,
2566
+ timed_out: result.timedOut
2567
+ });
2568
+ const rendered = formatExecResult(result, effectiveTimeoutMs);
2569
+ return textResult(rendered);
2570
+ } catch (ex) {
2571
+ return toToolError(ex, "ssh_exec", command);
2572
+ }
2573
+ }
2574
+ );
2575
+ }
2576
+ var TARGET_DESCRIPTION = "Host profile alias. Omit it when this server fronts a single host, which is the usual setup.";
2577
+ var SESSION_DESCRIPTION = "Session id from ssh_connect, whose connection is reused. Pass this or 'target', not both.";
2578
+ function requirePermittedLocalPath(context, localPath, access) {
2579
+ const verdict = inspectLocalPath(localPath, access, protectedLocalPathsOf(context));
2580
+ if (verdict.allowed == false) {
2581
+ throw new GuardRejectionError(verdict);
2582
+ }
2583
+ }
2584
+ function registerFileTools(server, context) {
2585
+ server.registerTool(
2586
+ "ssh_list_dir",
2587
+ {
2588
+ title: "List a remote directory",
2589
+ description: [
2590
+ "List a directory on a registered SSH host over SFTP, with type, permissions, size and mtime.",
2591
+ "Cheaper and more structured than running 'ls -la' through ssh_exec, and it works on read-only profiles."
2592
+ ].join(" "),
2593
+ inputSchema: {
2594
+ path: zod.z.string().min(1).describe("Absolute remote directory path. Profiles with allowedPaths set accept only paths inside them."),
2595
+ target: zod.z.string().optional().describe(TARGET_DESCRIPTION),
2596
+ session: zod.z.string().optional().describe(SESSION_DESCRIPTION)
2597
+ },
2598
+ annotations: {
2599
+ readOnlyHint: true,
2600
+ destructiveHint: false,
2601
+ idempotentHint: true,
2602
+ openWorldHint: true
2603
+ }
2604
+ },
2605
+ async ({ path: remotePath, target, session }) => {
2606
+ const _logger = $logger.child({ context: "ssh_list_dir", remote_path: remotePath });
2607
+ try {
2608
+ const { profile, session: openSession } = resolveProfile(context, { target, session });
2609
+ const listing = await withHostConnection(profile, openSession, async (connection) => {
2610
+ return await listRemoteDirectory(connection, remotePath);
2611
+ });
2612
+ _logger.info("remote directory listed", {
2613
+ profile: profile.name,
2614
+ entry_count: listing.totalCount,
2615
+ truncated: listing.truncated
2616
+ });
2617
+ const rendered = formatDirectoryListing(remotePath, listing);
2618
+ return textResult(rendered);
2619
+ } catch (ex) {
2620
+ return toToolError(ex, "ssh_list_dir", `list ${remotePath}`);
2621
+ }
2622
+ }
2623
+ );
2624
+ server.registerTool(
2625
+ "ssh_read_file",
2626
+ {
2627
+ title: "Read a remote file",
2628
+ description: [
2629
+ "Read a remote text file over SFTP and return its contents.",
2630
+ "Oversized files come back truncated to their leading bytes rather than failing, so pointing this at a large log is safe.",
2631
+ "Files that report size 0 but hold content (/proc, /sys) are read in full up to the ceiling."
2632
+ ].join(" "),
2633
+ inputSchema: {
2634
+ path: zod.z.string().min(1).describe("Absolute remote file path."),
2635
+ target: zod.z.string().optional().describe(TARGET_DESCRIPTION),
2636
+ session: zod.z.string().optional().describe(SESSION_DESCRIPTION),
2637
+ maxBytes: zod.z.number().int().positive().optional().describe("Lowers the read ceiling for this call; it cannot exceed the profile's maxReadFileBytes.")
2638
+ },
2639
+ annotations: {
2640
+ readOnlyHint: true,
2641
+ destructiveHint: false,
2642
+ idempotentHint: true,
2643
+ openWorldHint: true
2644
+ }
2645
+ },
2646
+ async ({ path: remotePath, target, session, maxBytes }) => {
2647
+ const _logger = $logger.child({ context: "ssh_read_file", remote_path: remotePath });
2648
+ try {
2649
+ const { profile, session: openSession } = resolveProfile(context, { target, session });
2650
+ const requestedLimit = maxBytes ?? profile.policy.maxReadFileBytes;
2651
+ const readLimit = Math.min(requestedLimit, profile.policy.maxReadFileBytes);
2652
+ const file = await withHostConnection(profile, openSession, async (connection) => {
2653
+ return await readRemoteFile(connection, remotePath, readLimit);
2654
+ });
2655
+ let header = `${remotePath} \u2014 ${formatBytes(file.readBytes)} read`;
2656
+ if (file.sizeBytes > 0) {
2657
+ header = `${remotePath} \u2014 ${formatBytes(file.sizeBytes)}`;
2658
+ }
2659
+ if (file.truncated == true) {
2660
+ header += `, truncated to the first ${formatBytes(file.readBytes)}`;
2661
+ }
2662
+ _logger.info("remote file read", {
2663
+ profile: profile.name,
2664
+ size_bytes: file.sizeBytes,
2665
+ read_bytes: file.readBytes,
2666
+ truncated: file.truncated
2667
+ });
2668
+ return textResult(`${header}
2669
+
2670
+ ${file.content}`);
2671
+ } catch (ex) {
2672
+ return toToolError(ex, "ssh_read_file", `read ${remotePath}`);
2673
+ }
2674
+ }
2675
+ );
2676
+ server.registerTool(
2677
+ "ssh_write_file",
2678
+ {
2679
+ title: "Write a remote file",
2680
+ description: [
2681
+ "Write or append UTF-8 text to a file on a registered SSH host over SFTP.",
2682
+ "Use this instead of 'echo ... > file' through ssh_exec: no shell quoting to get wrong, and it works with multi-line content.",
2683
+ "Refused on read-only profiles. The parent directory must already exist."
2684
+ ].join(" "),
2685
+ inputSchema: {
2686
+ path: zod.z.string().min(1).describe("Absolute remote file path. Existing content is replaced unless 'append' is true."),
2687
+ content: zod.z.string().describe("UTF-8 text to write."),
2688
+ target: zod.z.string().optional().describe(TARGET_DESCRIPTION),
2689
+ session: zod.z.string().optional().describe(SESSION_DESCRIPTION),
2690
+ append: zod.z.boolean().optional().describe("Append instead of replacing. Defaults to false.")
2691
+ },
2692
+ annotations: {
2693
+ readOnlyHint: false,
2694
+ destructiveHint: true,
2695
+ idempotentHint: false,
2696
+ openWorldHint: true
2697
+ }
2698
+ },
2699
+ async ({ path: remotePath, content, target, session, append }) => {
2700
+ const _logger = $logger.child({ context: "ssh_write_file", remote_path: remotePath });
2701
+ try {
2702
+ const { profile, session: openSession } = resolveProfile(context, { target, session });
2703
+ const shouldAppend = append ?? false;
2704
+ const writtenBytes = await withHostConnection(profile, openSession, async (connection) => {
2705
+ return await writeRemoteFile(connection, {
2706
+ remotePath,
2707
+ content,
2708
+ append: shouldAppend
2709
+ });
2710
+ });
2711
+ let verb = "Wrote";
2712
+ if (shouldAppend == true) {
2713
+ verb = "Appended";
2714
+ }
2715
+ _logger.info("remote file written", { profile: profile.name, bytes: writtenBytes });
2716
+ return textResult(`${verb} ${formatBytes(writtenBytes)} to ${remotePath} on ${profile.name}.`);
2717
+ } catch (ex) {
2718
+ return toToolError(ex, "ssh_write_file", `write ${remotePath}`);
2719
+ }
2720
+ }
2721
+ );
2722
+ server.registerTool(
2723
+ "ssh_upload",
2724
+ {
2725
+ title: "Upload a file over SFTP",
2726
+ description: [
2727
+ "Upload a local file to a registered SSH host over SFTP.",
2728
+ "Refused on read-only profiles, and for local paths holding credentials (SSH keys, cloud credentials, this server's own config).",
2729
+ "The remote parent directory must already exist."
2730
+ ].join(" "),
2731
+ inputSchema: {
2732
+ localPath: zod.z.string().min(1).describe("Local file path on the machine running this MCP server. '~' is expanded."),
2733
+ remotePath: zod.z.string().min(1).describe("Absolute destination path on the remote host, including the filename."),
2734
+ target: zod.z.string().optional().describe(TARGET_DESCRIPTION),
2735
+ session: zod.z.string().optional().describe(SESSION_DESCRIPTION)
2736
+ },
2737
+ annotations: {
2738
+ readOnlyHint: false,
2739
+ destructiveHint: true,
2740
+ idempotentHint: true,
2741
+ openWorldHint: true
2742
+ }
2743
+ },
2744
+ async ({ localPath, remotePath, target, session }) => {
2745
+ const _logger = $logger.child({ context: "ssh_upload", remote_path: remotePath });
2746
+ try {
2747
+ const { profile, session: openSession } = resolveProfile(context, { target, session });
2748
+ const resolvedLocalPath = expandHomePath(localPath);
2749
+ requirePermittedLocalPath(context, resolvedLocalPath, "read");
2750
+ if (fs4__default.default.existsSync(resolvedLocalPath) == false) {
2751
+ return textResult(`local file not found: ${resolvedLocalPath}`);
2752
+ }
2753
+ const transferredBytes = await withHostConnection(profile, openSession, async (connection) => {
2754
+ return await uploadFile(connection, resolvedLocalPath, remotePath);
2755
+ });
2756
+ _logger.info("file uploaded", { profile: profile.name, bytes: transferredBytes });
2757
+ return textResult(`Uploaded ${resolvedLocalPath} \u2192 ${remotePath} (${formatBytes(transferredBytes)}) on ${profile.name}.`);
2758
+ } catch (ex) {
2759
+ return toToolError(ex, "ssh_upload", `upload to ${remotePath}`);
2760
+ }
2761
+ }
2762
+ );
2763
+ server.registerTool(
2764
+ "ssh_download",
2765
+ {
2766
+ title: "Download a file over SFTP",
2767
+ description: [
2768
+ "Download a file from a registered SSH host to the machine running this MCP server.",
2769
+ "Use this for binaries or files too large to read into the conversation; missing local directories are created.",
2770
+ "The local destination is overwritten, and paths holding credentials or this server's own configuration are refused."
2771
+ ].join(" "),
2772
+ inputSchema: {
2773
+ remotePath: zod.z.string().min(1).describe("Absolute source path on the remote host."),
2774
+ localPath: zod.z.string().min(1).describe("Local destination path, including the filename. '~' is expanded. Overwritten if it exists."),
2775
+ target: zod.z.string().optional().describe(TARGET_DESCRIPTION),
2776
+ session: zod.z.string().optional().describe(SESSION_DESCRIPTION)
2777
+ },
2778
+ annotations: {
2779
+ readOnlyHint: false,
2780
+ destructiveHint: true,
2781
+ idempotentHint: true,
2782
+ openWorldHint: true
2783
+ }
2784
+ },
2785
+ async ({ remotePath, localPath, target, session }) => {
2786
+ const _logger = $logger.child({ context: "ssh_download", remote_path: remotePath });
2787
+ try {
2788
+ const { profile, session: openSession } = resolveProfile(context, { target, session });
2789
+ const resolvedLocalPath = expandHomePath(localPath);
2790
+ requirePermittedLocalPath(context, resolvedLocalPath, "write");
2791
+ fs4__default.default.mkdirSync(path2__default.default.dirname(resolvedLocalPath), { recursive: true });
2792
+ const transferredBytes = await withHostConnection(profile, openSession, async (connection) => {
2793
+ return await downloadFile(connection, remotePath, resolvedLocalPath);
2794
+ });
2795
+ _logger.info("file downloaded", { profile: profile.name, bytes: transferredBytes });
2796
+ return textResult(`Downloaded ${remotePath} \u2192 ${resolvedLocalPath} (${formatBytes(transferredBytes)}) from ${profile.name}.`);
2797
+ } catch (ex) {
2798
+ return toToolError(ex, "ssh_download", `download ${remotePath}`);
2799
+ }
2800
+ }
2801
+ );
2802
+ }
2803
+
2804
+ // src/modules/tools/host-tools.ts
2805
+ function registerHostTools(server, context) {
2806
+ server.registerTool(
2807
+ "ssh_list_hosts",
2808
+ {
2809
+ title: "List SSH host profiles",
2810
+ description: [
2811
+ "List the SSH hosts this server may connect to, with each host's guard policy.",
2812
+ "Call this first: every other tool takes a profile alias as its 'target', and no host outside this list can be reached.",
2813
+ "Credentials are never returned \u2014 only which auth method the profile uses."
2814
+ ].join(" "),
2815
+ inputSchema: {},
2816
+ annotations: {
2817
+ readOnlyHint: true,
2818
+ destructiveHint: false,
2819
+ idempotentHint: true,
2820
+ openWorldHint: false
2821
+ }
2822
+ },
2823
+ async () => {
2824
+ const _logger = $logger.child({ context: "ssh_list_hosts" });
2825
+ try {
2826
+ const listing = formatHostList(context.config);
2827
+ _logger.debug("host list returned", { profile_count: context.config.profiles.size });
2828
+ return textResult(listing);
2829
+ } catch (ex) {
2830
+ return toToolError(ex, "ssh_list_hosts", "list hosts");
2831
+ }
2832
+ }
2833
+ );
2834
+ server.registerTool(
2835
+ "ssh_reload_config",
2836
+ {
2837
+ title: "Reload the host configuration",
2838
+ description: [
2839
+ "Re-read the host configuration and apply it, without restarting the MCP server.",
2840
+ "Use after the operator edits the config file \u2014 adding a host, tightening a policy.",
2841
+ "Every open session is closed first, so the new policy is in force everywhere rather than leaving old sessions on the old rules.",
2842
+ "Configuration supplied through environment variables is fixed for the life of the process; changing that still needs a restart."
2843
+ ].join(" "),
2844
+ inputSchema: {},
2845
+ annotations: {
2846
+ readOnlyHint: false,
2847
+ destructiveHint: false,
2848
+ idempotentHint: true,
2849
+ openWorldHint: false
2850
+ }
2851
+ },
2852
+ async () => {
2853
+ const _logger = $logger.child({ context: "ssh_reload_config" });
2854
+ try {
2855
+ const previousLabel = context.config.sourceLabel;
2856
+ const closedSessions = context.sessions.list().length;
2857
+ context.sessions.closeAll();
2858
+ const reloaded = context.reloadConfig();
2859
+ const lines = [
2860
+ `Reloaded ${reloaded.profiles.size} host profile(s) from ${reloaded.sourceLabel}.`
2861
+ ];
2862
+ if (reloaded.profiles.size > 0) {
2863
+ lines.push(`Hosts: ${Array.from(reloaded.profiles.keys()).join(", ")}`);
2864
+ }
2865
+ if (closedSessions > 0) {
2866
+ lines.push(`Closed ${closedSessions} open session(s); reopen with ssh_connect if needed.`);
2867
+ }
2868
+ if (reloaded.sourcePath == null) {
2869
+ lines.push("This config comes from environment variables, which cannot change while the server runs \u2014 restart the MCP server to apply edits to them.");
2870
+ }
2871
+ _logger.info("config reloaded", {
2872
+ profile_count: reloaded.profiles.size,
2873
+ previous_source: previousLabel,
2874
+ config_source: reloaded.sourceLabel
2875
+ });
2876
+ return textResult(lines.join("\n"));
2877
+ } catch (ex) {
2878
+ return toToolError(ex, "ssh_reload_config", "reload the configuration");
2879
+ }
2880
+ }
2881
+ );
2882
+ }
2883
+ function registerSessionTools(server, context) {
2884
+ server.registerTool(
2885
+ "ssh_connect",
2886
+ {
2887
+ title: "Open an SSH session",
2888
+ description: [
2889
+ "Open a reusable SSH session to a registered host and return its session id.",
2890
+ "Prefer this over one-off 'target' calls when running several commands: it pays the handshake once and remembers the working directory, so a 'cd' in one ssh_exec still applies to the next.",
2891
+ "Close it with ssh_disconnect when done; idle sessions are reaped automatically, and a session whose connection drops reports that rather than silently reconnecting."
2892
+ ].join(" "),
2893
+ inputSchema: {
2894
+ target: zod.z.string().optional().describe("Host profile alias. Omit it when this server fronts a single host, which is the usual setup."),
2895
+ cwd: zod.z.string().optional().describe("Absolute directory to start in, with no shell metacharacters. Defaults to the profile's defaultCwd, else the login shell's directory.")
2896
+ },
2897
+ annotations: {
2898
+ readOnlyHint: false,
2899
+ destructiveHint: false,
2900
+ idempotentHint: false,
2901
+ openWorldHint: true
2902
+ }
2903
+ },
2904
+ async ({ target, cwd }) => {
2905
+ let _logger = $logger.child({ context: "ssh_connect", target });
2906
+ let openedSessionId = null;
2907
+ try {
2908
+ const { profile } = resolveProfile(context, { target });
2909
+ const session = await context.sessions.open(profile);
2910
+ openedSessionId = session.sessionId;
2911
+ _logger = _logger.child({ session_id: session.sessionId });
2912
+ if (cwd != null && cwd.trim() !== "") {
2913
+ session.cwd = cwd.trim();
2914
+ }
2915
+ const probe = await session.exec("pwd");
2916
+ const probeSucceeded = probe.exitCode === 0 && probe.timedOut == false;
2917
+ if (probeSucceeded == false) {
2918
+ context.sessions.close(session.sessionId);
2919
+ openedSessionId = null;
2920
+ _logger.warn("session probe failed, closing the session", {
2921
+ exit_code: probe.exitCode,
2922
+ timed_out: probe.timedOut,
2923
+ stderr: probe.stderr
2924
+ });
2925
+ return errorResult(`ssh_connect failed: could not confirm the starting directory.
2926
+ ${probe.stderr.trim()}`);
2927
+ }
2928
+ const lines = [
2929
+ `Opened session ${session.sessionId} \u2192 ${profile.username}@${profile.host}:${profile.port}`,
2930
+ `cwd: ${session.cwd}`
2931
+ ];
2932
+ if (profile.policy.readonly == true) {
2933
+ lines.push("This profile is READ-ONLY: writes, redirections and uploads will be rejected.");
2934
+ }
2935
+ lines.push(`Run commands with ssh_exec({ session: "${session.sessionId}", command: "..." }).`);
2936
+ _logger.info("session opened via tool", { cwd: session.cwd });
2937
+ return textResult(lines.join("\n"));
2938
+ } catch (ex) {
2939
+ if (openedSessionId != null) {
2940
+ context.sessions.close(openedSessionId);
2941
+ }
2942
+ return toToolError(ex, "ssh_connect", `connect to ${target ?? "the configured host"}`);
2943
+ }
2944
+ }
2945
+ );
2946
+ server.registerTool(
2947
+ "ssh_disconnect",
2948
+ {
2949
+ title: "Close an SSH session",
2950
+ description: "Close an open SSH session and release its connection.",
2951
+ inputSchema: {
2952
+ session: zod.z.string().describe("Session id returned by ssh_connect.")
2953
+ },
2954
+ annotations: {
2955
+ readOnlyHint: false,
2956
+ destructiveHint: false,
2957
+ idempotentHint: true,
2958
+ openWorldHint: true
2959
+ }
2960
+ },
2961
+ async ({ session }) => {
2962
+ const _logger = $logger.child({ context: "ssh_disconnect", session_id: session });
2963
+ try {
2964
+ const closed = context.sessions.close(session);
2965
+ if (closed == false) {
2966
+ return errorResult(`no open session with id '${session}'. Call ssh_list_sessions to see what is open.`);
2967
+ }
2968
+ _logger.info("session closed via tool");
2969
+ return textResult(`Closed session ${session}.`);
2970
+ } catch (ex) {
2971
+ return toToolError(ex, "ssh_disconnect", `close ${session}`);
2972
+ }
2973
+ }
2974
+ );
2975
+ server.registerTool(
2976
+ "ssh_list_sessions",
2977
+ {
2978
+ title: "List open SSH sessions",
2979
+ description: "List every open SSH session with its host, working directory and idle time.",
2980
+ inputSchema: {},
2981
+ annotations: {
2982
+ readOnlyHint: true,
2983
+ destructiveHint: false,
2984
+ idempotentHint: true,
2985
+ openWorldHint: false
2986
+ }
2987
+ },
2988
+ async () => {
2989
+ try {
2990
+ const sessions = context.sessions.list();
2991
+ const rendered = formatSessionList(sessions);
2992
+ return textResult(rendered);
2993
+ } catch (ex) {
2994
+ return toToolError(ex, "ssh_list_sessions", "list sessions");
2995
+ }
2996
+ }
2997
+ );
2998
+ }
2999
+
3000
+ // src/modules/tools/index.ts
3001
+ function registerAllTools(server, context) {
3002
+ registerHostTools(server, context);
3003
+ registerSessionTools(server, context);
3004
+ registerExecTools(server, context);
3005
+ registerFileTools(server, context);
3006
+ }
3007
+
3008
+ // src/server.ts
3009
+ var SERVER_INSTRUCTIONS = [
3010
+ "This server runs shell commands and transfers files on remote hosts over SSH.",
3011
+ "Only hosts pre-registered in a local config file are reachable \u2014 arbitrary host/credential arguments are not accepted.",
3012
+ "",
3013
+ "Usually one server entry fronts one host, so no 'target' argument is needed \u2014 omit it and the configured host is used. ssh_list_hosts shows what is registered and what it permits.",
3014
+ "",
3015
+ "Workflow:",
3016
+ "1. One command: ssh_exec with just 'command'.",
3017
+ "2. Several commands in a row: ssh_connect once, then ssh_exec with 'session' (the working directory persists across calls), then ssh_disconnect.",
3018
+ "3. Files: ssh_list_dir, ssh_read_file, ssh_write_file, ssh_upload, ssh_download \u2014 prefer these over shell equivalents; no quoting to get wrong.",
3019
+ "Pass 'target' only when ssh_list_hosts shows several hosts, and never together with 'session'.",
3020
+ "",
3021
+ "What is permitted: ordinary administration, without asking \u2014 package installs, service restarts, sudo, interpreters, config edits, deployments.",
3022
+ "Refused on every profile, and only these: operations that destroy the machine or cut this connection \u2014 wiping the filesystem root or a system directory, deleting a file the host cannot boot without, formatting a disk, writing raw bytes to a block device, rebooting or powering off, flushing the firewall, removing every cron job.",
3023
+ "Individual profiles may add their own limits (read-only, an allow-list, deny patterns, path restrictions). ssh_list_hosts shows which apply where.",
3024
+ "",
3025
+ "Commands run with no terminal and with stdin closed, so anything that waits for input fails immediately instead of hanging: use non-interactive flags (apt-get -y, sudo -n), 'top -bn1' rather than 'top', and ssh_write_file rather than an editor.",
3026
+ "A job that may outlive its timeout should be started detached (nohup \u2026 &, tmux new -d) and polled through its log.",
3027
+ "",
3028
+ "A guard rejection is a policy decision, not a transient failure \u2014 do not retry the same command; report it and ask before working around it.",
3029
+ "A non-zero exit code is returned as normal output; read it and stderr to judge what happened.",
3030
+ "You still carry the judgement the guards do not: these are real hosts, and an irreversible action deserves a check with the operator first."
3031
+ ].join("\n");
3032
+ function createSshMcpServer(options) {
3033
+ const _logger = $logger.child({ context: "createSshMcpServer" });
3034
+ const config = loadConfig(options?.configPath);
3035
+ const sessions = new SshSessionManager();
3036
+ sessions.startIdleSweeper();
3037
+ const server = new mcp_js.McpServer(
3038
+ {
3039
+ name: SERVER_NAME,
3040
+ version: SERVER_VERSION
3041
+ },
3042
+ {
3043
+ instructions: SERVER_INSTRUCTIONS
3044
+ }
3045
+ );
3046
+ const context = {
3047
+ config,
3048
+ sessions,
3049
+ reloadConfig() {
3050
+ const reloaded = loadConfig(options?.configPath);
3051
+ context.config = reloaded;
3052
+ return reloaded;
3053
+ }
3054
+ };
3055
+ registerAllTools(server, context);
3056
+ _logger.info("mcp server built", {
3057
+ profile_count: config.profiles.size,
3058
+ config_source: config.sourceLabel
3059
+ });
3060
+ return {
3061
+ server,
3062
+ sessions,
3063
+ get config() {
3064
+ return context.config;
3065
+ },
3066
+ reloadConfig() {
3067
+ return context.reloadConfig();
3068
+ },
3069
+ shutdown() {
3070
+ sessions.stopIdleSweeper();
3071
+ sessions.closeAll();
3072
+ }
3073
+ };
3074
+ }
3075
+
3076
+ // src/cli.ts
3077
+ function parseArguments(argv) {
3078
+ const parsed = { showHelp: false, showVersion: false, checkOnly: false };
3079
+ for (let index = 0; index < argv.length; index++) {
3080
+ const argument = argv[index];
3081
+ if (argument === "--help" || argument === "-h") {
3082
+ parsed.showHelp = true;
3083
+ continue;
3084
+ }
3085
+ if (argument === "--version" || argument === "-v") {
3086
+ parsed.showVersion = true;
3087
+ continue;
3088
+ }
3089
+ if (argument === "--check") {
3090
+ parsed.checkOnly = true;
3091
+ continue;
3092
+ }
3093
+ if (argument === "--config" || argument === "-c") {
3094
+ const value = argv[index + 1];
3095
+ if (value == null || value.startsWith("-") == true) {
3096
+ throw new Error(`${argument} requires a file path`);
3097
+ }
3098
+ parsed.configPath = value;
3099
+ index++;
3100
+ continue;
3101
+ }
3102
+ if (argument.startsWith("--config=") == true) {
3103
+ const value = argument.slice("--config=".length);
3104
+ if (value === "") {
3105
+ throw new Error("--config= requires a file path");
3106
+ }
3107
+ parsed.configPath = value;
3108
+ continue;
3109
+ }
3110
+ }
3111
+ return parsed;
3112
+ }
3113
+ var HELP_TEXT = [
3114
+ `${SERVER_NAME} ${SERVER_VERSION} \u2014 MCP server for SSH command execution and SFTP transfers`,
3115
+ "",
3116
+ "Usage:",
3117
+ " akms-ssh [--config <path>]",
3118
+ "",
3119
+ "Options:",
3120
+ " -c, --config <path> Host profile config file",
3121
+ ` (default: ${CONFIG_PATH_ENV} env var, else ~/${DEFAULT_CONFIG_FILENAMES[0]})`,
3122
+ " --check Connect to every profile, report the result, and exit.",
3123
+ " Prints each host key fingerprint so it can be pinned.",
3124
+ " -v, --version Print the version and exit",
3125
+ " -h, --help Print this help and exit",
3126
+ "",
3127
+ "Environment:",
3128
+ ` ${CONFIG_PATH_ENV} Host profiles as JSON, or a path to a JSON file.`,
3129
+ " A value starting with '{' is read as the config itself, so an",
3130
+ " MCP client can carry the whole setup in its own env block.",
3131
+ ` ${LOG_LEVEL_ENV} silent | debug | info | warn | error (default: info, written to stderr)`,
3132
+ "",
3133
+ "The server speaks MCP over stdio; run it from an MCP client, not interactively."
3134
+ ].join("\n");
3135
+ function describeAuth(profile) {
3136
+ if (profile.privateKeyPath != null) {
3137
+ return `key ${profile.privateKeyPath}`;
3138
+ }
3139
+ if (profile.privateKey != null) {
3140
+ return "inline key";
3141
+ }
3142
+ if (profile.password != null) {
3143
+ return "password";
3144
+ }
3145
+ return "ssh agent";
3146
+ }
3147
+ async function runConnectionCheck(config) {
3148
+ if (config.profiles.size === 0) {
3149
+ process.stdout.write(`No host profiles are registered (config source: ${config.sourceLabel}).
3150
+ `);
3151
+ return 1;
3152
+ }
3153
+ process.stdout.write(`Checking ${config.profiles.size} profile(s) from ${config.sourceLabel}
3154
+
3155
+ `);
3156
+ const profiles = Array.from(config.profiles.values());
3157
+ const results = await Promise.all(profiles.map(async (profile) => {
3158
+ const connection = new SshConnection(profile);
3159
+ const startedAt = Date.now();
3160
+ try {
3161
+ await connection.connect();
3162
+ return {
3163
+ profile,
3164
+ connected: true,
3165
+ durationMs: Date.now() - startedAt,
3166
+ fingerprint: connection.hostKeyFingerprint,
3167
+ detail: ""
3168
+ };
3169
+ } catch (ex) {
3170
+ let detail = String(ex);
3171
+ if (ex instanceof Error) {
3172
+ detail = ex.message;
3173
+ }
3174
+ return {
3175
+ profile,
3176
+ connected: false,
3177
+ durationMs: Date.now() - startedAt,
3178
+ fingerprint: connection.hostKeyFingerprint,
3179
+ detail
3180
+ };
3181
+ } finally {
3182
+ connection.disconnect();
3183
+ }
3184
+ }));
3185
+ let connectedCount = 0;
3186
+ for (const result of results) {
3187
+ const endpoint = `${result.profile.username}@${result.profile.host}:${result.profile.port}`;
3188
+ const auth = describeAuth(result.profile);
3189
+ if (result.connected == true) {
3190
+ connectedCount += 1;
3191
+ process.stdout.write(` OK ${result.profile.name} \u2192 ${endpoint} (${auth}, ${result.durationMs}ms)
3192
+ `);
3193
+ } else {
3194
+ process.stdout.write(` FAILED ${result.profile.name} \u2192 ${endpoint} (${auth})
3195
+ `);
3196
+ process.stdout.write(` ${result.detail}
3197
+ `);
3198
+ }
3199
+ if (result.fingerprint != null) {
3200
+ let pinNote = "not pinned \u2014 add it as hostKeyFingerprint to detect a changed key";
3201
+ if (result.profile.hostKeyFingerprint != null) {
3202
+ pinNote = "pinned";
3203
+ }
3204
+ process.stdout.write(` host key ${result.fingerprint} (${pinNote})
3205
+ `);
3206
+ }
3207
+ }
3208
+ process.stdout.write(`
3209
+ ${connectedCount} of ${results.length} reachable
3210
+ `);
3211
+ if (connectedCount === results.length) {
3212
+ return 0;
3213
+ }
3214
+ return 1;
3215
+ }
3216
+ async function main() {
3217
+ const _logger = $logger.child({ context: "cli" });
3218
+ const cliArguments = parseArguments(process.argv.slice(2));
3219
+ if (cliArguments.showHelp == true) {
3220
+ process.stderr.write(`${HELP_TEXT}
3221
+ `);
3222
+ return;
3223
+ }
3224
+ if (cliArguments.showVersion == true) {
3225
+ process.stderr.write(`${SERVER_VERSION}
3226
+ `);
3227
+ return;
3228
+ }
3229
+ if (cliArguments.checkOnly == true && process.env[LOG_LEVEL_ENV] == null) {
3230
+ process.env[LOG_LEVEL_ENV] = "silent";
3231
+ }
3232
+ const instance = createSshMcpServer({ configPath: cliArguments.configPath });
3233
+ if (cliArguments.checkOnly == true) {
3234
+ const exitCode = await runConnectionCheck(instance.config);
3235
+ instance.shutdown();
3236
+ process.exit(exitCode);
3237
+ }
3238
+ let shuttingDown = false;
3239
+ const shutdown = (signal) => {
3240
+ if (shuttingDown == true) {
3241
+ return;
3242
+ }
3243
+ shuttingDown = true;
3244
+ _logger.info("shutting down", { signal });
3245
+ instance.shutdown();
3246
+ void instance.server.close().finally(() => {
3247
+ process.exit(0);
3248
+ });
3249
+ };
3250
+ process.on("SIGINT", () => {
3251
+ shutdown("SIGINT");
3252
+ });
3253
+ process.on("SIGTERM", () => {
3254
+ shutdown("SIGTERM");
3255
+ });
3256
+ process.stdin.on("close", () => {
3257
+ shutdown("stdin-close");
3258
+ });
3259
+ const transport = new stdio_js.StdioServerTransport();
3260
+ await instance.server.connect(transport);
3261
+ _logger.info("mcp server ready on stdio", {
3262
+ server: SERVER_NAME,
3263
+ version: SERVER_VERSION,
3264
+ config_source: instance.config.sourceLabel,
3265
+ profile_count: instance.config.profiles.size
3266
+ });
3267
+ }
3268
+ main().catch((error) => {
3269
+ const _logger = $logger.child({ context: "cli" });
3270
+ _logger.error(error, "mcp server failed to start");
3271
+ let detail = String(error);
3272
+ if (error instanceof Error) {
3273
+ detail = error.message;
3274
+ }
3275
+ process.stderr.write(`akms-ssh: ${detail}
3276
+ `);
3277
+ process.exit(1);
3278
+ });
3279
+ //# sourceMappingURL=cli.cjs.map
3280
+ //# sourceMappingURL=cli.cjs.map