ssh-mcp 2.3.2 → 2.3.4

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.
@@ -140,22 +140,43 @@ export class SessionManager {
140
140
  const session = this.sessions.get(name);
141
141
  if (!session)
142
142
  throw new Error(`Session "${name}" not found on ${this.deps.profile().name}`);
143
- await session.close();
144
- this.sessions.delete(name);
145
- }
146
- async closeAll() {
147
- for (const name of [...this.sessions.keys()]) {
148
- await this.close(name).catch(() => { });
143
+ try {
144
+ return await session.close();
149
145
  }
150
- }
151
- reapExpired() {
152
- for (const [name, session] of this.sessions) {
153
- if (session.isExpired()) {
154
- session.close().catch(() => { });
146
+ finally {
147
+ // Guarded and in a `finally`, both because closing now awaits a kill ladder rather
148
+ // than returning on the next microtask. Unguarded, a reaper closing this session
149
+ // while the caller re-opened the same name would evict the *new* session — the
150
+ // hazard `register` already documents. Without the `finally`, a rejection left the
151
+ // entry behind forever, holding a `sessionMaxPerConnection` slot and blocking the
152
+ // name.
153
+ if (this.sessions.get(name) === session)
155
154
  this.sessions.delete(name);
156
- }
157
155
  }
158
156
  }
157
+ async closeAll() {
158
+ // Concurrently. The ladders are independent timers on independent channels, and
159
+ // sequentially this was measured at 10.0s for five commands that ignore INT and TERM —
160
+ // past Docker's 10s default stop grace, so the container was SIGKILLed mid-teardown and
161
+ // the later sessions got no escalation at all. Before the ladder was awaited each
162
+ // session at least received its INT immediately, so serialising made the tail worse
163
+ // than the bug it fixed.
164
+ await Promise.all([...this.sessions.keys()].map((name) => this.close(name).catch(() => { })));
165
+ }
166
+ /**
167
+ * Close every expired session, awaiting the escalation.
168
+ *
169
+ * Awaited, and async for that reason. Fire-and-forget left exactly one hole in the
170
+ * guarantee `waitForChannelClose` exists for: `close()` dispatched INT and returned, the
171
+ * entry was deleted so `sessionCount` hit zero in the same tick, and the reaper's very
172
+ * next call — `reapIdleConnections`, which gates on `sessionCount` — closed the
173
+ * connection and ended the client microseconds later. TERM and KILL were then discarded,
174
+ * so a background command that ignores INT survived its own reaping.
175
+ */
176
+ async reapExpired() {
177
+ const expired = [...this.sessions.entries()].filter(([, s]) => s.isExpired());
178
+ await Promise.all(expired.map(([name]) => this.close(name).catch(() => { })));
179
+ }
159
180
  markAllDisconnected() {
160
181
  for (const session of this.sessions.values()) {
161
182
  session.markDisconnected();
@@ -1,5 +1,6 @@
1
1
  import { randomBytes } from 'crypto';
2
2
  import { tracer } from '../observability/tracer.js';
3
+ import { terminateChannel, waitForChannelClose } from './channel-signal.js';
3
4
  const ANSI_REGEX = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07]*\x07|\r/g;
4
5
  export function stripAnsi(text) {
5
6
  return text.replace(ANSI_REGEX, '');
@@ -250,6 +251,9 @@ export class InteractiveSession extends Session {
250
251
  }
251
252
  catch { /* ignore */ }
252
253
  this._status = 'closed';
254
+ // Nothing remote is signalled here: an interactive session's stop is `^C` written into
255
+ // its live pty during a command, and closing the session just ends the shell.
256
+ return 'closed';
253
257
  }
254
258
  get currentCwd() {
255
259
  return this.cwd;
@@ -341,10 +345,30 @@ export class BackgroundSession extends Session {
341
345
  throw new Error('Background sessions do not support run(). The command was started when the session was opened.');
342
346
  }
343
347
  async close() {
344
- try {
345
- this.stream.close();
346
- }
347
- catch { /* ignore */ }
348
+ // Signalled, not just closed. A background session runs on an exec channel, and
349
+ // closing such a channel was measured not to stop the command (#146) — so
350
+ // `close-session` reported `status: 'closed'` while `tail -f` kept running on the
351
+ // host, which is the same false claim the exec path stopped making.
352
+ const dispatched = terminateChannel(this.stream);
353
+ // Our side is closed either way — the session is being torn down and the caller is not
354
+ // getting it back. What varies is whether the *host* stopped, which the outcome carries.
348
355
  this._status = 'closed';
356
+ if (!dispatched)
357
+ return 'unsignalled';
358
+ // Awaited, because the ladder's later rungs are timers and whatever happens next may
359
+ // tear the transport down: `SSHConnection.close()` ends the client immediately after
360
+ // `closeAll()` returns, and shutdown calls `process.exit`. Returning early meant a
361
+ // command that ignored INT received nothing further and survived — the KILL rung
362
+ // exists precisely for that command. Bounded by the ladder's own length, so a command
363
+ // that dies to INT costs one round trip rather than three seconds.
364
+ //
365
+ // Skipped entirely when nothing was dispatched: no rung can be delivered through a
366
+ // transport that refused the first one, including the `channel.close()` rung, so
367
+ // `'close'` would never arrive and the wait was measured burning its full 3.5s for
368
+ // nothing.
369
+ //
370
+ // The old `stream.close()` released the channel synchronously; this releases it when
371
+ // the ladder finishes, which is why the wait is here rather than left to the caller.
372
+ return (await waitForChannelClose(this.stream)) ? 'closed' : 'stop-unconfirmed';
349
373
  }
350
374
  }
@@ -3,7 +3,7 @@ export const TOOL_DESCRIPTIONS = {
3
3
  'list-connections': 'List all configured SSH profiles and their connection status. Use this to discover available hosts before running commands.',
4
4
  'list-sessions': 'List active sessions for a given SSH profile.',
5
5
  'open-session': 'Open a named session on a remote host. Use type="interactive" for stateful shell (CWD/env persists between commands) or type="background" for long-running processes.',
6
- 'close-session': 'Close a named session, releasing its resources.',
6
+ 'close-session': 'Close a named session. A background session\'s command is signalled on the host (INT, then TERM, then KILL) before its channel is dropped; an interactive session\'s shell is ended. The response says so if the command could not be signalled or had not stopped in time.',
7
7
  'read-session-output': 'Read recent output from a background session (e.g., tail -f logs).',
8
8
  'read-command': 'Execute a READ-ONLY command from an allowlist (ls, cat, grep, find, stat, df, etc.). This tool does NOT modify the system. Prefer this tool for all read operations.',
9
9
  'run-command': 'Execute an arbitrary shell command on the remote server. May modify the system. Destructive commands require user approval.',
@@ -2,11 +2,40 @@ import { z } from 'zod';
2
2
  import { sanitizeSessionName } from '../guard/sanitizer.js';
3
3
  import { redactText } from '../guard/redactor.js';
4
4
  import { BackgroundSession } from '../ssh/session.js';
5
+ import { COULD_NOT_SIGNAL } from '../ssh/channel-signal.js';
5
6
  import { TOOL_DESCRIPTIONS as D } from './descriptions.js';
6
7
  import { syntheticSuccess, textResult } from './results.js';
7
8
  /** Connection and session discovery/lifecycle tools. */
9
+ /**
10
+ * The evaluation recorded for a session release.
11
+ *
12
+ * Releasing a session is audited but never refused, which is why this is a constant rather
13
+ * than an engine call. The first version of this routed the close through `runAudited`, and
14
+ * that made the stop *refusable*: `session:close <name>` classifies as `safe`, so a
15
+ * `readOnly` profile — which can open a background session, because a `tail -f` classifies
16
+ * `read-only` — was denied permission to close it, and had no other way to stop the
17
+ * command for an hour. `ask-all` prompted on every close, and an exhausted
18
+ * `commandQuotaPerDay` wedged the profile entirely.
19
+ *
20
+ * A control whose refusal mode is "the thing you asked me to stop keeps running" is worse
21
+ * than the unaudited stop it replaced. The record was the part worth having; the veto was
22
+ * not. `ruleId` says so, so the log distinguishes this from an engine `allow`.
23
+ */
24
+ const RELEASE = {
25
+ decision: 'allow',
26
+ commandClass: 'safe',
27
+ binary: 'session:close',
28
+ ruleId: 'session-release',
29
+ reason: 'A session release is audited but not policy-gated; see SECURITY.md.',
30
+ };
31
+ /** What to append to the caller's confirmation for each outcome. */
32
+ const CLOSE_NOTES = {
33
+ closed: '',
34
+ unsignalled: COULD_NOT_SIGNAL,
35
+ 'stop-unconfirmed': ' The command was signalled but the channel had not closed in time, so it may still be running on the host.',
36
+ };
8
37
  export function registerSessionTools({ server, registry }, pipeline) {
9
- const { runAudited, resolveConn } = pipeline;
38
+ const { runAudited, resolveConn, auditResult, auditFailure, makeCtx, defaultProfileName } = pipeline;
10
39
  // ─── list-connections ──────────────────────────────────────────────────
11
40
  server.tool('list-connections', D["list-connections"], {}, { readOnlyHint: true }, async () => {
12
41
  const connections = registry.listConnections();
@@ -73,10 +102,35 @@ export function registerSessionTools({ server, registry }, pipeline) {
73
102
  server.tool('close-session', D["close-session"], {
74
103
  name: z.string().describe('Session name to close'),
75
104
  profile: z.string().optional().describe('Profile name'),
76
- }, { destructiveHint: true }, async ({ name, profile }) => {
105
+ }, { destructiveHint: true }, async ({ name, profile }, extra) => {
106
+ const cleanName = sanitizeSessionName(name);
107
+ const profileName = defaultProfileName(profile);
108
+ const ctx = makeCtx(extra, profileName, cleanName);
77
109
  const conn = await resolveConn(profile);
78
- await conn.closeSession(name);
79
- return textResult(`Session "${name}" closed.`);
110
+ // Recorded with the session's kind, so a remote SIGKILL is greppable in the log and
111
+ // distinguishable from ending a local shell — `open-session` encodes its type the
112
+ // same way.
113
+ // Through `toInfo()` rather than an `instanceof` check: the type is public API, and a
114
+ // tool handler reaching for a constructor identity is the layer leak this repo's own
115
+ // rules call out.
116
+ const kind = conn.getSession(cleanName)?.toInfo().type ?? 'unknown';
117
+ const command = `session:close ${kind} ${cleanName}`;
118
+ const startedAt = Date.now();
119
+ try {
120
+ const outcome = await conn.closeSession(cleanName);
121
+ await auditResult(ctx, profileName, command, RELEASE, {
122
+ stdout: '',
123
+ stderr: '',
124
+ exitCode: outcome === 'closed' ? 0 : 1,
125
+ durationMs: Date.now() - startedAt,
126
+ profile: profileName,
127
+ });
128
+ return textResult(`Session "${cleanName}" closed.${CLOSE_NOTES[outcome]}`);
129
+ }
130
+ catch (err) {
131
+ await auditFailure(ctx, profileName, { command }, 'safe', err);
132
+ throw err;
133
+ }
80
134
  });
81
135
  // ─── read-session-output ───────────────────────────────────────────────
82
136
  server.tool('read-session-output', D["read-session-output"], {
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "ssh-mcp",
3
3
  "license": "MIT",
4
- "version": "2.3.2",
4
+ "version": "2.3.4",
5
5
  "description": "MCP server exposing policy-gated, audited SSH access for Linux and Windows hosts via the Model Context Protocol.",
6
+ "mcpName": "io.github.tufantunc/ssh-mcp",
6
7
  "type": "module",
7
8
  "bin": {
8
9
  "ssh-mcp": "build/index.js"
@@ -13,7 +14,7 @@
13
14
  "typecheck": "tsc --noEmit -p tsconfig.test.json",
14
15
  "inspect": "npx @modelcontextprotocol/inspector node build/index.js",
15
16
  "changeset": "changeset",
16
- "version": "changeset version",
17
+ "version": "changeset version && node scripts/sync-server-json.mjs",
17
18
  "test": "cross-env SSH_MCP_DISABLE_MAIN=1 vitest --run",
18
19
  "test:unit": "cross-env SSH_MCP_DISABLE_MAIN=1 vitest --run test/unit/ test/property/",
19
20
  "test:integration": "cross-env SSH_MCP_DISABLE_MAIN=1 vitest --run test/integration/",