@kontextmind/kxm 0.7.6 → 0.7.7

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.
@@ -7,7 +7,7 @@ import { AGENT_COMMANDS_MAP, enforceToolPolicy, getMcpTools, reconcileInbox } fr
7
7
  import { deliverInboxNotification } from "./inbox.ts";
8
8
  import type { HubEvent, MessageRecord } from "./protocol.ts";
9
9
 
10
- const VERSION = "0.7.6";
10
+ const VERSION = "0.7.7";
11
11
  const inbox = new Map<string, MessageRecord>();
12
12
  const notifiedInbox = new Set<string>();
13
13
  let meshClient: HubClient | undefined;
@@ -9,3 +9,4 @@ export * from "./logger.ts";
9
9
  export * from "./improve.ts";
10
10
  export * from "./browser.ts";
11
11
  export * from "./modes.ts";
12
+ export * from "./ssh-remote.ts";
@@ -0,0 +1,560 @@
1
+ /**
2
+ * Multiplexed remote SSH execution, worker orchestration, and secure config discovery.
3
+ *
4
+ * Implements:
5
+ * - Safe `~/.ssh/config` parsing and alias discovery without socket connections or credential leaks.
6
+ * - OpenSSH `ControlMaster` connection reuse (.kxm/run/ssh-sockets/%C) for low-latency command execution.
7
+ * - In-memory masked credential passing via `sshpass -e` (env) and `sudo -S -p ''` (stdin).
8
+ * - Safety seatbelt validation and pinned host key verification.
9
+ * - Output bounding and truncation (50KB / 2000 lines).
10
+ */
11
+
12
+ import { spawnSync, type SpawnSyncOptionsWithStringEncoding } from "node:child_process";
13
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { join, resolve } from "node:path";
16
+ import { assertCommandSeatbelt, assertPinnedSshHostKeyPolicy } from "./safety-integrity.ts";
17
+
18
+ export const MAX_SSH_OUTPUT_BYTES = 50 * 1024; // 50KB
19
+ export const MAX_SSH_OUTPUT_LINES = 2000;
20
+ export const DEFAULT_SOCKET_DIR = ".kxm/run/ssh-sockets";
21
+ export const DEFAULT_CONTROL_PERSIST = "10m";
22
+
23
+ export interface SshHostInfo {
24
+ alias: string;
25
+ hostName?: string | undefined;
26
+ user?: string | undefined;
27
+ port?: number | undefined;
28
+ proxyJump?: string | undefined;
29
+ identityFiles?: string[] | undefined;
30
+ }
31
+
32
+ export interface SshRunParams {
33
+ action: "info" | "command" | "file";
34
+ host?: string | undefined;
35
+ command?: string | undefined;
36
+ sudo?: boolean | undefined;
37
+ file_path?: string | undefined;
38
+ file_content?: string | undefined;
39
+ file_op?: "write" | "read" | "append" | undefined;
40
+ reason?: string | undefined;
41
+ password?: string | undefined;
42
+ sudo_password?: string | undefined;
43
+ socket_dir?: string | undefined;
44
+ control_persist?: string | undefined;
45
+ timeout_ms?: number | undefined;
46
+ strict_host_key?: boolean | undefined;
47
+ execFn?: ((cmd: string, args: string[], options: SpawnSyncOptionsWithStringEncoding) => {
48
+ status: number | null;
49
+ stdout: string | Buffer;
50
+ stderr: string | Buffer;
51
+ error?: Error | undefined;
52
+ }) | undefined;
53
+ }
54
+
55
+ export interface SshExecutionReceipt {
56
+ ok: boolean;
57
+ action: "info" | "command" | "file";
58
+ host?: string | undefined;
59
+ resolvedHost?: string | undefined;
60
+ user?: string | undefined;
61
+ port?: number | undefined;
62
+ exitCode?: number | undefined;
63
+ stdout?: string | undefined;
64
+ stderr?: string | undefined;
65
+ durationMs?: number | undefined;
66
+ truncated?: boolean | undefined;
67
+ socketReused?: boolean | undefined;
68
+ fileProcessed?: string | undefined;
69
+ bytesProcessed?: number | undefined;
70
+ hosts?: SshHostInfo[] | undefined;
71
+ error?: string | undefined;
72
+ }
73
+
74
+ /**
75
+ * Truncates output to 50KB and 2000 lines max.
76
+ */
77
+ export function truncateSshOutput(raw: string): { text: string; truncated: boolean } {
78
+ let text = raw;
79
+ let truncated = false;
80
+
81
+ if (Buffer.byteLength(text, "utf-8") > MAX_SSH_OUTPUT_BYTES) {
82
+ const buf = Buffer.from(text, "utf-8");
83
+ text = buf.subarray(0, MAX_SSH_OUTPUT_BYTES).toString("utf-8");
84
+ truncated = true;
85
+ }
86
+
87
+ const lines = text.split("\n");
88
+ if (lines.length > MAX_SSH_OUTPUT_LINES) {
89
+ text = lines.slice(0, MAX_SSH_OUTPUT_LINES).join("\n");
90
+ truncated = true;
91
+ }
92
+
93
+ if (truncated) {
94
+ text += `\n\n[kxm: ssh output truncated to ${MAX_SSH_OUTPUT_BYTES / 1024}KB / ${MAX_SSH_OUTPUT_LINES} lines]`;
95
+ }
96
+
97
+ return { text, truncated };
98
+ }
99
+
100
+ /**
101
+ * Parses ~/.ssh/config safely to list host aliases and their configurations.
102
+ * Does not read private key files or open network sockets.
103
+ */
104
+ export function parseSshConfig(configPath?: string): SshHostInfo[] {
105
+ const targetPath = configPath ?? join(homedir(), ".ssh", "config");
106
+ if (!existsSync(targetPath)) {
107
+ return [];
108
+ }
109
+
110
+ try {
111
+ const content = readFileSync(targetPath, "utf-8");
112
+ const lines = content.split("\n");
113
+ const hosts: SshHostInfo[] = [];
114
+ let currentHosts: SshHostInfo[] = [];
115
+
116
+ for (const rawLine of lines) {
117
+ const line = rawLine.trim();
118
+ if (!line || line.startsWith("#")) continue;
119
+
120
+ const spaceIdx = line.search(/\s/);
121
+ if (spaceIdx === -1) continue;
122
+
123
+ const key = line.slice(0, spaceIdx).trim().toLowerCase();
124
+ const value = line.slice(spaceIdx + 1).trim();
125
+
126
+ if (key === "host") {
127
+ // Space-separated host patterns; skip wildcard-only matches like "*"
128
+ const aliases = value.split(/\s+/).filter((a) => a !== "*");
129
+ currentHosts = [];
130
+ for (const alias of aliases) {
131
+ const entry: SshHostInfo = { alias, identityFiles: [] };
132
+ currentHosts.push(entry);
133
+ hosts.push(entry);
134
+ }
135
+ } else if (currentHosts.length > 0) {
136
+ for (const currentHost of currentHosts) {
137
+ if (key === "hostname") {
138
+ currentHost.hostName = value;
139
+ } else if (key === "user") {
140
+ currentHost.user = value;
141
+ } else if (key === "port") {
142
+ const p = Number.parseInt(value, 10);
143
+ if (!Number.isNaN(p)) currentHost.port = p;
144
+ } else if (key === "proxyjump") {
145
+ currentHost.proxyJump = value;
146
+ } else if (key === "identityfile") {
147
+ currentHost.identityFiles = currentHost.identityFiles ?? [];
148
+ currentHost.identityFiles.push(value);
149
+ }
150
+ }
151
+ }
152
+ }
153
+
154
+ return hosts;
155
+ } catch {
156
+ return [];
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Resolves effective SSH parameters for a specific host using `ssh -G <host>`.
162
+ * Does not initiate any network connections.
163
+ */
164
+ export function resolveSshHostG(
165
+ host: string,
166
+ execFn: typeof spawnSync = spawnSync,
167
+ ): SshHostInfo {
168
+ try {
169
+ const result = execFn("ssh", ["-G", host], { encoding: "utf-8" });
170
+ if (result.status !== 0 || !result.stdout) {
171
+ return { alias: host, hostName: host };
172
+ }
173
+
174
+ const lines = String(result.stdout).split("\n");
175
+ const info: SshHostInfo = { alias: host, identityFiles: [] };
176
+
177
+ for (const rawLine of lines) {
178
+ const line = rawLine.trim();
179
+ if (!line) continue;
180
+ const spaceIdx = line.indexOf(" ");
181
+ if (spaceIdx === -1) continue;
182
+
183
+ const key = line.slice(0, spaceIdx).toLowerCase();
184
+ const value = line.slice(spaceIdx + 1).trim();
185
+
186
+ if (key === "hostname") {
187
+ info.hostName = value;
188
+ } else if (key === "user") {
189
+ info.user = value;
190
+ } else if (key === "port") {
191
+ const p = Number.parseInt(value, 10);
192
+ if (!Number.isNaN(p)) info.port = p;
193
+ } else if (key === "proxyjump") {
194
+ if (value && value !== "none") info.proxyJump = value;
195
+ } else if (key === "identityfile") {
196
+ info.identityFiles = info.identityFiles ?? [];
197
+ info.identityFiles.push(value);
198
+ }
199
+ }
200
+
201
+ return info;
202
+ } catch {
203
+ return { alias: host, hostName: host };
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Ensures control socket directory exists with secure permissions (0700).
209
+ */
210
+ export function ensureSocketDir(socketDir: string = DEFAULT_SOCKET_DIR): string {
211
+ const resolved = resolve(socketDir);
212
+ if (!existsSync(resolved)) {
213
+ mkdirSync(resolved, { recursive: true, mode: 0o700 });
214
+ }
215
+ return resolved;
216
+ }
217
+
218
+ /**
219
+ * Formats standard OpenSSH arguments including ControlMaster multiplexing
220
+ * and safety invariants.
221
+ */
222
+ export function buildSshArgs(options: {
223
+ host: string;
224
+ socketDir?: string | undefined;
225
+ controlPersist?: string | undefined;
226
+ batchMode?: boolean | undefined;
227
+ strictHostKey?: boolean | undefined;
228
+ extraArgs?: string[] | undefined;
229
+ }): string[] {
230
+ const socketDir = ensureSocketDir(options.socketDir ?? DEFAULT_SOCKET_DIR);
231
+ const controlPath = join(socketDir, "%C");
232
+ const persist = options.controlPersist ?? DEFAULT_CONTROL_PERSIST;
233
+
234
+ const args: string[] = [
235
+ "-o", `ControlMaster=auto`,
236
+ "-o", `ControlPath=${controlPath}`,
237
+ "-o", `ControlPersist=${persist}`,
238
+ ];
239
+
240
+ if (options.batchMode !== false) {
241
+ args.push("-o", "BatchMode=yes");
242
+ }
243
+
244
+ if (options.strictHostKey !== false) {
245
+ args.push("-o", "StrictHostKeyChecking=yes");
246
+ }
247
+
248
+ if (options.extraArgs) {
249
+ args.push(...options.extraArgs);
250
+ }
251
+
252
+ // Validate that no insecure host key flags are present
253
+ assertPinnedSshHostKeyPolicy(args);
254
+
255
+ args.push(options.host);
256
+ return args;
257
+ }
258
+
259
+ /**
260
+ * Checks if a ControlMaster socket is active for the target host.
261
+ */
262
+ export function checkControlSocket(
263
+ host: string,
264
+ socketDir: string = DEFAULT_SOCKET_DIR,
265
+ execFn: typeof spawnSync = spawnSync,
266
+ ): boolean {
267
+ const resolvedDir = ensureSocketDir(socketDir);
268
+ const controlPath = join(resolvedDir, "%C");
269
+ try {
270
+ const result = execFn("ssh", ["-O", "check", "-o", `ControlPath=${controlPath}`, host], {
271
+ encoding: "utf-8",
272
+ });
273
+ return result.status === 0;
274
+ } catch {
275
+ return false;
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Closes an active ControlMaster socket for the target host.
281
+ */
282
+ export function closeControlSocket(
283
+ host: string,
284
+ socketDir: string = DEFAULT_SOCKET_DIR,
285
+ execFn: typeof spawnSync = spawnSync,
286
+ ): boolean {
287
+ const resolvedDir = ensureSocketDir(socketDir);
288
+ const controlPath = join(resolvedDir, "%C");
289
+ try {
290
+ const result = execFn("ssh", ["-O", "stop", "-o", `ControlPath=${controlPath}`, host], {
291
+ encoding: "utf-8",
292
+ });
293
+ return result.status === 0;
294
+ } catch {
295
+ return false;
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Cleans up all orphaned or expired ControlMaster sockets in the socket directory.
301
+ */
302
+ export function pruneSocketDir(socketDir: string = DEFAULT_SOCKET_DIR): number {
303
+ const resolvedDir = ensureSocketDir(socketDir);
304
+ let removed = 0;
305
+ try {
306
+ const entries = readdirSync(resolvedDir);
307
+ for (const entry of entries) {
308
+ const fullPath = join(resolvedDir, entry);
309
+ try {
310
+ const stat = statSync(fullPath);
311
+ if (stat.isSocket()) {
312
+ rmSync(fullPath, { force: true });
313
+ removed++;
314
+ }
315
+ } catch {
316
+ // Skip unreadable entries
317
+ }
318
+ }
319
+ } catch {
320
+ // Ignore directory read errors
321
+ }
322
+ return removed;
323
+ }
324
+
325
+ /**
326
+ * Main execution handler for `ssh_run` tool.
327
+ */
328
+ export function executeSshRun(params: SshRunParams): SshExecutionReceipt {
329
+ const startTime = Date.now();
330
+ const execSyncFn = params.execFn ?? spawnSync;
331
+
332
+ // 1. Action: "info"
333
+ if (params.action === "info") {
334
+ if (params.host) {
335
+ const hostInfo = resolveSshHostG(params.host, execSyncFn as typeof spawnSync);
336
+ return {
337
+ ok: true,
338
+ action: "info",
339
+ host: params.host,
340
+ resolvedHost: hostInfo.hostName ?? params.host,
341
+ user: hostInfo.user,
342
+ port: hostInfo.port,
343
+ hosts: [hostInfo],
344
+ durationMs: Date.now() - startTime,
345
+ };
346
+ }
347
+
348
+ const discovered = parseSshConfig();
349
+ return {
350
+ ok: true,
351
+ action: "info",
352
+ hosts: discovered,
353
+ durationMs: Date.now() - startTime,
354
+ };
355
+ }
356
+
357
+ // Require host for command and file actions
358
+ if (!params.host) {
359
+ return {
360
+ ok: false,
361
+ action: params.action,
362
+ error: 'Parameter "host" is required for command and file actions.',
363
+ durationMs: Date.now() - startTime,
364
+ };
365
+ }
366
+
367
+ const host = params.host;
368
+ const socketDir = params.socket_dir ?? DEFAULT_SOCKET_DIR;
369
+ const wasSocketActive = checkControlSocket(host, socketDir, execSyncFn as typeof spawnSync);
370
+
371
+ // 2. Action: "command"
372
+ if (params.action === "command") {
373
+ if (!params.command) {
374
+ return {
375
+ ok: false,
376
+ action: "command",
377
+ host,
378
+ error: 'Parameter "command" is required for command action.',
379
+ durationMs: Date.now() - startTime,
380
+ };
381
+ }
382
+
383
+ // Safety check: block destructive commands
384
+ try {
385
+ assertCommandSeatbelt(params.command);
386
+ } catch (err: unknown) {
387
+ return {
388
+ ok: false,
389
+ action: "command",
390
+ host,
391
+ error: (err as Error).message,
392
+ durationMs: Date.now() - startTime,
393
+ };
394
+ }
395
+
396
+ let remoteCommand = params.command;
397
+ let inputStdin: string | undefined = undefined;
398
+
399
+ // Handle remote sudo via stdin pipe
400
+ if (params.sudo) {
401
+ const sudoPwd = params.sudo_password ?? params.password;
402
+ if (sudoPwd) {
403
+ inputStdin = `${sudoPwd}\n`;
404
+ remoteCommand = `sudo -S -p '' -- sh -c ${JSON.stringify(params.command)}`;
405
+ } else {
406
+ remoteCommand = `sudo -- sh -c ${JSON.stringify(params.command)}`;
407
+ }
408
+ }
409
+
410
+ const useSshPass = Boolean(params.password);
411
+ const sshArgs = buildSshArgs({
412
+ host,
413
+ socketDir,
414
+ controlPersist: params.control_persist,
415
+ batchMode: !useSshPass,
416
+ strictHostKey: params.strict_host_key,
417
+ });
418
+ sshArgs.push("--", remoteCommand);
419
+
420
+ const spawnEnv: NodeJS.ProcessEnv = { ...process.env };
421
+ let binary = "ssh";
422
+ let finalArgs = sshArgs;
423
+
424
+ // Masked credential security: SSH password via SSHPASS environment variable
425
+ if (useSshPass && params.password) {
426
+ binary = "sshpass";
427
+ finalArgs = ["-e", "ssh", ...sshArgs];
428
+ spawnEnv.SSHPASS = params.password;
429
+ }
430
+
431
+ try {
432
+ const result = execSyncFn(binary, finalArgs, {
433
+ encoding: "utf-8",
434
+ env: spawnEnv,
435
+ input: inputStdin,
436
+ timeout: params.timeout_ms ?? 120_000,
437
+ maxBuffer: 10 * 1024 * 1024,
438
+ });
439
+
440
+ const stdoutRaw = String(result.stdout || "");
441
+ const stderrRaw = String(result.stderr || "");
442
+
443
+ // Sanitize stderr from potential sudo password echoes
444
+ const sanitizedStderr = stderrRaw.replace(/\[sudo\] password for [^:]+:\s*/gi, "");
445
+
446
+ const stdoutTrunc = truncateSshOutput(stdoutRaw);
447
+ const stderrTrunc = truncateSshOutput(sanitizedStderr);
448
+
449
+ return {
450
+ ok: result.status === 0,
451
+ action: "command",
452
+ host,
453
+ exitCode: result.status ?? 1,
454
+ stdout: stdoutTrunc.text,
455
+ stderr: stderrTrunc.text,
456
+ truncated: stdoutTrunc.truncated || stderrTrunc.truncated,
457
+ socketReused: wasSocketActive,
458
+ durationMs: Date.now() - startTime,
459
+ error: result.error ? result.error.message : undefined,
460
+ };
461
+ } catch (err: unknown) {
462
+ return {
463
+ ok: false,
464
+ action: "command",
465
+ host,
466
+ error: (err as Error).message,
467
+ durationMs: Date.now() - startTime,
468
+ };
469
+ }
470
+ }
471
+
472
+ // 3. Action: "file"
473
+ if (params.action === "file") {
474
+ if (!params.file_path) {
475
+ return {
476
+ ok: false,
477
+ action: "file",
478
+ host,
479
+ error: 'Parameter "file_path" is required for file action.',
480
+ durationMs: Date.now() - startTime,
481
+ };
482
+ }
483
+
484
+ const filePath = params.file_path;
485
+ const op = params.file_op ?? "write";
486
+
487
+ if (op === "read") {
488
+ const readCmd = params.sudo ? `sudo cat ${JSON.stringify(filePath)}` : `cat ${JSON.stringify(filePath)}`;
489
+ return executeSshRun({
490
+ ...params,
491
+ action: "command",
492
+ command: readCmd,
493
+ });
494
+ }
495
+
496
+ const content = params.file_content ?? "";
497
+ const redirectOp = op === "append" ? ">>" : ">";
498
+ const writeCmd = params.sudo
499
+ ? `sudo tee ${op === "append" ? "-a " : ""}${JSON.stringify(filePath)} > /dev/null`
500
+ : `cat ${redirectOp} ${JSON.stringify(filePath)}`;
501
+
502
+ const useSshPass = Boolean(params.password);
503
+ const sshArgs = buildSshArgs({
504
+ host,
505
+ socketDir,
506
+ controlPersist: params.control_persist,
507
+ batchMode: !useSshPass,
508
+ strictHostKey: params.strict_host_key,
509
+ });
510
+ sshArgs.push("--", writeCmd);
511
+
512
+ const spawnEnv: NodeJS.ProcessEnv = { ...process.env };
513
+ let binary = "ssh";
514
+ let finalArgs = sshArgs;
515
+
516
+ if (useSshPass && params.password) {
517
+ binary = "sshpass";
518
+ finalArgs = ["-e", "ssh", ...sshArgs];
519
+ spawnEnv.SSHPASS = params.password;
520
+ }
521
+
522
+ try {
523
+ const result = execSyncFn(binary, finalArgs, {
524
+ encoding: "utf-8",
525
+ env: spawnEnv,
526
+ input: content,
527
+ timeout: params.timeout_ms ?? 60_000,
528
+ });
529
+
530
+ return {
531
+ ok: result.status === 0,
532
+ action: "file",
533
+ host,
534
+ exitCode: result.status ?? 1,
535
+ stdout: String(result.stdout || ""),
536
+ stderr: String(result.stderr || ""),
537
+ fileProcessed: filePath,
538
+ bytesProcessed: Buffer.byteLength(content, "utf-8"),
539
+ socketReused: wasSocketActive,
540
+ durationMs: Date.now() - startTime,
541
+ error: result.error ? result.error.message : undefined,
542
+ };
543
+ } catch (err: unknown) {
544
+ return {
545
+ ok: false,
546
+ action: "file",
547
+ host,
548
+ error: (err as Error).message,
549
+ durationMs: Date.now() - startTime,
550
+ };
551
+ }
552
+ }
553
+
554
+ return {
555
+ ok: false,
556
+ action: params.action,
557
+ error: `Unknown action: ${(params as { action: string }).action}`,
558
+ durationMs: Date.now() - startTime,
559
+ };
560
+ }