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.
- package/README.md +1 -1
- package/build/cli.js +221 -0
- package/build/config/sddl.js +453 -0
- package/build/config/windows-acl.js +1 -423
- package/build/index.js +38 -220
- package/build/ssh/channel-signal.js +193 -0
- package/build/ssh/connection-registry.js +4 -3
- package/build/ssh/connection.js +64 -45
- package/build/ssh/session-manager.js +33 -12
- package/build/ssh/session.js +28 -4
- package/build/tools/descriptions.js +1 -1
- package/build/tools/session-tools.js +58 -4
- package/package.json +3 -2
|
@@ -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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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();
|
package/build/ssh/session.js
CHANGED
|
@@ -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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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,
|
|
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
|
-
|
|
79
|
-
|
|
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.
|
|
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/",
|