ssh-mcp 2.3.2 → 2.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -138,7 +138,7 @@ claude mcp add --transport stdio ssh-mcp -- ssh-mcp
138
138
  | `list-connections` | Discover available hosts and connection status | ✅ | — |
139
139
  | `list-sessions` | List active sessions per host | ✅ | — |
140
140
  | `open-session` | Create a named interactive (stateful) or background session | — | — |
141
- | `close-session` | Close a session, releasing resources | — | ✅ |
141
+ | `close-session` | Close a session. A background session's command is signalled (INT/TERM/KILL) before its channel is dropped | — | ✅ |
142
142
  | `read-session-output` | Read output from background sessions (e.g., `tail -f`) | ✅ | — |
143
143
  | `read-command` | Execute allowlisted read-only commands (`ls`, `cat`, `grep`, ...) | ✅ | — |
144
144
  | `run-command` | Execute arbitrary commands (destructive ones need approval) | — | — |
package/build/index.js CHANGED
@@ -268,14 +268,29 @@ async function main() {
268
268
  console.error('SSH MCP Server v2 running on stdio');
269
269
  }
270
270
  const reaperInterval = setInterval(() => {
271
- for (const info of registry.listConnections()) {
272
- const conn = registry.get(info.profile);
273
- if (conn) {
274
- conn.reapExpiredSessions();
275
- }
276
- }
277
- registry.reapIdleConnections();
271
+ // Session reaping is awaited before connection reaping, and that order is load-bearing:
272
+ // closing a background session now signals its command, and `reapIdleConnections` gates
273
+ // on `sessionCount`, which drops to zero as soon as a session is closed. Fired without
274
+ // awaiting, the connection was torn down microseconds after the first signal and the
275
+ // TERM and KILL rungs were discarded — so a command that ignores INT survived its own
276
+ // reaping.
277
+ void (async () => {
278
+ const conns = registry.listConnections()
279
+ .map((info) => registry.get(info.profile))
280
+ .filter((c) => Boolean(c));
281
+ await Promise.all(conns.map((conn) => conn.reapExpiredSessions().catch(() => { })));
282
+ registry.reapIdleConnections();
283
+ })();
278
284
  }, 60_000);
285
+ /**
286
+ * How long teardown gets before the process exits regardless.
287
+ *
288
+ * One kill ladder (3s) plus slack. Sessions and connections close concurrently, so this
289
+ * is the whole budget rather than a per-session one. Docker's default stop grace is 10s
290
+ * and Kubernetes' is 30s, so this stays comfortably inside both — the point is that we
291
+ * choose the moment we give up rather than being SIGKILLed in the middle of it.
292
+ */
293
+ const SHUTDOWN_BUDGET_MS = 5000;
279
294
  let isShuttingDown = false;
280
295
  const cleanup = async () => {
281
296
  if (isShuttingDown)
@@ -283,14 +298,26 @@ async function main() {
283
298
  isShuttingDown = true;
284
299
  console.error('Shutting down SSH MCP Server...');
285
300
  clearInterval(reaperInterval);
301
+ // Audit first. An earlier version of this comment claimed session teardown wrote audit
302
+ // records that the old order dropped — it does not: nothing under src/ssh/ touches the
303
+ // audit store, only the `close-session` tool does, and that record is written and
304
+ // awaited during the tool call. So the reorder rescued nothing and put the final flush
305
+ // behind a teardown that now waits for kill ladders. Flushing first cannot lose a record
306
+ // that teardown does not write, and it survives a SIGKILL at the end of the grace period.
286
307
  try {
287
308
  await audit.close();
288
309
  }
289
310
  catch (e) {
290
311
  console.error('audit.close failed:', e);
291
312
  }
313
+ // Bounded, so `process.exit(0)` is reached deliberately rather than by the container
314
+ // runtime's SIGKILL. Teardown is concurrent now, so the honest ceiling is one kill
315
+ // ladder plus slack rather than one per session.
292
316
  try {
293
- await registry.closeAll();
317
+ await Promise.race([
318
+ registry.closeAll(),
319
+ new Promise((resolve) => setTimeout(resolve, SHUTDOWN_BUDGET_MS).unref()),
320
+ ]);
294
321
  }
295
322
  catch (e) {
296
323
  console.error('closeAll failed:', e);
@@ -0,0 +1,193 @@
1
+ /** Outgoing states in which the far end still has a channel to receive a request. */
2
+ const OPEN_STATES = new Set(['open', 'eof']);
3
+ /**
4
+ * Whether ssh2's own `Channel.signal()` would put this request on the wire.
5
+ *
6
+ * A deliberate copy of lib/Channel.js:241-243 in ssh2 1.17, and the single place to
7
+ * update when that changes. Copying it is what lets us tell "ssh2 will handle this"
8
+ * from "ssh2 will silently discard it" — the distinction the whole module exists for.
9
+ */
10
+ function ssh2WillSend(ch) {
11
+ return ch.type === 'session' && ch.writable === true && ch.outgoing?.state === 'open';
12
+ }
13
+ /**
14
+ * Whether the transport can still carry a packet.
15
+ *
16
+ * ssh2 hands every packet to `onWrite`, which is `if (isWritable(sock)) sock.write(data)`
17
+ * (lib/client.js:303) — an unwritable socket means the packet is dropped with no error
18
+ * and no return value. Measured against 1.17.0: immediately after `client.end()`,
19
+ * `sock.writable` is false while `outgoing.state` is still `'eof'`, so the state guard
20
+ * below passes and the signal call returns without throwing. Reporting that as delivery
21
+ * is #146 one layer down, and it is reachable whenever a connection is closed under a
22
+ * running command — `SSHConnection.close()`, or the idle reaper, which does not consult
23
+ * `activeChannels`.
24
+ *
25
+ * All three of `isWritable`'s conjuncts, not just the first. The first version of this
26
+ * checked `sock.writable` alone, and ssh2 added the other two for a reason
27
+ * (nodejs/node#36029: `writable` stays true after the stream has ended). Measured:
28
+ *
29
+ * allowHalfOpen=false, peer FIN -> writable=false, isWritable()=false (agree)
30
+ * allowHalfOpen=true, peer FIN -> writable=true, isWritable()=false (disagree)
31
+ *
32
+ * and a ProxyJump connection's transport is exactly the second case — `connectConfig.sock`
33
+ * is a `forwardOut` channel, and ssh2's channels default to `allowHalfOpen: true`
34
+ * (lib/Channel.js:90). So the one-conjunct version was blind on the path that has its own
35
+ * integration test. Mirroring the whole predicate is also what `ssh2WillSend` does, and
36
+ * the asymmetry between the two was the actual defect.
37
+ */
38
+ function onTheWire(ch) {
39
+ const sock = ch._client?._sock;
40
+ // Fail closed on a shape we cannot read: every other unknown in this module is reported
41
+ // as "not dispatched", and a guard whose job is to refuse when unsure must not be the
42
+ // one place that assumes the best.
43
+ if (sock === undefined || typeof sock.writable !== 'boolean')
44
+ return false;
45
+ return sock.writable && sock._readableState?.ended === false;
46
+ }
47
+ /**
48
+ * Send `name` to the process behind `channel`.
49
+ *
50
+ * Returns whether the request was **dispatched to the wire** — not whether the command
51
+ * stopped. Nothing in SSH acknowledges a signal request, the delivery semantics are the
52
+ * server's choice rather than the protocol's, and a target may refuse it outright, so
53
+ * "the process is gone" is not a fact this function can produce. What it can produce
54
+ * honestly is "I could not even ask", which is what the caller warns about, because a
55
+ * signal that goes nowhere while reporting success is the defect this module exists to
56
+ * remove.
57
+ */
58
+ export function signalChannel(channel, name) {
59
+ const ch = channel;
60
+ const state = ch.outgoing?.state;
61
+ // Only session channels carry a process. ssh2 gates on this too, and a
62
+ // forwarded-tcpip channel has nothing to signal.
63
+ if (ch.type !== undefined && ch.type !== 'session')
64
+ return false;
65
+ // Already closing or closed: the request would have no channel to arrive on, and
66
+ // ssh2 may have handed the id to a new channel.
67
+ if (state !== undefined && !OPEN_STATES.has(state))
68
+ return false;
69
+ // Nothing can be dispatched through a socket ssh2 will not write to, and it will not
70
+ // tell us — so this has to be asked before either path claims success.
71
+ if (!onTheWire(ch))
72
+ return false;
73
+ // The supported path, taken whenever ssh2 will actually write the request.
74
+ if (ssh2WillSend(ch)) {
75
+ try {
76
+ channel.signal(name);
77
+ return true;
78
+ }
79
+ catch {
80
+ return false;
81
+ }
82
+ }
83
+ const id = ch.outgoing?.id;
84
+ const protocol = ch._client?._protocol;
85
+ if (typeof id === 'number' && typeof protocol?.signal === 'function') {
86
+ try {
87
+ protocol.signal(id, name);
88
+ return true;
89
+ }
90
+ catch {
91
+ return false;
92
+ }
93
+ }
94
+ // A shape we do not recognise. Ask ssh2 and report failure anyway: we cannot tell
95
+ // whether it sent anything, and claiming delivery we cannot verify is what made
96
+ // this bug invisible for so long. An unnecessary "may still be running" warning is
97
+ // the better way to be wrong.
98
+ try {
99
+ channel.signal(name);
100
+ }
101
+ catch { /* nothing left to try */ }
102
+ return false;
103
+ }
104
+ /**
105
+ * Appended when a stop request could not even be dispatched, so nothing was asked of the
106
+ * host at all.
107
+ *
108
+ * Deliberately narrower than "the command is still running", which is not knowable from
109
+ * here: nothing acknowledges a signal request. Its *absence* therefore means "we asked",
110
+ * not "it died".
111
+ *
112
+ * It lives beside the function that decides it because two callers now need the same
113
+ * sentence — a timed-out or cancelled `exec`, and `close-session` on a background
114
+ * session. One contract, one place.
115
+ */
116
+ export const COULD_NOT_SIGNAL = ' The remote command could not be signalled, so it may still be running on the host.';
117
+ /** How long each signal is given to work before the next one is tried. */
118
+ const ESCALATION_MS = 1000;
119
+ /**
120
+ * Escalate until the command is gone: INT, then TERM, then KILL, then drop the
121
+ * channel.
122
+ *
123
+ * KILL is last because it denies the command any chance to clean up; it exists
124
+ * because the rung below it used to be `close()`, and closing a channel was measured
125
+ * not to stop anything. A command that ignores INT and TERM used to run forever.
126
+ *
127
+ * Returns whether the *first* signal was dispatched — known synchronously, so the
128
+ * caller can say "I could not even ask" in the same breath as the timeout it is already
129
+ * reporting. It is not a claim that the command stopped, and callers must not read it
130
+ * as one: the later rungs have not run yet, and no rung is acknowledged by anything.
131
+ */
132
+ export function terminateChannel(channel) {
133
+ const dispatched = signalChannel(channel, 'INT');
134
+ const pending = [];
135
+ const later = (fn, ms) => {
136
+ // Unreferenced: this ladder outlives the promise it settled by up to three
137
+ // seconds, and a referenced timer would add that to the exit of any process
138
+ // whose last act was a timed-out command.
139
+ pending.push(setTimeout(fn, ms).unref());
140
+ };
141
+ const stop = () => { for (const t of pending)
142
+ clearTimeout(t); pending.length = 0; };
143
+ // Nothing more to send once the channel is gone, and the id may be reused.
144
+ channel.once('close', stop);
145
+ later(() => { signalChannel(channel, 'TERM'); }, ESCALATION_MS);
146
+ later(() => { signalChannel(channel, 'KILL'); }, ESCALATION_MS * 2);
147
+ later(() => { try {
148
+ channel.close();
149
+ }
150
+ catch { /* already gone */ } }, ESCALATION_MS * 3);
151
+ return dispatched;
152
+ }
153
+ /**
154
+ * Wait for a channel to actually close, bounded.
155
+ *
156
+ * The ladder's rungs are timers, so `terminateChannel` returns long before the escalation
157
+ * has finished. Anything that tears the transport down afterwards — `SSHConnection.close()`
158
+ * calling `client.end()`, or the process exiting — cancels the rest of it, and a command
159
+ * that ignored INT then survives with no further escalation possible. Callers that are
160
+ * about to do that must wait here first.
161
+ *
162
+ * Resolves true if the channel closed, false on timeout. The default budget is the ladder
163
+ * plus a little, so a command that only dies to KILL still gets there.
164
+ */
165
+ export function waitForChannelClose(channel, ms = ESCALATION_MS * 3 + 500) {
166
+ // ssh2 emits `'close'` exactly once, and `onCHANNEL_CLOSE` early-returns for a channel
167
+ // whose incoming state is already `'closed'` — so a listener attached after the fact
168
+ // never fires and the caller would burn the whole budget waiting for an event that has
169
+ // already happened. Not reachable through SessionManager today, because the session is
170
+ // removed from the map by that same emit; it is a trap for the next caller.
171
+ const incoming = channel.incoming;
172
+ if (incoming?.state === 'closed' || channel.destroyed)
173
+ return Promise.resolve(true);
174
+ return new Promise((resolve) => {
175
+ let settled = false;
176
+ const onClose = () => done(true);
177
+ const done = (closed) => {
178
+ if (settled)
179
+ return;
180
+ settled = true;
181
+ clearTimeout(timer);
182
+ // Detached on the timeout path too: a timed-out wait would otherwise leave its
183
+ // closure attached to a channel that may outlive it.
184
+ channel.removeListener('close', onClose);
185
+ resolve(closed);
186
+ };
187
+ const timer = setTimeout(() => done(false), ms);
188
+ // Unreferenced so a hung channel cannot hold the process open; the caller is awaiting
189
+ // this, so the loop stays alive for as long as the caller itself does.
190
+ timer.unref();
191
+ channel.once('close', onClose);
192
+ });
193
+ }
@@ -94,9 +94,10 @@ export class ConnectionRegistry {
94
94
  }
95
95
  }
96
96
  async closeAll() {
97
- for (const conn of this.connections.values()) {
98
- await conn.close().catch(() => { });
99
- }
97
+ // Concurrently, for the reason SessionManager.closeAll gives: each connection now waits
98
+ // for its sessions' kill ladders, and serialising multiplied that wait by the number of
99
+ // profiles.
100
+ await Promise.all([...this.connections.values()].map((conn) => conn.close().catch(() => { })));
100
101
  this.connections.clear();
101
102
  }
102
103
  }
@@ -6,6 +6,40 @@ import { tracer } from '../observability/tracer.js';
6
6
  import { redactText } from '../guard/redactor.js';
7
7
  import { shellSingleQuote } from '../guard/sanitizer.js';
8
8
  import { openWithRetry } from './channel-retry.js';
9
+ import { terminateChannel, COULD_NOT_SIGNAL } from './channel-signal.js';
10
+ /**
11
+ * Stop the command behind `channel`, record why on the span, and return the sentence the
12
+ * rejection should carry.
13
+ *
14
+ * Module scope rather than a closure inside `exec`: it captures nothing but the span, and
15
+ * at module scope it is directly testable with a stub span and a fake channel. It exists
16
+ * at all because the three settle paths used to inline this, and two of them computed the
17
+ * "unstopped" value for the message and then dropped it before the span — so
18
+ * `ssh.unstopped` was structurally incapable of being true for a cancelled command, which
19
+ * is the most common way an MCP client stops one.
20
+ */
21
+ export function stopAndDescribe(span, reason, channel) {
22
+ span.setAttribute(reason, true);
23
+ // A null channel means the exec callback has not arrived yet. It does NOT mean nothing is
24
+ // running — ssh2 invokes that callback on CHANNEL_SUCCESS, which OpenSSH sends after
25
+ // forking the command — but there is nothing to warn about either: `onExecChannel` stops
26
+ // such a late channel the moment it arrives. Warning here would put "may still be
27
+ // running" on every timeout that races a slow channel open, and a warning that fires when
28
+ // nothing is wrong stops being read.
29
+ //
30
+ // Recorded as its own attribute rather than as `ssh.unstopped: false`, which would assert
31
+ // a clean stop this path cannot yet know about. The late-arrival handler emits the real
32
+ // answer on its own span.
33
+ if (channel === null) {
34
+ span.setAttribute('ssh.stopDeferred', true);
35
+ return '';
36
+ }
37
+ const dispatched = terminateChannel(channel);
38
+ // Set on every settle path, always, so "stopped cleanly" and "ran an older build" are not
39
+ // the same absent-attribute query.
40
+ span.setAttribute('ssh.unstopped', !dispatched);
41
+ return dispatched ? '' : COULD_NOT_SIGNAL;
42
+ }
9
43
  export class SSHConnection {
10
44
  profile;
11
45
  client = null;
@@ -193,25 +227,9 @@ export class SSHConnection {
193
227
  const timeoutId = setTimeout(() => {
194
228
  if (!resolved) {
195
229
  resolved = true;
196
- if (activeStream) {
197
- try {
198
- activeStream.signal('INT');
199
- }
200
- catch { /* */ }
201
- setTimeout(() => {
202
- try {
203
- activeStream?.signal('TERM');
204
- }
205
- catch { /* */ }
206
- setTimeout(() => { try {
207
- activeStream?.close();
208
- }
209
- catch { /* */ } }, 1000);
210
- }, 1000);
211
- }
212
- span.setAttribute('ssh.timedOut', true);
230
+ const note = stopAndDescribe(span, 'ssh.timedOut', activeStream);
213
231
  span.end();
214
- reject(new Error(`Command timed out after ${timeoutMs}ms`));
232
+ reject(new Error(`Command timed out after ${timeoutMs}ms${note}`));
215
233
  }
216
234
  }, timeoutMs);
217
235
  const execOpts = {};
@@ -240,6 +258,23 @@ export class SSHConnection {
240
258
  }
241
259
  return;
242
260
  }
261
+ // The promise may already have settled — the timeout can fire while
262
+ // `openWithRetry` is still in flight, and ssh2 only invokes this callback once
263
+ // the server has replied CHANNEL_SUCCESS, which OpenSSH sends *after* forking
264
+ // the command. Without this the command ran to completion on the host after the
265
+ // caller had been told it timed out, holding a channel and discarding its
266
+ // output: #146 exactly, in the one path the first fix did not cover.
267
+ if (resolved) {
268
+ // On its own span: the exec span was ended when the promise settled, and an
269
+ // attribute set on an ended span is silently dropped. Without this the one
270
+ // outcome `stopAndDescribe` deferred would be unobservable — the shape this
271
+ // whole change exists to remove.
272
+ const late = tracer.startSpan('ssh.exec.lateStop');
273
+ late.setAttribute('ssh.host', this.profile.host);
274
+ late.setAttribute('ssh.unstopped', !terminateChannel(stream));
275
+ late.end();
276
+ return;
277
+ }
243
278
  activeStream = stream;
244
279
  this.activeChannels++;
245
280
  let stdout = '';
@@ -261,42 +296,26 @@ export class SSHConnection {
261
296
  if (opts.abortSignal.aborted) {
262
297
  resolved = true;
263
298
  clearTimeout(timeoutId);
299
+ // Decremented here rather than on 'close', because the close listener
300
+ // below is never attached on this path. The channel now outlives the
301
+ // count by as long as the kill ladder takes.
264
302
  this.activeChannels--;
265
303
  // client.exec() has already dispatched the command, so rejecting
266
304
  // without signalling left it running to completion on the host and
267
- // held a channel until it finished.
268
- try {
269
- stream.signal('INT');
270
- }
271
- catch { /* */ }
272
- try {
273
- stream.close();
274
- }
275
- catch { /* */ }
305
+ // held a channel until it finished. Closing the channel does not stop
306
+ // it either — only a delivered signal does (#146).
307
+ const note = stopAndDescribe(span, 'ssh.aborted', stream);
276
308
  span.end();
277
- reject(new Error('Command aborted before execution'));
309
+ reject(new Error(`Command aborted before execution${note}`));
278
310
  return;
279
311
  }
280
312
  const onAbort = () => {
281
313
  if (!resolved) {
282
314
  resolved = true;
283
315
  clearTimeout(timeoutId);
284
- try {
285
- stream.signal('INT');
286
- }
287
- catch { /* */ }
288
- setTimeout(() => {
289
- try {
290
- stream.signal('TERM');
291
- }
292
- catch { /* */ }
293
- setTimeout(() => { try {
294
- stream.close();
295
- }
296
- catch { /* */ } }, 1000);
297
- }, 1000);
316
+ const note = stopAndDescribe(span, 'ssh.aborted', stream);
298
317
  span.end();
299
- reject(new Error('Command aborted'));
318
+ reject(new Error(`Command aborted${note}`));
300
319
  }
301
320
  };
302
321
  opts.abortSignal.addEventListener('abort', onAbort, { once: true });
@@ -359,8 +378,8 @@ export class SSHConnection {
359
378
  async closeSession(name) {
360
379
  return this.sessions.close(name);
361
380
  }
362
- reapExpiredSessions() {
363
- this.sessions.reapExpired();
381
+ async reapExpiredSessions() {
382
+ return this.sessions.reapExpired();
364
383
  }
365
384
  async close() {
366
385
  await this.sessions.closeAll();
@@ -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,7 +1,7 @@
1
1
  {
2
2
  "name": "ssh-mcp",
3
3
  "license": "MIT",
4
- "version": "2.3.2",
4
+ "version": "2.3.3",
5
5
  "description": "MCP server exposing policy-gated, audited SSH access for Linux and Windows hosts via the Model Context Protocol.",
6
6
  "type": "module",
7
7
  "bin": {