ssh-mcp 1.5.0 → 2.0.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.
@@ -0,0 +1,350 @@
1
+ import { randomBytes } from 'crypto';
2
+ import { tracer } from '../observability/tracer.js';
3
+ const ANSI_REGEX = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07]*\x07|\r/g;
4
+ export function stripAnsi(text) {
5
+ return text.replace(ANSI_REGEX, '');
6
+ }
7
+ /**
8
+ * Trim leading and trailing newlines by index rather than with /^\n+/ and
9
+ * /\n+$/.
10
+ *
11
+ * The trailing pattern is unanchored at the start, so on output that is mostly
12
+ * newlines but does not end in one the engine retries `\n+` from every offset:
13
+ * quadratic in the length of the output. That output is whatever the remote
14
+ * command printed and the session buffer holds up to 2 MB of it, which measured
15
+ * at roughly 25 minutes of blocked event loop — and the loop is shared by every
16
+ * session and connection this server has open.
17
+ */
18
+ /**
19
+ * A per-command marker separating the command's own output from the trailer
20
+ * carrying `$?` and `$PWD`.
21
+ *
22
+ * Guessing one is enough to forge an exit code or a working directory — a
23
+ * failed command reported as successful, in a server whose whole claim is an
24
+ * auditable record of what ran. Every marker is written to the remote host in
25
+ * the clear, so anything watching that session collects a stream of them, and
26
+ * Math.random() is reconstructible from such a stream.
27
+ *
28
+ * base64url keeps the value safe both inside the single-quoted printf that
29
+ * emits it and inside the RegExp built from it: no quotes, no metacharacters.
30
+ */
31
+ export function generateSessionMarker() {
32
+ return randomBytes(12).toString('base64url');
33
+ }
34
+ export function trimNewlines(text) {
35
+ let start = 0;
36
+ let end = text.length;
37
+ while (start < end && text.charCodeAt(start) === 10)
38
+ start++;
39
+ while (end > start && text.charCodeAt(end - 1) === 10)
40
+ end--;
41
+ return start === 0 && end === text.length ? text : text.slice(start, end);
42
+ }
43
+ export class Session {
44
+ id;
45
+ name;
46
+ profile;
47
+ type;
48
+ createdAt;
49
+ lastActivity;
50
+ ttlMs;
51
+ /** Absolute lifetime cap, measured from createdAt. Undefined = no cap. */
52
+ maxLifetimeMs;
53
+ _status = 'active';
54
+ constructor(id, name, profile, type, ttlMs, maxLifetimeMs) {
55
+ this.id = id;
56
+ this.name = name;
57
+ this.profile = profile;
58
+ this.type = type;
59
+ this.createdAt = new Date();
60
+ this.lastActivity = new Date();
61
+ this.ttlMs = ttlMs;
62
+ this.maxLifetimeMs = maxLifetimeMs;
63
+ }
64
+ get status() {
65
+ return this._status;
66
+ }
67
+ isExpired() {
68
+ // The idle TTL alone never fires for a chatty background process, because
69
+ // every data chunk touches lastActivity. The absolute cap is what actually
70
+ // bounds a `tail -f`-style session.
71
+ if (this.maxLifetimeMs !== undefined && Date.now() - this.createdAt.getTime() > this.maxLifetimeMs) {
72
+ return true;
73
+ }
74
+ return Date.now() - this.lastActivity.getTime() > this.ttlMs;
75
+ }
76
+ touch() {
77
+ this.lastActivity = new Date();
78
+ }
79
+ markDisconnected() {
80
+ if (this._status === 'active') {
81
+ this._status = 'disconnected';
82
+ }
83
+ }
84
+ toInfo() {
85
+ return {
86
+ id: this.id,
87
+ name: this.name,
88
+ profile: this.profile,
89
+ type: this.type,
90
+ status: this._status,
91
+ createdAt: this.createdAt,
92
+ lastActivity: this.lastActivity,
93
+ ttlMs: this.ttlMs,
94
+ };
95
+ }
96
+ }
97
+ export class InteractiveSession extends Session {
98
+ stream;
99
+ cwd = '~';
100
+ static MAX_OUTPUT = 1_048_576;
101
+ constructor(id, name, profile, stream, ttlMs) {
102
+ super(id, name, profile, 'interactive', ttlMs);
103
+ this.stream = stream;
104
+ stream.on('close', () => {
105
+ this._status = 'closed';
106
+ });
107
+ }
108
+ async run(command, timeoutMs = 60_000, abortSignal) {
109
+ if (this._status !== 'active') {
110
+ throw new Error(`Session ${this.name} is not active (status: ${this._status})`);
111
+ }
112
+ const span = tracer.startSpan('ssh.session.run');
113
+ span.setAttribute('session.id', this.id);
114
+ span.setAttribute('session.name', this.name);
115
+ const marker = this.generateMarker();
116
+ // Split literals: the shell assembles these at runtime, so the *echoed*
117
+ // command line never contains the assembled marker — only the command's
118
+ // actual output does. That is what makes the parse below deterministic
119
+ // instead of a guess about which echoed line to skip.
120
+ const beginParts = ['SSHMCP_BEG', `_${marker}`];
121
+ const endParts = ['SSHMCP_END', `_${marker}`];
122
+ const beginMarker = beginParts.join('');
123
+ const endMarker = endParts.join('');
124
+ const startTime = Date.now();
125
+ return new Promise((resolve, reject) => {
126
+ let buffer = '';
127
+ let resolved = false;
128
+ // Assigned below; declared here so every settle path can detach it.
129
+ let detachAbort = () => { };
130
+ const timeoutId = setTimeout(() => {
131
+ if (!resolved) {
132
+ resolved = true;
133
+ this.stream.removeListener('data', dataHandler);
134
+ detachAbort();
135
+ try {
136
+ this.stream.write('\x03');
137
+ }
138
+ catch { /* */ }
139
+ setTimeout(() => { try {
140
+ this.stream.signal('TERM');
141
+ }
142
+ catch { /* */ } }, 500);
143
+ span.end();
144
+ reject(new Error(`Command timed out after ${timeoutMs}ms in session ${this.name}`));
145
+ }
146
+ }, timeoutMs);
147
+ // The trailer carries the exit code and the shell's real CWD, so neither
148
+ // has to be inferred (cwd used to be guessed from the command text, which
149
+ // was wrong for relative paths, `cd -`, symlinks and `cd` with no arg).
150
+ const endRegex = new RegExp(`${endMarker}__(\\d+)__([^\\r\\n]*)`);
151
+ const dataHandler = (data) => {
152
+ const prevLength = buffer.length;
153
+ buffer += data.toString();
154
+ if (buffer.length > InteractiveSession.MAX_OUTPUT * 2) {
155
+ buffer = buffer.slice(-InteractiveSession.MAX_OUTPUT * 2);
156
+ }
157
+ // The marker can only appear at the tail, so search the newly-arrived
158
+ // bytes plus an overlap rather than rescanning the whole buffer on
159
+ // every chunk — that was O(total x buffer) for a chatty command. The
160
+ // cheap indexOf also gates the ANSI strip, which is the expensive part.
161
+ const searchFrom = Math.max(0, Math.min(prevLength, buffer.length) - endMarker.length - 32);
162
+ if (resolved || buffer.indexOf(endMarker, searchFrom) === -1)
163
+ return;
164
+ const cleaned = stripAnsi(buffer);
165
+ const match = cleaned.match(endRegex);
166
+ if (match && !resolved) {
167
+ resolved = true;
168
+ clearTimeout(timeoutId);
169
+ this.stream.removeListener('data', dataHandler);
170
+ detachAbort();
171
+ const exitCode = parseInt(match[1]);
172
+ const reportedCwd = match[2].trim();
173
+ // Output is exactly what lies between the two printed markers.
174
+ // Anything before the begin marker — the echoed command, leftover
175
+ // shell init, a prompt that PS1="" had not suppressed yet — is
176
+ // discarded by construction rather than by counting lines.
177
+ const beginIdx = cleaned.indexOf(beginMarker);
178
+ const afterBegin = beginIdx >= 0
179
+ ? cleaned.slice(cleaned.indexOf('\n', beginIdx) + 1)
180
+ : cleaned;
181
+ const endIdx = afterBegin.indexOf(endMarker);
182
+ const between = endIdx >= 0 ? afterBegin.slice(0, endIdx) : afterBegin;
183
+ const output = trimNewlines(between);
184
+ if (reportedCwd)
185
+ this.cwd = reportedCwd;
186
+ this.touch();
187
+ resolve({
188
+ stdout: output,
189
+ stderr: '',
190
+ exitCode,
191
+ durationMs: Date.now() - startTime,
192
+ cwd: this.cwd,
193
+ sessionId: this.id,
194
+ profile: this.profile,
195
+ });
196
+ span.setAttribute('ssh.exitCode', exitCode);
197
+ span.end();
198
+ }
199
+ };
200
+ if (abortSignal) {
201
+ if (abortSignal.aborted) {
202
+ resolved = true;
203
+ clearTimeout(timeoutId);
204
+ span.end();
205
+ reject(new Error('Command aborted before execution'));
206
+ return;
207
+ }
208
+ const onAbort = () => {
209
+ if (!resolved) {
210
+ resolved = true;
211
+ clearTimeout(timeoutId);
212
+ this.stream.removeListener('data', dataHandler);
213
+ try {
214
+ this.stream.write('\x03');
215
+ }
216
+ catch { /* */ }
217
+ setTimeout(() => { try {
218
+ this.stream.signal('TERM');
219
+ }
220
+ catch { /* */ } }, 1000);
221
+ span.end();
222
+ reject(new Error('Command aborted'));
223
+ }
224
+ };
225
+ abortSignal.addEventListener('abort', onAbort, { once: true });
226
+ // Without this the listener outlived the run, and its closure retained
227
+ // the run's buffer (up to 2MB) for the lifetime of the signal.
228
+ detachAbort = () => abortSignal.removeEventListener('abort', onAbort);
229
+ }
230
+ this.stream.on('data', dataHandler);
231
+ // One line, so the PTY produces exactly one echo and it necessarily
232
+ // precedes the begin marker's output. Written as three lines, the shell
233
+ // echoed each just before running it, interleaving the echoes of the
234
+ // command and the trailer *after* the begin marker — right in the middle
235
+ // of what we treat as output.
236
+ //
237
+ // The markers are printed from split literals, so the echo of this line
238
+ // never contains an assembled marker; only the shell's own output does.
239
+ this.stream.write(`printf '%s%s\\n' '${beginParts[0]}' '${beginParts[1]}'; ` +
240
+ `${command}; ` +
241
+ `printf '%s%s__%s__%s\\n' '${endParts[0]}' '${endParts[1]}' "$?" "$PWD"\n`);
242
+ });
243
+ }
244
+ generateMarker() {
245
+ return generateSessionMarker();
246
+ }
247
+ async close() {
248
+ try {
249
+ this.stream.end();
250
+ }
251
+ catch { /* ignore */ }
252
+ this._status = 'closed';
253
+ }
254
+ get currentCwd() {
255
+ return this.cwd;
256
+ }
257
+ /** Record the shell's starting directory when the profile sets a workdir. */
258
+ setCwd(dir) {
259
+ this.cwd = dir;
260
+ }
261
+ }
262
+ export class BackgroundSession extends Session {
263
+ stream;
264
+ ringBuffer = [];
265
+ ringBytes = 0;
266
+ ringMax = 10_000;
267
+ /** Tail of the last chunk when it did not end on a line boundary. */
268
+ partialLine = '';
269
+ exitCode = null;
270
+ static RING_CHAR_LIMIT = 100_000;
271
+ constructor(id, name, profile, stream, ttlMs, maxLifetimeMs) {
272
+ super(id, name, profile, 'background', ttlMs, maxLifetimeMs);
273
+ this.stream = stream;
274
+ stream.on('data', (data) => {
275
+ // Splitting each chunk independently corrupted the output twice over: a
276
+ // line spanning two chunks became two entries (readOutput joins with
277
+ // '\n', inventing a break that was never in the stream), and a chunk
278
+ // ending on '\n' produced a trailing '' entry, so a blank line was
279
+ // inserted between every chunk. Carry the incomplete tail instead.
280
+ const text = this.partialLine + data.toString();
281
+ const lines = text.split('\n');
282
+ this.partialLine = lines.pop() ?? '';
283
+ for (const line of lines) {
284
+ this.ringBuffer.push(line);
285
+ this.ringBytes += line.length + 1;
286
+ }
287
+ this.trimRingBuffer();
288
+ this.touch();
289
+ });
290
+ stream.on('close', (code) => {
291
+ // Flush a final line that never got its newline.
292
+ if (this.partialLine) {
293
+ this.ringBuffer.push(this.partialLine);
294
+ this.ringBytes += this.partialLine.length + 1;
295
+ this.partialLine = '';
296
+ this.trimRingBuffer();
297
+ }
298
+ this.exitCode = code;
299
+ this._status = this.isExpired() ? 'expired' : 'closed';
300
+ });
301
+ }
302
+ /**
303
+ * Evict from the head in one splice. Looping `shift()` moved every remaining
304
+ * element per evicted line, so a chatty process paid O(lines x buffer) on the
305
+ * shared event loop once the buffer was full.
306
+ */
307
+ trimRingBuffer() {
308
+ let dropCount = 0;
309
+ let freed = 0;
310
+ while (this.ringBytes - freed > BackgroundSession.RING_CHAR_LIMIT &&
311
+ dropCount < this.ringBuffer.length) {
312
+ freed += this.ringBuffer[dropCount].length + 1;
313
+ dropCount++;
314
+ }
315
+ const remaining = this.ringBuffer.length - dropCount;
316
+ if (remaining > this.ringMax) {
317
+ const extra = remaining - this.ringMax;
318
+ for (let i = 0; i < extra; i++) {
319
+ freed += this.ringBuffer[dropCount + i].length + 1;
320
+ }
321
+ dropCount += extra;
322
+ }
323
+ if (dropCount > 0) {
324
+ this.ringBuffer.splice(0, dropCount);
325
+ this.ringBytes -= freed;
326
+ }
327
+ }
328
+ readOutput(lines = 50) {
329
+ // Include the in-flight partial line so output that has not yet ended with
330
+ // a newline is still visible to a caller polling the session.
331
+ const all = this.partialLine ? [...this.ringBuffer, this.partialLine] : this.ringBuffer;
332
+ return all.slice(-lines).join('\n');
333
+ }
334
+ isRunning() {
335
+ return this._status === 'active';
336
+ }
337
+ getExitCode() {
338
+ return this.exitCode;
339
+ }
340
+ async run(_command) {
341
+ throw new Error('Background sessions do not support run(). The command was started when the session was opened.');
342
+ }
343
+ async close() {
344
+ try {
345
+ this.stream.close();
346
+ }
347
+ catch { /* ignore */ }
348
+ this._status = 'closed';
349
+ }
350
+ }
@@ -0,0 +1,130 @@
1
+ import { openWithRetry } from './channel-retry.js';
2
+ export class SftpClient {
3
+ conn;
4
+ constructor(conn) {
5
+ this.conn = conn;
6
+ }
7
+ async withSftp(fn) {
8
+ // ensureConnected lives inside the retry: Dropbear drops the whole
9
+ // connection under SFTP channel churn, so an attempt can fail because the
10
+ // link died rather than because the channel was refused. Re-establishing
11
+ // before each attempt covers both.
12
+ const sftp = await openWithRetry(async () => {
13
+ await this.conn.ensureConnected();
14
+ const client = this.conn.getClient();
15
+ return new Promise((resolve, reject) => {
16
+ client.sftp((err, handle) => {
17
+ if (err) {
18
+ reject(new Error(`SFTP error: ${err.message}`));
19
+ return;
20
+ }
21
+ resolve(handle);
22
+ });
23
+ });
24
+ });
25
+ try {
26
+ return await fn(sftp);
27
+ }
28
+ finally {
29
+ // Every client.sftp() opens a new subsystem channel. Without end() they
30
+ // accumulate for the life of the connection until the server's MaxSessions
31
+ // limit (OpenSSH default: 10) is hit, after which no channel — SFTP, exec
32
+ // or shell — can be opened on this connection any more.
33
+ try {
34
+ sftp.end();
35
+ }
36
+ catch { /* already torn down */ }
37
+ }
38
+ }
39
+ async upload(opts) {
40
+ return this.withSftp(async (sftp) => {
41
+ return new Promise((resolve, reject) => {
42
+ const stream = sftp.createWriteStream(opts.remotePath, { mode: opts.mode || 0o644 });
43
+ stream.on('error', reject);
44
+ stream.on('close', resolve);
45
+ stream.end(Buffer.isBuffer(opts.content) ? opts.content : Buffer.from(opts.content));
46
+ });
47
+ });
48
+ }
49
+ /**
50
+ * Download a remote file, refusing anything over the cap.
51
+ *
52
+ * exec output has always been capped, but SFTP download was not: the whole
53
+ * file was buffered, concatenated, decoded to a UTF-16 string and entropy
54
+ * scanned. A large remote file therefore turned one tool call into multi-GB
55
+ * RSS and a multi-second event-loop stall for the entire server.
56
+ */
57
+ async download(opts) {
58
+ const maxBytes = opts.maxBytes ?? this.conn.profile.maxOutputBytes;
59
+ return this.withSftp(async (sftp) => {
60
+ // Refuse before transferring anything when the size is known up front.
61
+ const size = await new Promise((resolve) => {
62
+ sftp.stat(opts.remotePath, (err, stats) => resolve(err ? undefined : stats?.size));
63
+ });
64
+ if (size !== undefined && size > maxBytes) {
65
+ throw new Error(`Refusing to download ${opts.remotePath}: ${size} bytes exceeds the ${maxBytes} byte limit ` +
66
+ `(commandMaxOutputBytes). Narrow the file or raise the limit for this profile.`);
67
+ }
68
+ return new Promise((resolve, reject) => {
69
+ const chunks = [];
70
+ let received = 0;
71
+ const stream = sftp.createReadStream(opts.remotePath);
72
+ stream.on('data', (chunk) => {
73
+ received += chunk.length;
74
+ // stat can be stale or unavailable (growing file, no permission), so
75
+ // enforce the cap on the stream too and stop reading immediately.
76
+ if (received > maxBytes) {
77
+ stream.destroy();
78
+ reject(new Error(`Refusing to download ${opts.remotePath}: exceeded the ${maxBytes} byte limit while transferring.`));
79
+ return;
80
+ }
81
+ chunks.push(chunk);
82
+ });
83
+ stream.on('error', reject);
84
+ stream.on('close', () => resolve(Buffer.concat(chunks)));
85
+ });
86
+ });
87
+ }
88
+ async list(remotePath) {
89
+ return this.withSftp(async (sftp) => {
90
+ return new Promise((resolve, reject) => {
91
+ sftp.readdir(remotePath, (err, list) => {
92
+ if (err) {
93
+ reject(new Error(`SFTP list error: ${err.message}`));
94
+ return;
95
+ }
96
+ resolve(list.map((entry) => ({
97
+ path: `${remotePath}/${entry.filename}`,
98
+ size: entry.attrs.size,
99
+ mode: entry.attrs.mode,
100
+ isDirectory: (entry.attrs.mode & 0o170000) === 0o040000,
101
+ isFile: (entry.attrs.mode & 0o170000) === 0o100000,
102
+ mtime: new Date(entry.attrs.mtime * 1000),
103
+ atime: new Date(entry.attrs.atime * 1000),
104
+ })));
105
+ });
106
+ });
107
+ });
108
+ }
109
+ async stat(remotePath) {
110
+ return this.withSftp(async (sftp) => {
111
+ return new Promise((resolve, reject) => {
112
+ sftp.stat(remotePath, (err, stats) => {
113
+ if (err) {
114
+ reject(new Error(`SFTP stat error: ${err.message}`));
115
+ return;
116
+ }
117
+ resolve({
118
+ path: remotePath,
119
+ size: stats.size,
120
+ mode: stats.mode,
121
+ isDirectory: (stats.mode & 0o170000) === 0o040000,
122
+ isFile: (stats.mode & 0o170000) === 0o100000,
123
+ mtime: new Date(stats.mtime * 1000),
124
+ atime: new Date(stats.atime * 1000),
125
+ });
126
+ });
127
+ });
128
+ });
129
+ }
130
+ }
@@ -0,0 +1,82 @@
1
+ import { z } from 'zod';
2
+ import { shellSingleQuote } from '../guard/sanitizer.js';
3
+ import { TOOL_DESCRIPTIONS as D } from './descriptions.js';
4
+ import { commandOutput, textResult } from './results.js';
5
+ import { PolicyRefusedError } from './pipeline.js';
6
+ /** Tools that run a command on the remote host. */
7
+ export function registerCommandTools({ server, registry, policy }, pipeline) {
8
+ const { runAudited, execAndReport, defaultProfileName } = pipeline;
9
+ // ─── read-command ──────────────────────────────────────────────────────
10
+ server.tool('read-command', D["read-command"], {
11
+ command: z.string().describe('Read-only shell command (must be in the allowlist)'),
12
+ profile: z.string().optional().describe('Profile name'),
13
+ }, { readOnlyHint: true }, async ({ command, profile }, extra) => {
14
+ return runAudited(command, { toolName: 'read-command', failureClass: 'read-only', enforceClass: 'read-only', profile, extra }, execAndReport());
15
+ });
16
+ // ─── run-command ───────────────────────────────────────────────────────
17
+ server.tool('run-command', D["run-command"], {
18
+ command: z.string().describe('Shell command to execute'),
19
+ profile: z.string().optional().describe('Profile name'),
20
+ session: z.string().optional().describe('Run in an existing interactive session (stateful)'),
21
+ tty: z.boolean().optional().describe('Allocate a pseudo-terminal'),
22
+ }, { destructiveHint: true }, async ({ command, profile, session, tty }, extra) => {
23
+ return runAudited(command, { toolName: 'run-command', failureClass: 'safe', profile, session, extra }, async (rt) => {
24
+ // A session run keeps the caller's shell state; a plain exec does not.
25
+ let audited;
26
+ if (session) {
27
+ const sess = rt.conn.getSession(session);
28
+ if (!sess)
29
+ throw new Error(`Session "${session}" not found`);
30
+ audited = await sess.run(rt.command, undefined, rt.abortSignal);
31
+ }
32
+ else {
33
+ audited = await rt.conn.exec(rt.command, {
34
+ tty, onProgress: rt.onProgress, abortSignal: rt.abortSignal,
35
+ });
36
+ }
37
+ return { audited, output: commandOutput(audited) };
38
+ });
39
+ });
40
+ // ─── privileged-command ────────────────────────────────────────────────
41
+ server.tool('privileged-command', D["privileged-command"], {
42
+ command: z.string().describe('Command to execute with sudo'),
43
+ profile: z.string().optional().describe('Profile name'),
44
+ }, { destructiveHint: true }, async ({ command, profile }, extra) => {
45
+ const profileName = defaultProfileName(profile);
46
+ return runAudited(command, {
47
+ toolName: 'privileged-command',
48
+ failureClass: 'privileged',
49
+ profile,
50
+ extra,
51
+ // Evaluate the bare command too: the sudo wrapper would otherwise
52
+ // hide a forbidden command inside a quoted `sh -c` argument.
53
+ preCheck: (cleanCmd) => {
54
+ const rawEval = policy.evaluate(cleanCmd, registry.getProfile(profileName), 'privileged-command');
55
+ if (rawEval.decision === 'deny') {
56
+ throw new PolicyRefusedError(`POLICY_DENIED: ${rawEval.reason || 'Command not allowed'}`, rawEval);
57
+ }
58
+ },
59
+ wrap: (cleanCmd) => `sudo -p "" -S sh -c ${shellSingleQuote(cleanCmd)}`,
60
+ }, (rt) => {
61
+ const sudoPassword = rt.conn.getSudoPassword();
62
+ return execAndReport({ stdin: sudoPassword ? sudoPassword + '\n' : undefined })(rt);
63
+ });
64
+ });
65
+ // ─── signal-process ────────────────────────────────────────────────────
66
+ server.tool('signal-process', D["signal-process"], {
67
+ pid: z.number().int().min(1).describe('Process ID to signal (positive integer)'),
68
+ signal: z.enum(['INT', 'TERM', 'KILL']).default('TERM').describe('Signal to send'),
69
+ profile: z.string().optional().describe('Profile name'),
70
+ }, { destructiveHint: true }, async ({ pid, signal, profile }, extra) => {
71
+ return runAudited(`kill -${signal} ${pid}`, { toolName: 'signal-process', failureClass: 'destructive', profile, extra, synthetic: true }, async (rt) => {
72
+ const { audited, output } = await execAndReport()(rt);
73
+ // `kill` is silent on success — confirm what was sent rather than
74
+ // returning an empty result.
75
+ const silentSuccess = !output.isError && !audited.stdout.trim() && !audited.stderr.trim();
76
+ return {
77
+ audited,
78
+ output: silentSuccess ? textResult(`Signal ${signal} sent to PID ${pid}`) : output,
79
+ };
80
+ });
81
+ });
82
+ }
@@ -0,0 +1,21 @@
1
+ import { createHash } from 'crypto';
2
+ export const TOOL_DESCRIPTIONS = {
3
+ 'list-connections': 'List all configured SSH profiles and their connection status. Use this to discover available hosts before running commands.',
4
+ 'list-sessions': 'List active sessions for a given SSH profile.',
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.',
7
+ 'read-session-output': 'Read recent output from a background session (e.g., tail -f logs).',
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
+ 'run-command': 'Execute an arbitrary shell command on the remote server. May modify the system. Destructive commands require user approval.',
10
+ 'privileged-command': 'Execute a command with sudo elevation. ALWAYS requires user approval. The sudo password is piped via stdin (never visible in process list).',
11
+ 'sftp-upload': 'Upload a file to the remote server via SFTP (secure file transfer, not shell-based).',
12
+ 'sftp-download': 'Download a file from the remote server via SFTP.',
13
+ 'signal-process': 'Send a signal (INT, TERM, KILL) to a remote process by PID.',
14
+ };
15
+ export function getToolHashes() {
16
+ const hashes = {};
17
+ for (const [name, desc] of Object.entries(TOOL_DESCRIPTIONS)) {
18
+ hashes[name] = createHash('sha256').update(desc).digest('hex').slice(0, 16);
19
+ }
20
+ return hashes;
21
+ }
@@ -0,0 +1,36 @@
1
+ import { z } from 'zod';
2
+ import { redactText } from '../guard/redactor.js';
3
+ import { SftpClient } from '../ssh/sftp.js';
4
+ import { TOOL_DESCRIPTIONS as D } from './descriptions.js';
5
+ import { syntheticSuccess, textResult } from './results.js';
6
+ /** SFTP transfer tools. */
7
+ export function registerFileTools({ server }, pipeline) {
8
+ const { runAudited } = pipeline;
9
+ // ─── sftp-upload ───────────────────────────────────────────────────────
10
+ server.tool('sftp-upload', D["sftp-upload"], {
11
+ remotePath: z.string().describe('Remote file path'),
12
+ content: z.string().describe('File content to upload'),
13
+ profile: z.string().optional().describe('Profile name'),
14
+ }, { destructiveHint: true }, async ({ remotePath, content, profile }, extra) => {
15
+ return runAudited(`sftp:upload ${remotePath}`, { toolName: 'sftp-upload', failureClass: 'destructive', profile, extra, synthetic: true }, async (rt) => {
16
+ await new SftpClient(rt.conn).upload({ remotePath, content });
17
+ return {
18
+ audited: syntheticSuccess(rt.profileName),
19
+ output: textResult(`Uploaded ${content.length} bytes to ${remotePath}`),
20
+ };
21
+ });
22
+ });
23
+ // ─── sftp-download ─────────────────────────────────────────────────────
24
+ server.tool('sftp-download', D["sftp-download"], {
25
+ remotePath: z.string().describe('Remote file path to download'),
26
+ profile: z.string().optional().describe('Profile name'),
27
+ }, { readOnlyHint: true }, async ({ remotePath, profile }, extra) => {
28
+ return runAudited(`sftp:download ${remotePath}`, { toolName: 'sftp-download', failureClass: 'read-only', profile, extra, synthetic: true }, async (rt) => {
29
+ const data = await new SftpClient(rt.conn).download({ remotePath });
30
+ return {
31
+ audited: syntheticSuccess(rt.profileName),
32
+ output: textResult(redactText(data.toString('utf8'), { entropyScan: true })),
33
+ };
34
+ });
35
+ });
36
+ }