smooth-ssh-mcp 0.1.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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +319 -0
  3. package/README.md +32 -0
  4. package/README.zh-CN.md +319 -0
  5. package/bin/smooth-ssh-mcp-codex +43 -0
  6. package/dist/audit.d.ts +23 -0
  7. package/dist/audit.js +140 -0
  8. package/dist/audit.js.map +1 -0
  9. package/dist/auth.d.ts +8 -0
  10. package/dist/auth.js +19 -0
  11. package/dist/auth.js.map +1 -0
  12. package/dist/doctor.d.ts +27 -0
  13. package/dist/doctor.js +169 -0
  14. package/dist/doctor.js.map +1 -0
  15. package/dist/forwardManager.d.ts +49 -0
  16. package/dist/forwardManager.js +141 -0
  17. package/dist/forwardManager.js.map +1 -0
  18. package/dist/init.d.ts +21 -0
  19. package/dist/init.js +80 -0
  20. package/dist/init.js.map +1 -0
  21. package/dist/inventory.d.ts +4 -0
  22. package/dist/inventory.js +262 -0
  23. package/dist/inventory.js.map +1 -0
  24. package/dist/mcpServer.d.ts +8 -0
  25. package/dist/mcpServer.js +403 -0
  26. package/dist/mcpServer.js.map +1 -0
  27. package/dist/operations.d.ts +167 -0
  28. package/dist/operations.js +1240 -0
  29. package/dist/operations.js.map +1 -0
  30. package/dist/policy.d.ts +21 -0
  31. package/dist/policy.js +470 -0
  32. package/dist/policy.js.map +1 -0
  33. package/dist/redaction.d.ts +2 -0
  34. package/dist/redaction.js +64 -0
  35. package/dist/redaction.js.map +1 -0
  36. package/dist/runner.d.ts +24 -0
  37. package/dist/runner.js +90 -0
  38. package/dist/runner.js.map +1 -0
  39. package/dist/server.d.ts +9 -0
  40. package/dist/server.js +130 -0
  41. package/dist/server.js.map +1 -0
  42. package/dist/sessionManager.d.ts +77 -0
  43. package/dist/sessionManager.js +195 -0
  44. package/dist/sessionManager.js.map +1 -0
  45. package/dist/sshArgs.d.ts +24 -0
  46. package/dist/sshArgs.js +135 -0
  47. package/dist/sshArgs.js.map +1 -0
  48. package/dist/stateStore.d.ts +27 -0
  49. package/dist/stateStore.js +99 -0
  50. package/dist/stateStore.js.map +1 -0
  51. package/dist/types.d.ts +95 -0
  52. package/dist/types.js +2 -0
  53. package/dist/types.js.map +1 -0
  54. package/docs/mcp-client.example.json +15 -0
  55. package/examples/hosts.example.yaml +79 -0
  56. package/package.json +58 -0
@@ -0,0 +1,1240 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { unlinkSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join, posix as pathPosix } from "node:path";
5
+ import { wrapWithPasswordAuth } from "./auth.js";
6
+ import { buildScpArgs, buildSshArgs, controlPathForHost } from "./sshArgs.js";
7
+ import { ForwardManager } from "./forwardManager.js";
8
+ import { findHost } from "./inventory.js";
9
+ import { evaluateOperationPolicy, issueConfirmation, verifyConfirmation } from "./policy.js";
10
+ import { redactAndTruncate } from "./redaction.js";
11
+ import { nodeRunner } from "./runner.js";
12
+ import { SessionManager } from "./sessionManager.js";
13
+ import { defaultStatePath, StateStore } from "./stateStore.js";
14
+ const DANGEROUS_CLEANUP_PATHS = new Set([
15
+ "/",
16
+ "/bin",
17
+ "/boot",
18
+ "/dev",
19
+ "/etc",
20
+ "/home",
21
+ "/lib",
22
+ "/lib64",
23
+ "/opt",
24
+ "/proc",
25
+ "/root",
26
+ "/run",
27
+ "/sbin",
28
+ "/srv",
29
+ "/sys",
30
+ "/tmp",
31
+ "/usr",
32
+ "/var"
33
+ ]);
34
+ const DEFAULT_BATCH_OUTPUT_LIMIT_BYTES = 2048;
35
+ const MAX_BATCH_OUTPUT_LIMIT_BYTES = 4096;
36
+ export class SshOperations {
37
+ inventory;
38
+ runner;
39
+ controlDir;
40
+ sessions;
41
+ forwards;
42
+ env;
43
+ stateStore;
44
+ sessionInputBuffers = new Map();
45
+ constructor(options) {
46
+ this.inventory = options.inventory;
47
+ this.runner = options.runner ?? nodeRunner;
48
+ this.env = options.env ?? process.env;
49
+ this.controlDir = options.controlDir ?? join(homedir(), ".cache", "smooth-ssh-mcp", "control");
50
+ this.sessions = options.sessionManager ?? new SessionManager({ controlDir: this.controlDir, env: this.env });
51
+ this.forwards = options.forwardManager ?? new ForwardManager({ controlDir: this.controlDir, env: this.env });
52
+ this.stateStore = options.stateStore ?? new StateStore(defaultStatePath());
53
+ }
54
+ dispose() {
55
+ this.sessions.stopAll();
56
+ }
57
+ hostList() {
58
+ return this.inventory.hosts.map((rawHost) => {
59
+ const effectiveHost = this.applyStatePolicy(rawHost);
60
+ const { identityFile, passwordEnv, ...host } = effectiveHost;
61
+ return {
62
+ ...host,
63
+ hasIdentityFile: Boolean(identityFile),
64
+ hasPasswordEnv: Boolean(passwordEnv)
65
+ };
66
+ });
67
+ }
68
+ hostGet(hostId) {
69
+ const { identityFile, passwordEnv, ...host } = this.findEffectiveHost(hostId);
70
+ return { ...host, hasIdentityFile: Boolean(identityFile), hasPasswordEnv: Boolean(passwordEnv) };
71
+ }
72
+ hostSelect(input) {
73
+ findHost(this.inventory, input.hostId);
74
+ return this.stateStore.selectHost(input.hostId, "manual");
75
+ }
76
+ hostRecent() {
77
+ const state = this.stateStore.getState();
78
+ return {
79
+ selectedHostId: state.selectedHostId,
80
+ recentHosts: state.recentHosts
81
+ };
82
+ }
83
+ hostPermissionSet(input) {
84
+ const host = findHost(this.inventory, input.hostId);
85
+ const command = `host_permission_set ${input.hostId} ${input.permissionLevel}`;
86
+ if (input.permissionLevel === 1 &&
87
+ !verifyConfirmation(input.confirmationToken, {
88
+ host,
89
+ operation: "permission",
90
+ command
91
+ })) {
92
+ return issueConfirmation({
93
+ host,
94
+ operation: "permission",
95
+ command,
96
+ reasons: ["permission level 1 bypasses the smooth-ssh confirmation layer"]
97
+ });
98
+ }
99
+ this.stateStore.setPermissionLevel(input.hostId, input.permissionLevel);
100
+ return {
101
+ hostId: input.hostId,
102
+ permissionLevel: input.permissionLevel
103
+ };
104
+ }
105
+ capabilityList() {
106
+ return {
107
+ capabilities: [
108
+ {
109
+ id: "host_health",
110
+ preferredTool: "host_health",
111
+ access: "read",
112
+ description: "Fixed read-only host inspection for system, load, memory, disk, services, ports, Docker, and critical logs."
113
+ },
114
+ {
115
+ id: "single_command",
116
+ preferredTool: "ssh_command",
117
+ access: "read-or-confirmed-write",
118
+ description: "Run one remote program with argv arguments and semantic policy checks."
119
+ },
120
+ {
121
+ id: "batch_tasks",
122
+ preferredTool: "task_batch",
123
+ access: "read-or-confirmed-write",
124
+ description: "Run multiple argv tasks one by one without building a composite shell command. Defaults to compact per-task output; pass detail=full only when raw output is needed."
125
+ },
126
+ {
127
+ id: "cleanup_paths",
128
+ preferredTool: "cleanup_paths",
129
+ access: "confirmed-write",
130
+ description: "Delete exact remote paths or empty exact directories after a single path-list confirmation."
131
+ },
132
+ {
133
+ id: "interactive_session",
134
+ preferredTool: "host_connect",
135
+ access: "pty",
136
+ description: "Open a managed interactive SSH session only when the user asks for a shell-like workflow."
137
+ }
138
+ ],
139
+ shellFallback: {
140
+ tool: "ssh_exec",
141
+ useWhen: "No structured capability or argv command fits; shell syntax and complex commands are policy-gated."
142
+ }
143
+ };
144
+ }
145
+ async hostConnect(input = {}) {
146
+ const hostId = input.hostId ?? this.stateStore.getState().selectedHostId;
147
+ if (!hostId) {
148
+ throw new Error("No hostId provided and no selected host is saved");
149
+ }
150
+ const host = this.findEffectiveHost(hostId);
151
+ const retryCount = input.retryCount ?? 2;
152
+ const retryDelayMs = input.retryDelayMs ?? 2000;
153
+ const probeTimeoutMs = hostConnectProbeTimeoutMs(input.timeoutMs, retryCount, retryDelayMs);
154
+ let probe;
155
+ let attempts = 0;
156
+ for (let attempt = 0; attempt <= retryCount; attempt++) {
157
+ attempts = attempt + 1;
158
+ probe = await this.sshProbe({ hostId: host.id, timeoutMs: probeTimeoutMs });
159
+ if (probe.ok || !isTransientProbeFailure(probe) || attempt === retryCount)
160
+ break;
161
+ await sleep(retryDelayMs);
162
+ }
163
+ if (!probe)
164
+ throw new Error("SSH probe did not run");
165
+ if (!probe.ok) {
166
+ return {
167
+ hostId: host.id,
168
+ connected: false,
169
+ probeConnected: false,
170
+ target: hostTargetSummary(host),
171
+ attempts,
172
+ probe
173
+ };
174
+ }
175
+ if (input.startSession !== true || !host.policy.allowPty) {
176
+ const sessionBlockedReason = input.startSession === true && !host.policy.allowPty ? "pty is disabled by host policy" : undefined;
177
+ return {
178
+ hostId: host.id,
179
+ connected: true,
180
+ probeConnected: true,
181
+ target: hostTargetSummary(host),
182
+ attempts,
183
+ probe,
184
+ sessionStarted: false,
185
+ ...(sessionBlockedReason ? { sessionBlockedReason } : {}),
186
+ session: null
187
+ };
188
+ }
189
+ const session = this.sessionStart({ hostId: host.id, confirmationToken: input.confirmationToken });
190
+ const sessionStarted = hasSessionId(session);
191
+ return {
192
+ hostId: host.id,
193
+ connected: true,
194
+ probeConnected: true,
195
+ target: hostTargetSummary(host),
196
+ attempts,
197
+ probe,
198
+ sessionStarted,
199
+ ...(!sessionStarted ? { sessionBlockedReason: policyReason(session) } : {}),
200
+ session
201
+ };
202
+ }
203
+ async sshProbe(input) {
204
+ const host = this.findEffectiveHost(input.hostId);
205
+ const timeoutMs = input.timeoutMs ?? 10_000;
206
+ const command = [
207
+ "printf 'smooth-ssh-ok\\n'",
208
+ "uname -srm 2>/dev/null || true",
209
+ "test -f /etc/openwrt_release && printf 'openwrt=true\\n' || true"
210
+ ].join("; ");
211
+ const argv = buildSshArgs(host, {
212
+ controlDir: this.controlDir,
213
+ command,
214
+ timeoutSeconds: sshConnectTimeoutSeconds(timeoutMs)
215
+ });
216
+ const commandSpec = wrapWithPasswordAuth(host, "ssh", argv, this.env);
217
+ const result = await this.runner.run(commandSpec.file, commandSpec.args, {
218
+ timeoutMs,
219
+ maxBufferBytes: host.policy.maxOutputBytes,
220
+ env: commandSpec.env
221
+ });
222
+ if (result.exitCode === 0)
223
+ this.stateStore.recordHostUse(host.id, "probe");
224
+ return {
225
+ hostId: host.id,
226
+ ok: result.exitCode === 0,
227
+ stages: {
228
+ ssh: {
229
+ ok: result.exitCode === 0,
230
+ exitCode: result.exitCode,
231
+ diagnostic: result.exitCode === 0 ? classifySshSuccess(result.stderr) : classifySshFailure(result.stderr, result.timedOut)
232
+ }
233
+ },
234
+ stdout: redactAndTruncate(result.stdout, host.policy.maxOutputBytes).text,
235
+ stderr: redactAndTruncate(result.stderr, host.policy.maxOutputBytes).text
236
+ };
237
+ }
238
+ async sshExec(input) {
239
+ const host = this.findEffectiveHost(input.hostId);
240
+ const command = composeRemoteCommand(input);
241
+ const stdinHash = hashOptionalInput(input.stdin);
242
+ const policy = evaluateOperationPolicy({
243
+ host,
244
+ operation: "exec",
245
+ command,
246
+ stdin: input.stdin,
247
+ stdinHash
248
+ });
249
+ const confirmationVerified = !policy.allowed && verifyConfirmation(input.confirmationToken, { host, operation: "exec", command, stdin: input.stdin, stdinHash });
250
+ if (!policy.allowed && !confirmationVerified) {
251
+ if (policy.confirmationRequired) {
252
+ return issueConfirmation({
253
+ host,
254
+ operation: "exec",
255
+ command,
256
+ stdin: input.stdin,
257
+ stdinHash,
258
+ reasons: policy.reasons
259
+ });
260
+ }
261
+ return policy;
262
+ }
263
+ return this.runRemoteCommand(host, command, {
264
+ timeoutMs: input.timeoutMs,
265
+ stdin: input.stdin,
266
+ allowRetry: policy.allowed
267
+ });
268
+ }
269
+ async sshCommand(input) {
270
+ const host = this.findEffectiveHost(input.hostId);
271
+ const args = input.args ?? [];
272
+ validateArgvProgram(input.program);
273
+ args.forEach((arg, index) => validateArgvArg(arg, index));
274
+ const policyCommand = composeArgvPolicyCommand(input);
275
+ const remoteCommand = composeArgvRemoteCommand(input);
276
+ const access = classifyArgvAccess(input.program, args);
277
+ const policy = evaluateOperationPolicy({
278
+ host,
279
+ operation: "exec",
280
+ command: policyCommand,
281
+ commandMode: "argv",
282
+ access
283
+ });
284
+ const confirmationVerified = !policy.allowed &&
285
+ verifyConfirmation(input.confirmationToken, {
286
+ host,
287
+ operation: "exec",
288
+ command: policyCommand,
289
+ commandMode: "argv",
290
+ access
291
+ });
292
+ if (!policy.allowed && !confirmationVerified) {
293
+ if (policy.confirmationRequired) {
294
+ return issueConfirmation({
295
+ host,
296
+ operation: "exec",
297
+ command: policyCommand,
298
+ commandMode: "argv",
299
+ access,
300
+ reasons: policy.reasons
301
+ });
302
+ }
303
+ return policy;
304
+ }
305
+ return this.runRemoteCommand(host, remoteCommand, {
306
+ timeoutMs: input.timeoutMs,
307
+ allowRetry: policy.allowed && access === "read"
308
+ });
309
+ }
310
+ async taskBatch(input) {
311
+ const completed = [];
312
+ const detail = input.detail ?? "compact";
313
+ const outputLimitBytes = normalizeBatchOutputLimit(input.outputLimitBytes);
314
+ const startAt = normalizeBatchStartAt(input.startAt, input.tasks.length);
315
+ for (const [index, task] of input.tasks.entries()) {
316
+ if (index < startAt)
317
+ continue;
318
+ const id = task.id ?? `task-${index + 1}`;
319
+ const result = await this.sshCommand({
320
+ hostId: input.hostId,
321
+ program: task.program,
322
+ args: task.args,
323
+ timeoutMs: task.timeoutMs ?? input.timeoutMs,
324
+ confirmationToken: input.confirmationToken
325
+ });
326
+ if (!isExecResult(result)) {
327
+ return {
328
+ hostId: input.hostId,
329
+ completed,
330
+ blocked: {
331
+ id,
332
+ index,
333
+ resumeFrom: index,
334
+ result
335
+ }
336
+ };
337
+ }
338
+ completed.push({
339
+ id,
340
+ program: task.program,
341
+ args: task.args ?? [],
342
+ exitCode: result.exitCode,
343
+ ...formatBatchOutput(result, detail, outputLimitBytes),
344
+ diagnostic: result.diagnostic,
345
+ durationMs: result.durationMs,
346
+ redactions: result.redactions
347
+ });
348
+ }
349
+ return {
350
+ hostId: input.hostId,
351
+ detail,
352
+ startAt,
353
+ ...(detail === "compact" ? { outputLimitBytes } : {}),
354
+ completed
355
+ };
356
+ }
357
+ async cleanupPaths(input) {
358
+ const host = this.findEffectiveHost(input.hostId);
359
+ const targets = normalizeCleanupTargets(input.targets);
360
+ const command = composeCleanupPolicyCommand(targets);
361
+ const access = "destructive";
362
+ const policy = evaluateOperationPolicy({
363
+ host,
364
+ operation: "exec",
365
+ command,
366
+ commandMode: "argv",
367
+ access
368
+ });
369
+ if (!policy.allowed && !policy.confirmationRequired)
370
+ return policy;
371
+ const confirmationVerified = verifyConfirmation(input.confirmationToken, {
372
+ host,
373
+ operation: "exec",
374
+ command,
375
+ commandMode: "argv",
376
+ access
377
+ });
378
+ if (!confirmationVerified) {
379
+ return issueConfirmation({
380
+ host,
381
+ operation: "exec",
382
+ command,
383
+ commandMode: "argv",
384
+ access,
385
+ reasons: policy.reasons.length > 0 ? policy.reasons : ["command appears to modify remote state"]
386
+ });
387
+ }
388
+ const completed = [];
389
+ for (const target of targets) {
390
+ const remoteCommand = composeCleanupRemoteCommand(target);
391
+ const result = await this.runRemoteCommand(host, remoteCommand, {
392
+ timeoutMs: input.timeoutMs,
393
+ allowRetry: false
394
+ });
395
+ const item = {
396
+ path: target.path,
397
+ mode: target.mode,
398
+ command: remoteCommand,
399
+ exitCode: result.exitCode,
400
+ stdout: result.stdout,
401
+ stderr: result.stderr,
402
+ diagnostic: result.diagnostic,
403
+ durationMs: result.durationMs,
404
+ truncated: result.truncated,
405
+ redactions: result.redactions
406
+ };
407
+ completed.push(item);
408
+ if (result.exitCode !== 0) {
409
+ return {
410
+ hostId: input.hostId,
411
+ completed,
412
+ failed: item
413
+ };
414
+ }
415
+ }
416
+ return {
417
+ hostId: input.hostId,
418
+ completed
419
+ };
420
+ }
421
+ async hostHealth(input) {
422
+ const services = input.services ?? ["ssh", "sshd", "nginx", "docker", "containerd", "ufw", "fail2ban"];
423
+ const tasks = [
424
+ { id: "host", program: "hostnamectl" },
425
+ { id: "kernel", program: "uname", args: ["-srm"] },
426
+ { id: "uptime", program: "uptime" },
427
+ { id: "memory", program: "free", args: ["-h"] },
428
+ { id: "disk", program: "df", args: ["-hT", "-x", "tmpfs", "-x", "devtmpfs"] },
429
+ { id: "inode", program: "df", args: ["-ih", "-x", "tmpfs", "-x", "devtmpfs"] },
430
+ { id: "top-cpu", program: "ps", args: ["-eo", "pid,ppid,comm,%cpu,%mem", "--sort=-%cpu"] },
431
+ { id: "failed-units", program: "systemctl", args: ["--failed", "--no-pager"] },
432
+ ...services.map((service) => ({
433
+ id: `service:${service}`,
434
+ program: "systemctl",
435
+ args: ["is-active", service]
436
+ })),
437
+ { id: "listening-ports", program: "ss", args: ["-ltnp"] },
438
+ { id: "docker", program: "docker", args: ["ps", "--format", "table {{.Names}}\\t{{.Image}}\\t{{.Status}}\\t{{.Ports}}"] },
439
+ { id: "critical-logs", program: "journalctl", args: ["-p", "0..3", "-n", "20", "--no-pager"] }
440
+ ];
441
+ const result = await this.taskBatch({
442
+ hostId: input.hostId,
443
+ timeoutMs: input.timeoutMs,
444
+ tasks,
445
+ detail: "full"
446
+ });
447
+ const checks = Array.isArray(result.completed) ? result.completed : [];
448
+ const blocked = result.blocked;
449
+ const summary = summarizeHealth(checks, blocked);
450
+ const failedChecks = Array.isArray(summary.failedChecks) ? summary.failedChecks : [];
451
+ const includeRaw = input.detail === "compact" ? false : input.includeRaw === true || input.detail === "full";
452
+ return {
453
+ hostId: input.hostId,
454
+ ok: failedChecks.length === 0 && !blocked,
455
+ summary,
456
+ ...(blocked ? { blocked } : {}),
457
+ ...(includeRaw ? { checks } : {})
458
+ };
459
+ }
460
+ async runRemoteCommand(host, command, options) {
461
+ const timeoutMs = options.timeoutMs ?? host.policy.maxCommandSeconds * 1000;
462
+ const runExec = async () => {
463
+ const argv = buildSshArgs(host, {
464
+ controlDir: this.controlDir,
465
+ command,
466
+ timeoutSeconds: Math.ceil(timeoutMs / 1000)
467
+ });
468
+ const commandSpec = wrapWithPasswordAuth(host, "ssh", argv, this.env);
469
+ const result = await this.runner.run(commandSpec.file, commandSpec.args, {
470
+ timeoutMs,
471
+ input: options.stdin,
472
+ maxBufferBytes: host.policy.maxOutputBytes,
473
+ env: commandSpec.env
474
+ });
475
+ return { commandSpec, result };
476
+ };
477
+ let attempts = 1;
478
+ let { commandSpec, result } = await runExec();
479
+ if (options.allowRetry && shouldRetrySshExec(result)) {
480
+ clearControlSocket(host, this.controlDir);
481
+ attempts = 2;
482
+ ({ commandSpec, result } = await runExec());
483
+ }
484
+ const stdout = redactAndTruncate(result.stdout, host.policy.maxOutputBytes);
485
+ const stderr = redactAndTruncate(result.stderr, host.policy.maxOutputBytes);
486
+ if (result.exitCode === 0)
487
+ this.stateStore.recordHostUse(host.id, "exec");
488
+ const diagnostic = result.exitCode === 0 ? classifySshSuccess(result.stderr) : classifySshFailure(result.stderr, result.timedOut);
489
+ return {
490
+ hostId: host.id,
491
+ commandId: randomUUID(),
492
+ exitCode: result.exitCode,
493
+ signal: result.signal,
494
+ stdout: stdout.text,
495
+ stderr: stderr.text,
496
+ startedAt: result.startedAt.toISOString(),
497
+ endedAt: result.endedAt.toISOString(),
498
+ durationMs: result.durationMs,
499
+ truncated: stdout.truncated || stderr.truncated || Boolean(result.stdoutTruncated) || Boolean(result.stderrTruncated),
500
+ redactions: [...stdout.redactions, ...stderr.redactions],
501
+ diagnostic,
502
+ attempts,
503
+ ...debugArgv(commandSpec.args, this.env)
504
+ };
505
+ }
506
+ async fileUpload(input) {
507
+ return this.fileTransfer("upload", input);
508
+ }
509
+ async fileDownload(input) {
510
+ return this.fileTransfer("download", input);
511
+ }
512
+ sessionStart(input) {
513
+ const host = this.findEffectiveHost(input.hostId);
514
+ const policy = evaluateOperationPolicy({ host, operation: "pty" });
515
+ if (!policy.allowed && !verifyConfirmation(input.confirmationToken, { host, operation: "pty" })) {
516
+ if (policy.confirmationRequired) {
517
+ return issueConfirmation({
518
+ host,
519
+ operation: "pty",
520
+ reasons: policy.reasons
521
+ });
522
+ }
523
+ return policy;
524
+ }
525
+ const session = this.sessions.start(host);
526
+ this.stateStore.recordHostUse(host.id, "pty");
527
+ return session;
528
+ }
529
+ sessionSend(input) {
530
+ const bufferedInput = this.sessionInputBuffers.get(input.sessionId) ?? "";
531
+ const candidateInput = bufferedInput + input.input;
532
+ const completedInput = completedSessionInput(candidateInput);
533
+ if (completedInput && completedInput.trim().length > 0) {
534
+ const sessionHost = this.sessions.hostForSession(input.sessionId);
535
+ const host = this.findEffectiveHost(sessionHost.id);
536
+ const policy = evaluateOperationPolicy({
537
+ host,
538
+ operation: "pty-input",
539
+ command: completedInput
540
+ });
541
+ const confirmationVerified = !policy.allowed &&
542
+ verifyConfirmation(input.confirmationToken, {
543
+ host,
544
+ operation: "pty-input",
545
+ command: completedInput
546
+ });
547
+ if (!policy.allowed && !confirmationVerified) {
548
+ if (policy.confirmationRequired) {
549
+ return issueConfirmation({
550
+ host,
551
+ operation: "pty-input",
552
+ command: completedInput,
553
+ reasons: policy.reasons
554
+ });
555
+ }
556
+ return policy;
557
+ }
558
+ }
559
+ const result = this.sessions.send(input.sessionId, input.input);
560
+ this.updateSessionInputBuffer(input.sessionId, candidateInput);
561
+ return result;
562
+ }
563
+ sessionRead(input) {
564
+ return this.sessions.read(input.sessionId, input.maxBytes);
565
+ }
566
+ sessionStop(input) {
567
+ this.sessionInputBuffers.delete(input.sessionId);
568
+ return this.sessions.stop(input.sessionId);
569
+ }
570
+ sessionList() {
571
+ return this.sessions.list();
572
+ }
573
+ updateSessionInputBuffer(sessionId, candidateInput) {
574
+ const lastNewline = lastSessionNewlineIndex(candidateInput);
575
+ if (lastNewline < 0) {
576
+ this.sessionInputBuffers.set(sessionId, candidateInput);
577
+ return;
578
+ }
579
+ const remaining = candidateInput.slice(lastNewline + 1);
580
+ if (remaining) {
581
+ this.sessionInputBuffers.set(sessionId, remaining);
582
+ }
583
+ else {
584
+ this.sessionInputBuffers.delete(sessionId);
585
+ }
586
+ }
587
+ async forwardStart(input) {
588
+ const host = this.findEffectiveHost(input.hostId);
589
+ const policy = evaluateOperationPolicy({
590
+ host,
591
+ operation: "forward",
592
+ ports: [`${input.localHost ?? "127.0.0.1"}:${input.localPort}`, `${input.remoteHost}:${input.remotePort}`]
593
+ });
594
+ if (!policy.allowed &&
595
+ !verifyConfirmation(input.confirmationToken, {
596
+ host,
597
+ operation: "forward",
598
+ ports: [`${input.localHost ?? "127.0.0.1"}:${input.localPort}`, `${input.remoteHost}:${input.remotePort}`]
599
+ })) {
600
+ if (policy.confirmationRequired) {
601
+ return issueConfirmation({
602
+ host,
603
+ operation: "forward",
604
+ ports: [`${input.localHost ?? "127.0.0.1"}:${input.localPort}`, `${input.remoteHost}:${input.remotePort}`],
605
+ reasons: policy.reasons
606
+ });
607
+ }
608
+ return policy;
609
+ }
610
+ const forward = this.forwards.start({
611
+ host,
612
+ localHost: input.localHost,
613
+ localPort: input.localPort,
614
+ remoteHost: input.remoteHost,
615
+ remotePort: input.remotePort
616
+ });
617
+ this.stateStore.recordHostUse(host.id, "forward");
618
+ return forward;
619
+ }
620
+ forwardStop(input) {
621
+ return this.forwards.stop(input.forwardId);
622
+ }
623
+ forwardList() {
624
+ return this.forwards.list();
625
+ }
626
+ findEffectiveHost(hostId) {
627
+ return this.applyStatePolicy(findHost(this.inventory, hostId));
628
+ }
629
+ applyStatePolicy(host) {
630
+ const permissionLevel = this.stateStore.permissionLevelFor(host.id);
631
+ if (!permissionLevel)
632
+ return host;
633
+ return {
634
+ ...host,
635
+ policy: {
636
+ ...host.policy,
637
+ permissionLevel
638
+ }
639
+ };
640
+ }
641
+ async fileTransfer(operation, input) {
642
+ const host = this.findEffectiveHost(input.hostId);
643
+ const policy = evaluateOperationPolicy({
644
+ host,
645
+ operation,
646
+ localPath: input.localPath,
647
+ remotePath: input.remotePath
648
+ });
649
+ if (!policy.allowed && !verifyConfirmation(input.confirmationToken, { host, operation, localPath: input.localPath, remotePath: input.remotePath })) {
650
+ if (policy.confirmationRequired) {
651
+ return issueConfirmation({
652
+ host,
653
+ operation,
654
+ localPath: input.localPath,
655
+ remotePath: input.remotePath,
656
+ reasons: policy.reasons
657
+ });
658
+ }
659
+ return policy;
660
+ }
661
+ const argv = buildScpArgs(host, {
662
+ controlDir: this.controlDir,
663
+ direction: operation,
664
+ localPath: input.localPath,
665
+ remotePath: input.remotePath
666
+ });
667
+ const commandSpec = wrapWithPasswordAuth(host, "scp", argv, this.env);
668
+ const result = await this.runner.run(commandSpec.file, commandSpec.args, {
669
+ timeoutMs: host.policy.maxCommandSeconds * 1000,
670
+ maxBufferBytes: host.policy.maxOutputBytes,
671
+ env: commandSpec.env
672
+ });
673
+ const stdout = redactAndTruncate(result.stdout, host.policy.maxOutputBytes);
674
+ const stderr = redactAndTruncate(result.stderr, host.policy.maxOutputBytes);
675
+ if (result.exitCode === 0)
676
+ this.stateStore.recordHostUse(host.id, operation);
677
+ return {
678
+ hostId: host.id,
679
+ commandId: randomUUID(),
680
+ exitCode: result.exitCode,
681
+ signal: result.signal,
682
+ stdout: stdout.text,
683
+ stderr: stderr.text,
684
+ startedAt: result.startedAt.toISOString(),
685
+ endedAt: result.endedAt.toISOString(),
686
+ durationMs: result.durationMs,
687
+ truncated: stdout.truncated || stderr.truncated || Boolean(result.stdoutTruncated) || Boolean(result.stderrTruncated),
688
+ redactions: [...stdout.redactions, ...stderr.redactions],
689
+ diagnostic: result.exitCode === 0 ? classifySshSuccess(result.stderr) : classifySshFailure(result.stderr, result.timedOut),
690
+ attempts: 1,
691
+ ...debugArgv(commandSpec.args, this.env)
692
+ };
693
+ }
694
+ }
695
+ function normalizeBatchOutputLimit(value) {
696
+ if (value === undefined)
697
+ return DEFAULT_BATCH_OUTPUT_LIMIT_BYTES;
698
+ if (!Number.isFinite(value) || value < 1)
699
+ throw new Error("Invalid outputLimitBytes: expected a positive number");
700
+ return Math.min(Math.floor(value), MAX_BATCH_OUTPUT_LIMIT_BYTES);
701
+ }
702
+ function normalizeBatchStartAt(value, taskCount) {
703
+ if (value === undefined)
704
+ return 0;
705
+ if (!Number.isInteger(value) || value < 0 || value > taskCount) {
706
+ throw new Error(`Invalid startAt: expected an integer from 0 to ${taskCount}`);
707
+ }
708
+ return value;
709
+ }
710
+ function completedSessionInput(value) {
711
+ const lastNewline = lastSessionNewlineIndex(value);
712
+ return lastNewline >= 0 ? value.slice(0, lastNewline + 1) : undefined;
713
+ }
714
+ function lastSessionNewlineIndex(value) {
715
+ return Math.max(value.lastIndexOf("\n"), value.lastIndexOf("\r"));
716
+ }
717
+ function formatBatchOutput(result, detail, outputLimitBytes) {
718
+ const stdout = detail === "full" ? fullOutput(result.stdout) : compactOutput(result.stdout, outputLimitBytes);
719
+ const stderr = detail === "full" ? fullOutput(result.stderr) : compactOutput(result.stderr, outputLimitBytes);
720
+ return {
721
+ stdout: stdout.text,
722
+ stderr: stderr.text,
723
+ stdoutBytes: stdout.bytes,
724
+ stderrBytes: stderr.bytes,
725
+ stdoutTruncated: stdout.truncated,
726
+ stderrTruncated: stderr.truncated,
727
+ truncated: result.truncated || stdout.truncated || stderr.truncated
728
+ };
729
+ }
730
+ function fullOutput(text) {
731
+ return {
732
+ text,
733
+ bytes: Buffer.byteLength(text, "utf8"),
734
+ truncated: false
735
+ };
736
+ }
737
+ function compactOutput(text, limitBytes) {
738
+ const bytes = Buffer.byteLength(text, "utf8");
739
+ if (bytes <= limitBytes) {
740
+ return {
741
+ text,
742
+ bytes,
743
+ truncated: false
744
+ };
745
+ }
746
+ return {
747
+ text: Buffer.from(text, "utf8").subarray(0, limitBytes).toString("utf8"),
748
+ bytes,
749
+ truncated: true
750
+ };
751
+ }
752
+ function composeRemoteCommand(input) {
753
+ let command = input.command;
754
+ if (input.env) {
755
+ const env = Object.entries(input.env)
756
+ .map(([key, value]) => {
757
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
758
+ throw new Error(`Invalid env key: ${key}`);
759
+ if (value.includes("\0"))
760
+ throw new Error(`Invalid env value for ${key}: contains NUL`);
761
+ return `${key}=${shellQuote(value)}`;
762
+ })
763
+ .join(" ");
764
+ if (env)
765
+ command = `${env} ${command}`;
766
+ }
767
+ if (input.cwd) {
768
+ if (input.cwd.includes("\0") || input.cwd.includes("\n") || input.cwd.includes("\r")) {
769
+ throw new Error("Invalid cwd: contains a control character");
770
+ }
771
+ command = `cd ${shellQuote(input.cwd)} && ${command}`;
772
+ }
773
+ if (input.sudo === "nopasswd") {
774
+ command = `sudo -n sh -lc ${shellQuote(command)}`;
775
+ }
776
+ return command;
777
+ }
778
+ function summarizeHealth(checks, blocked) {
779
+ const byId = new Map(checks.map((check) => [check.id, check]));
780
+ const services = summarizeServices(checks);
781
+ const failedChecks = checks
782
+ .filter((check) => check.exitCode !== 0 && !check.id.startsWith("service:"))
783
+ .map((check) => ({
784
+ id: check.id,
785
+ exitCode: check.exitCode,
786
+ diagnostic: check.diagnostic,
787
+ message: firstNonEmptyLine(check.stderr)
788
+ }));
789
+ const notices = summarizeNotices({ services, checks, blocked, failedChecks });
790
+ return {
791
+ system: summarizeSystem(byId),
792
+ uptime: summarizeUptime(byId.get("uptime")?.stdout ?? ""),
793
+ memory: summarizeMemory(byId.get("memory")?.stdout ?? ""),
794
+ disk: summarizeDisk(byId.get("disk")?.stdout ?? ""),
795
+ inode: summarizeDisk(byId.get("inode")?.stdout ?? ""),
796
+ services,
797
+ docker: summarizeDocker(byId.get("docker")?.stdout ?? ""),
798
+ ports: summarizePorts(byId.get("listening-ports")?.stdout ?? ""),
799
+ failedChecks,
800
+ notices
801
+ };
802
+ }
803
+ function summarizeSystem(byId) {
804
+ const host = byId.get("host")?.stdout ?? "";
805
+ const kernel = byId.get("kernel")?.stdout.trim() || matchLineValue(host, "Kernel");
806
+ return {
807
+ hostname: matchLineValue(host, "Static hostname"),
808
+ os: matchLineValue(host, "Operating System"),
809
+ kernel: kernel ? kernel.replace(/\s+x86_64$/, "") : undefined
810
+ };
811
+ }
812
+ function summarizeUptime(stdout) {
813
+ const load = stdout.match(/load averages?:\s*([0-9.]+),\s*([0-9.]+),\s*([0-9.]+)/i);
814
+ const up = stdout.match(/\bup\s+(.+?),\s+\d+\s+users?/i);
815
+ return {
816
+ text: stdout.trim() || undefined,
817
+ up: up?.[1]?.trim(),
818
+ load: load ? [load[1], load[2], load[3]] : undefined
819
+ };
820
+ }
821
+ function summarizeMemory(stdout) {
822
+ const memLine = stdout.split(/\r?\n/).find((line) => /^Mem:\s+/.test(line));
823
+ const swapLine = stdout.split(/\r?\n/).find((line) => /^Swap:\s+/.test(line));
824
+ const mem = memLine?.trim().split(/\s+/) ?? [];
825
+ const swap = swapLine?.trim().split(/\s+/) ?? [];
826
+ return {
827
+ total: mem[1],
828
+ used: mem[2],
829
+ free: mem[3],
830
+ available: mem[6],
831
+ swapTotal: swap[1],
832
+ swapUsed: swap[2],
833
+ swapFree: swap[3]
834
+ };
835
+ }
836
+ function summarizeDisk(stdout) {
837
+ const root = stdout.split(/\r?\n/).find((line) => /\s\/$/.test(line));
838
+ if (!root)
839
+ return {};
840
+ const parts = root.trim().split(/\s+/);
841
+ return {
842
+ root: {
843
+ filesystem: parts[0],
844
+ type: parts.length >= 7 ? parts[1] : undefined,
845
+ size: parts.length >= 7 ? parts[2] : parts[1],
846
+ used: parts.length >= 7 ? parts[3] : parts[2],
847
+ available: parts.length >= 7 ? parts[4] : parts[3],
848
+ usePercent: parts.length >= 7 ? parts[5] : parts[4]
849
+ }
850
+ };
851
+ }
852
+ function summarizeServices(checks) {
853
+ const services = {};
854
+ for (const check of checks) {
855
+ if (!check.id.startsWith("service:"))
856
+ continue;
857
+ const service = check.id.slice("service:".length);
858
+ services[service] = check.stdout.trim() || (check.exitCode === 0 ? "active" : "inactive");
859
+ }
860
+ return services;
861
+ }
862
+ function summarizeDocker(stdout) {
863
+ const rows = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).slice(1);
864
+ return {
865
+ running: rows.length,
866
+ healthy: rows.filter((row) => /\(healthy\)/i.test(row)).length,
867
+ names: rows.map((row) => row.split(/\s+/)[0]).filter(Boolean)
868
+ };
869
+ }
870
+ function summarizePorts(stdout) {
871
+ const publicPorts = new Set();
872
+ const localPorts = new Set();
873
+ for (const line of stdout.split(/\r?\n/)) {
874
+ if (!line.includes("LISTEN"))
875
+ continue;
876
+ const match = line.match(/\s(\S+):(\d+)\s+\S+:\*/);
877
+ if (!match)
878
+ continue;
879
+ const address = match[1];
880
+ const port = match[2];
881
+ if (address === "127.0.0.1" || address === "::1" || address === "[::1]") {
882
+ localPorts.add(port);
883
+ }
884
+ else {
885
+ publicPorts.add(port);
886
+ }
887
+ }
888
+ return {
889
+ public: [...publicPorts].sort(sortNumericStrings),
890
+ local: [...localPorts].sort(sortNumericStrings)
891
+ };
892
+ }
893
+ function summarizeNotices(input) {
894
+ const notices = [];
895
+ if (input.services.ufw && input.services.ufw !== "active")
896
+ notices.push("ufw is inactive");
897
+ if (input.services.fail2ban && input.services.fail2ban !== "active")
898
+ notices.push("fail2ban is inactive");
899
+ const criticalLogs = input.checks.find((check) => check.id === "critical-logs");
900
+ if (criticalLogs?.stdout.trim())
901
+ notices.push("critical logs present");
902
+ const failedUnits = input.checks.find((check) => check.id === "failed-units");
903
+ if (failedUnits && failedUnits.exitCode !== 0)
904
+ notices.push("systemd failed units present");
905
+ if (input.failedChecks.length > 0)
906
+ notices.push("one or more health checks failed");
907
+ if (input.blocked)
908
+ notices.push("health check blocked before completion");
909
+ return notices;
910
+ }
911
+ function matchLineValue(text, label) {
912
+ const pattern = new RegExp(`${escapeRegExp(label)}:\\s*(.+)`, "i");
913
+ return text.match(pattern)?.[1]?.trim();
914
+ }
915
+ function firstNonEmptyLine(text) {
916
+ return text.split(/\r?\n/).map((line) => line.trim()).find(Boolean);
917
+ }
918
+ function sortNumericStrings(a, b) {
919
+ return Number(a) - Number(b);
920
+ }
921
+ function escapeRegExp(value) {
922
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
923
+ }
924
+ function composeArgvRemoteCommand(input) {
925
+ return composeRemoteCommand({
926
+ hostId: input.hostId,
927
+ command: [input.program, ...(input.args ?? [])].map(shellQuote).join(" "),
928
+ cwd: input.cwd,
929
+ env: input.env,
930
+ sudo: input.sudo,
931
+ timeoutMs: input.timeoutMs,
932
+ confirmationToken: input.confirmationToken
933
+ });
934
+ }
935
+ function composeArgvPolicyCommand(input) {
936
+ let command = [input.program, ...(input.args ?? [])].join(" ");
937
+ if (input.sudo === "nopasswd")
938
+ command = `sudo -n ${command}`;
939
+ if (input.cwd)
940
+ command = `cd ${input.cwd} && ${command}`;
941
+ if (input.env) {
942
+ const env = Object.keys(input.env).join(" ");
943
+ if (env)
944
+ command = `${env} ${command}`;
945
+ }
946
+ return command;
947
+ }
948
+ function validateArgvProgram(value) {
949
+ if (!value || value.includes("\0") || value.includes("\n") || value.includes("\r")) {
950
+ throw new Error("Invalid program: contains a control character or is empty");
951
+ }
952
+ if (value.startsWith("-")) {
953
+ throw new Error("Invalid program: leading dash is not allowed");
954
+ }
955
+ if (/[\s;|&`$<>]/.test(value)) {
956
+ throw new Error("Invalid program: contains whitespace or shell metacharacters");
957
+ }
958
+ }
959
+ function validateArgvArg(value, index) {
960
+ if (value.includes("\0") || value.includes("\n") || value.includes("\r")) {
961
+ throw new Error(`Invalid args[${index}]: contains a control character`);
962
+ }
963
+ }
964
+ function normalizeCleanupTargets(targets) {
965
+ if (targets.length === 0)
966
+ throw new Error("cleanup_paths requires at least one target");
967
+ return targets.map((target, index) => {
968
+ const mode = target.mode ?? "delete";
969
+ if (mode !== "delete" && mode !== "empty-dir") {
970
+ throw new Error(`Invalid cleanup target mode at index ${index}: ${mode}`);
971
+ }
972
+ const path = normalizeCleanupPath(target.path, index);
973
+ return { path, mode };
974
+ });
975
+ }
976
+ function normalizeCleanupPath(value, index) {
977
+ if (!value || value.includes("\0") || value.includes("\n") || value.includes("\r")) {
978
+ throw new Error(`Invalid cleanup target path at index ${index}: contains a control character or is empty`);
979
+ }
980
+ if (!value.startsWith("/")) {
981
+ throw new Error(`Invalid cleanup target path at index ${index}: must be absolute`);
982
+ }
983
+ if (/[\s;|&`$<>'"*?[\]{}()]/.test(value)) {
984
+ throw new Error(`Invalid cleanup target path at index ${index}: contains whitespace, glob, quote, or shell metacharacters`);
985
+ }
986
+ const normalized = pathPosix.normalize(value);
987
+ if (DANGEROUS_CLEANUP_PATHS.has(normalized)) {
988
+ throw new Error(`Invalid cleanup target path at index ${index}: path is too broad`);
989
+ }
990
+ return normalized;
991
+ }
992
+ function composeCleanupPolicyCommand(targets) {
993
+ return ["cleanup_paths", ...targets.flatMap((target) => [`--${target.mode}`, target.path])].join(" ");
994
+ }
995
+ function composeCleanupRemoteCommand(target) {
996
+ if (target.mode === "empty-dir") {
997
+ return `find ${shellQuote(target.path)} -mindepth 1 -delete`;
998
+ }
999
+ return `rm -rf -- ${shellQuote(target.path)}`;
1000
+ }
1001
+ function classifyArgvAccess(program, args) {
1002
+ const rawTokens = [program, ...args].map(commandName);
1003
+ const tokens = unwrapArgvCommandTokens(rawTokens);
1004
+ const name = tokens[0] ?? commandName(program);
1005
+ const commandArgs = tokens.slice(1);
1006
+ const first = commandArgs[0];
1007
+ const joined = tokens.join(" ");
1008
+ if (/^(shutdown|reboot|halt|poweroff|mkfs|dd)$/.test(name))
1009
+ return "destructive";
1010
+ if (argvHasInterpreterExecution(rawTokens))
1011
+ return "write";
1012
+ if (name === "rm" || name === "rmdir" || name === "mv" || name === "cp" || name === "chmod" || name === "chown" || name === "tee")
1013
+ return "write";
1014
+ if (name === "sed" && commandArgs.includes("-i"))
1015
+ return "write";
1016
+ if (name === "find" && commandArgs.some((arg) => arg === "-exec" || arg === "-execdir" || arg === "-ok" || arg === "-okdir"))
1017
+ return "write";
1018
+ if (name === "xargs")
1019
+ return "write";
1020
+ if (name === "find" && commandArgs.includes("-delete"))
1021
+ return "write";
1022
+ if (argvActionAfter(tokens, "systemctl")?.match(/^(restart|reload|stop|disable|enable|daemon-reload)$/))
1023
+ return "restart";
1024
+ if (name === "service" && commandArgs[1] && /^(restart|reload|stop)$/.test(commandName(commandArgs[1])))
1025
+ return "restart";
1026
+ if (name === "ufw" && !ufwArgsAreReadOnly(commandArgs))
1027
+ return "firewall";
1028
+ if (name === "iptables" && commandArgs.some((arg) => /^-(A|I|D|R|P|F|N|X|Z)$/.test(arg)))
1029
+ return "firewall";
1030
+ if (argvActionAfter(tokens, "nft")?.match(/^(add|delete|destroy|flush|insert|replace|reset)$/))
1031
+ return "firewall";
1032
+ if (name === "docker" && /\bdocker\s+(rm|rmi|container rm|image rm|network rm|volume rm|image prune|builder prune|system prune)\b/i.test(joined)) {
1033
+ return "write";
1034
+ }
1035
+ if ([
1036
+ "cat",
1037
+ "crontab",
1038
+ "df",
1039
+ "du",
1040
+ "file",
1041
+ "free",
1042
+ "grep",
1043
+ "egrep",
1044
+ "fgrep",
1045
+ "head",
1046
+ "hostnamectl",
1047
+ "journalctl",
1048
+ "ls",
1049
+ "pgrep",
1050
+ "ps",
1051
+ "ss",
1052
+ "tail",
1053
+ "uname",
1054
+ "uptime",
1055
+ "which"
1056
+ ].includes(name)) {
1057
+ return "read";
1058
+ }
1059
+ if (name === "systemctl" && first && /^(list-units|list-unit-files|cat|status|show|is-active|is-enabled|--failed)$/.test(first))
1060
+ return "read";
1061
+ if (name === "ufw" && ufwArgsAreReadOnly(commandArgs))
1062
+ return "read";
1063
+ if (name === "iptables" && commandArgs.every((arg) => /^(-L|-S|-n|-v|--list|--list-rules)$/.test(arg)))
1064
+ return "read";
1065
+ if (name === "nft" && first && /^(list|monitor)$/.test(first))
1066
+ return "read";
1067
+ if (name === "docker" && first && /^(ps|images)$/.test(first))
1068
+ return "read";
1069
+ return "unknown";
1070
+ }
1071
+ function unwrapArgvCommandTokens(tokens) {
1072
+ let remaining = [...tokens];
1073
+ while (remaining.length > 0) {
1074
+ const head = remaining[0];
1075
+ if (head === "sudo") {
1076
+ remaining = remaining.slice(1);
1077
+ remaining = consumeWrapperOptions(remaining, new Set(["-u", "--user", "-g", "--group", "-h", "--host", "-p", "--prompt", "-C", "--close-from", "-D", "--chdir"]));
1078
+ continue;
1079
+ }
1080
+ if (head === "env") {
1081
+ remaining = remaining.slice(1);
1082
+ remaining = consumeWrapperOptions(remaining, new Set(["-u", "--unset", "-C", "--chdir", "-S", "--split-string"]));
1083
+ while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(remaining[0] ?? ""))
1084
+ remaining = remaining.slice(1);
1085
+ continue;
1086
+ }
1087
+ if (head === "command") {
1088
+ remaining = remaining.slice(1);
1089
+ continue;
1090
+ }
1091
+ break;
1092
+ }
1093
+ return remaining;
1094
+ }
1095
+ function consumeWrapperOptions(tokens, optionsWithValues) {
1096
+ let remaining = [...tokens];
1097
+ while (remaining[0]?.startsWith("-")) {
1098
+ const option = remaining[0];
1099
+ remaining = remaining.slice(1);
1100
+ const optionName = option.includes("=") ? option.slice(0, option.indexOf("=")) : option;
1101
+ if (optionsWithValues.has(optionName) && !option.includes("=") && remaining.length > 0) {
1102
+ remaining = remaining.slice(1);
1103
+ }
1104
+ }
1105
+ return remaining;
1106
+ }
1107
+ function argvHasInterpreterExecution(tokens) {
1108
+ const interpreters = new Set(["sh", "bash", "ash", "dash", "python", "python3", "perl", "ruby", "node"]);
1109
+ for (let index = 0; index < tokens.length; index++) {
1110
+ if (!interpreters.has(tokens[index]))
1111
+ continue;
1112
+ for (const option of tokens.slice(index + 1, index + 4)) {
1113
+ if (!option.startsWith("-"))
1114
+ break;
1115
+ if (option === "-c" || option === "-lc" || /^-[A-Za-z]*c[A-Za-z]*$/.test(option))
1116
+ return true;
1117
+ }
1118
+ }
1119
+ return false;
1120
+ }
1121
+ function argvActionAfter(tokens, command) {
1122
+ const index = tokens.indexOf(command);
1123
+ if (index < 0)
1124
+ return undefined;
1125
+ return tokens.slice(index + 1).find((token) => !token.startsWith("-"));
1126
+ }
1127
+ function ufwArgsAreReadOnly(args) {
1128
+ const action = args.find((arg) => !arg.startsWith("-"));
1129
+ return action === "status" || action === "show";
1130
+ }
1131
+ function commandName(value) {
1132
+ return value.split("/").filter(Boolean).pop()?.toLowerCase() ?? value.toLowerCase();
1133
+ }
1134
+ function isExecResult(value) {
1135
+ return Boolean(value && typeof value === "object" && "commandId" in value);
1136
+ }
1137
+ function shellQuote(value) {
1138
+ return `'${value.replace(/'/g, `'\\''`)}'`;
1139
+ }
1140
+ function hashOptionalInput(value) {
1141
+ if (value === undefined)
1142
+ return "";
1143
+ return createHash("sha256").update(value).digest("hex");
1144
+ }
1145
+ function hasSessionId(value) {
1146
+ return Boolean(value && typeof value === "object" && "sessionId" in value);
1147
+ }
1148
+ function policyReason(value) {
1149
+ if (!value || typeof value !== "object")
1150
+ return undefined;
1151
+ const reasons = value.reasons;
1152
+ if (Array.isArray(reasons) && typeof reasons[0] === "string")
1153
+ return reasons[0];
1154
+ const reason = value.reason;
1155
+ return typeof reason === "string" ? reason : undefined;
1156
+ }
1157
+ function isTransientProbeFailure(probe) {
1158
+ const diagnostic = probeDiagnostic(probe);
1159
+ return diagnostic === "tcp" || diagnostic === "timeout";
1160
+ }
1161
+ function probeDiagnostic(probe) {
1162
+ const stages = probe.stages;
1163
+ if (!stages || typeof stages !== "object")
1164
+ return "";
1165
+ const ssh = stages.ssh;
1166
+ if (!ssh || typeof ssh !== "object")
1167
+ return "";
1168
+ const diagnostic = ssh.diagnostic;
1169
+ return typeof diagnostic === "string" ? diagnostic : "";
1170
+ }
1171
+ function hostTargetSummary(host) {
1172
+ return `${host.user ? `${host.user}@` : ""}${host.hostname}${host.port ? `:${host.port}` : ""}`;
1173
+ }
1174
+ function sleep(ms) {
1175
+ if (ms <= 0)
1176
+ return Promise.resolve();
1177
+ return new Promise((resolve) => setTimeout(resolve, ms));
1178
+ }
1179
+ function hostConnectProbeTimeoutMs(totalTimeoutMs, retryCount, retryDelayMs) {
1180
+ if (retryCount <= 0)
1181
+ return totalTimeoutMs ?? 10_000;
1182
+ const totalBudgetMs = totalTimeoutMs ?? 30_000;
1183
+ const delayBudgetMs = Math.max(0, retryDelayMs) * retryCount;
1184
+ const attemptCount = retryCount + 1;
1185
+ return Math.max(1000, Math.floor((totalBudgetMs - delayBudgetMs) / attemptCount));
1186
+ }
1187
+ function shouldRetrySshExec(result) {
1188
+ if (result.exitCode !== 255)
1189
+ return false;
1190
+ const diagnostic = classifySshFailure(result.stderr, result.timedOut);
1191
+ return diagnostic === "timeout" || diagnostic === "tcp" || !result.stderr.trim();
1192
+ }
1193
+ function clearControlSocket(host, controlDir) {
1194
+ try {
1195
+ unlinkSync(controlPathForHost(host, controlDir));
1196
+ }
1197
+ catch {
1198
+ // Missing or already removed sockets are expected during recovery.
1199
+ }
1200
+ }
1201
+ function sshConnectTimeoutSeconds(timeoutMs) {
1202
+ const bufferedMs = Math.max(1000, timeoutMs - 2000);
1203
+ return Math.max(1, Math.ceil(bufferedMs / 1000));
1204
+ }
1205
+ function debugArgv(argv, env) {
1206
+ return env.SMOOTH_SSH_MCP_INCLUDE_ARGV === "1" ? { argv: sanitizeArgv(argv) } : {};
1207
+ }
1208
+ function sanitizeArgv(argv) {
1209
+ return argv.map((arg, index) => {
1210
+ const previous = argv[index - 1];
1211
+ if (previous === "-i")
1212
+ return "[REDACTED_IDENTITY_FILE]";
1213
+ if (arg.startsWith("ControlPath="))
1214
+ return "ControlPath=[REDACTED]";
1215
+ if (arg.includes("ControlPath="))
1216
+ return arg.replace(/ControlPath=[^\s]+/g, "ControlPath=[REDACTED]");
1217
+ return redactAndTruncate(arg, 2048).text;
1218
+ });
1219
+ }
1220
+ function classifySshFailure(stderr, timedOut) {
1221
+ if (/Could not resolve hostname/i.test(stderr))
1222
+ return "dns";
1223
+ if (/Connection timed out|No route to host|Connection refused|Connection reset|kex_exchange_identification/i.test(stderr))
1224
+ return "tcp";
1225
+ if (/REMOTE HOST IDENTIFICATION HAS CHANGED|Host key verification failed|authenticity of host|can't be established|fingerprint/i.test(stderr))
1226
+ return "host-key";
1227
+ if (/Permission denied|publickey|authentication/i.test(stderr))
1228
+ return "auth";
1229
+ if (/Pseudo-terminal|shell request failed/i.test(stderr))
1230
+ return "shell";
1231
+ if (timedOut)
1232
+ return "timeout";
1233
+ return stderr.trim() ? "ssh-error" : "none";
1234
+ }
1235
+ function classifySshSuccess(stderr) {
1236
+ if (/Permanently added .+ to the list of known hosts/i.test(stderr))
1237
+ return "host-key-added";
1238
+ return "none";
1239
+ }
1240
+ //# sourceMappingURL=operations.js.map