ssh-mcp 1.5.0 → 2.0.0

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,313 @@
1
+ import { tracer } from '../observability/tracer.js';
2
+ const ANSI_REGEX = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07]*\x07|\r/g;
3
+ export function stripAnsi(text) {
4
+ return text.replace(ANSI_REGEX, '');
5
+ }
6
+ export class Session {
7
+ id;
8
+ name;
9
+ profile;
10
+ type;
11
+ createdAt;
12
+ lastActivity;
13
+ ttlMs;
14
+ /** Absolute lifetime cap, measured from createdAt. Undefined = no cap. */
15
+ maxLifetimeMs;
16
+ _status = 'active';
17
+ constructor(id, name, profile, type, ttlMs, maxLifetimeMs) {
18
+ this.id = id;
19
+ this.name = name;
20
+ this.profile = profile;
21
+ this.type = type;
22
+ this.createdAt = new Date();
23
+ this.lastActivity = new Date();
24
+ this.ttlMs = ttlMs;
25
+ this.maxLifetimeMs = maxLifetimeMs;
26
+ }
27
+ get status() {
28
+ return this._status;
29
+ }
30
+ isExpired() {
31
+ // The idle TTL alone never fires for a chatty background process, because
32
+ // every data chunk touches lastActivity. The absolute cap is what actually
33
+ // bounds a `tail -f`-style session.
34
+ if (this.maxLifetimeMs !== undefined && Date.now() - this.createdAt.getTime() > this.maxLifetimeMs) {
35
+ return true;
36
+ }
37
+ return Date.now() - this.lastActivity.getTime() > this.ttlMs;
38
+ }
39
+ touch() {
40
+ this.lastActivity = new Date();
41
+ }
42
+ markDisconnected() {
43
+ if (this._status === 'active') {
44
+ this._status = 'disconnected';
45
+ }
46
+ }
47
+ toInfo() {
48
+ return {
49
+ id: this.id,
50
+ name: this.name,
51
+ profile: this.profile,
52
+ type: this.type,
53
+ status: this._status,
54
+ createdAt: this.createdAt,
55
+ lastActivity: this.lastActivity,
56
+ ttlMs: this.ttlMs,
57
+ };
58
+ }
59
+ }
60
+ export class InteractiveSession extends Session {
61
+ stream;
62
+ cwd = '~';
63
+ static MAX_OUTPUT = 1_048_576;
64
+ constructor(id, name, profile, stream, ttlMs) {
65
+ super(id, name, profile, 'interactive', ttlMs);
66
+ this.stream = stream;
67
+ stream.on('close', () => {
68
+ this._status = 'closed';
69
+ });
70
+ }
71
+ async run(command, timeoutMs = 60_000, abortSignal) {
72
+ if (this._status !== 'active') {
73
+ throw new Error(`Session ${this.name} is not active (status: ${this._status})`);
74
+ }
75
+ const span = tracer.startSpan('ssh.session.run');
76
+ span.setAttribute('session.id', this.id);
77
+ span.setAttribute('session.name', this.name);
78
+ const marker = this.generateMarker();
79
+ // Split literals: the shell assembles these at runtime, so the *echoed*
80
+ // command line never contains the assembled marker — only the command's
81
+ // actual output does. That is what makes the parse below deterministic
82
+ // instead of a guess about which echoed line to skip.
83
+ const beginParts = ['SSHMCP_BEG', `_${marker}`];
84
+ const endParts = ['SSHMCP_END', `_${marker}`];
85
+ const beginMarker = beginParts.join('');
86
+ const endMarker = endParts.join('');
87
+ const startTime = Date.now();
88
+ return new Promise((resolve, reject) => {
89
+ let buffer = '';
90
+ let resolved = false;
91
+ // Assigned below; declared here so every settle path can detach it.
92
+ let detachAbort = () => { };
93
+ const timeoutId = setTimeout(() => {
94
+ if (!resolved) {
95
+ resolved = true;
96
+ this.stream.removeListener('data', dataHandler);
97
+ detachAbort();
98
+ try {
99
+ this.stream.write('\x03');
100
+ }
101
+ catch { /* */ }
102
+ setTimeout(() => { try {
103
+ this.stream.signal('TERM');
104
+ }
105
+ catch { /* */ } }, 500);
106
+ span.end();
107
+ reject(new Error(`Command timed out after ${timeoutMs}ms in session ${this.name}`));
108
+ }
109
+ }, timeoutMs);
110
+ // The trailer carries the exit code and the shell's real CWD, so neither
111
+ // has to be inferred (cwd used to be guessed from the command text, which
112
+ // was wrong for relative paths, `cd -`, symlinks and `cd` with no arg).
113
+ const endRegex = new RegExp(`${endMarker}__(\\d+)__([^\\r\\n]*)`);
114
+ const dataHandler = (data) => {
115
+ const prevLength = buffer.length;
116
+ buffer += data.toString();
117
+ if (buffer.length > InteractiveSession.MAX_OUTPUT * 2) {
118
+ buffer = buffer.slice(-InteractiveSession.MAX_OUTPUT * 2);
119
+ }
120
+ // The marker can only appear at the tail, so search the newly-arrived
121
+ // bytes plus an overlap rather than rescanning the whole buffer on
122
+ // every chunk — that was O(total x buffer) for a chatty command. The
123
+ // cheap indexOf also gates the ANSI strip, which is the expensive part.
124
+ const searchFrom = Math.max(0, Math.min(prevLength, buffer.length) - endMarker.length - 32);
125
+ if (resolved || buffer.indexOf(endMarker, searchFrom) === -1)
126
+ return;
127
+ const cleaned = stripAnsi(buffer);
128
+ const match = cleaned.match(endRegex);
129
+ if (match && !resolved) {
130
+ resolved = true;
131
+ clearTimeout(timeoutId);
132
+ this.stream.removeListener('data', dataHandler);
133
+ detachAbort();
134
+ const exitCode = parseInt(match[1]);
135
+ const reportedCwd = match[2].trim();
136
+ // Output is exactly what lies between the two printed markers.
137
+ // Anything before the begin marker — the echoed command, leftover
138
+ // shell init, a prompt that PS1="" had not suppressed yet — is
139
+ // discarded by construction rather than by counting lines.
140
+ const beginIdx = cleaned.indexOf(beginMarker);
141
+ const afterBegin = beginIdx >= 0
142
+ ? cleaned.slice(cleaned.indexOf('\n', beginIdx) + 1)
143
+ : cleaned;
144
+ const endIdx = afterBegin.indexOf(endMarker);
145
+ const between = endIdx >= 0 ? afterBegin.slice(0, endIdx) : afterBegin;
146
+ const output = between.replace(/^\n+/, '').replace(/\n+$/, '');
147
+ if (reportedCwd)
148
+ this.cwd = reportedCwd;
149
+ this.touch();
150
+ resolve({
151
+ stdout: output,
152
+ stderr: '',
153
+ exitCode,
154
+ durationMs: Date.now() - startTime,
155
+ cwd: this.cwd,
156
+ sessionId: this.id,
157
+ profile: this.profile,
158
+ });
159
+ span.setAttribute('ssh.exitCode', exitCode);
160
+ span.end();
161
+ }
162
+ };
163
+ if (abortSignal) {
164
+ if (abortSignal.aborted) {
165
+ resolved = true;
166
+ clearTimeout(timeoutId);
167
+ span.end();
168
+ reject(new Error('Command aborted before execution'));
169
+ return;
170
+ }
171
+ const onAbort = () => {
172
+ if (!resolved) {
173
+ resolved = true;
174
+ clearTimeout(timeoutId);
175
+ this.stream.removeListener('data', dataHandler);
176
+ try {
177
+ this.stream.write('\x03');
178
+ }
179
+ catch { /* */ }
180
+ setTimeout(() => { try {
181
+ this.stream.signal('TERM');
182
+ }
183
+ catch { /* */ } }, 1000);
184
+ span.end();
185
+ reject(new Error('Command aborted'));
186
+ }
187
+ };
188
+ abortSignal.addEventListener('abort', onAbort, { once: true });
189
+ // Without this the listener outlived the run, and its closure retained
190
+ // the run's buffer (up to 2MB) for the lifetime of the signal.
191
+ detachAbort = () => abortSignal.removeEventListener('abort', onAbort);
192
+ }
193
+ this.stream.on('data', dataHandler);
194
+ // One line, so the PTY produces exactly one echo and it necessarily
195
+ // precedes the begin marker's output. Written as three lines, the shell
196
+ // echoed each just before running it, interleaving the echoes of the
197
+ // command and the trailer *after* the begin marker — right in the middle
198
+ // of what we treat as output.
199
+ //
200
+ // The markers are printed from split literals, so the echo of this line
201
+ // never contains an assembled marker; only the shell's own output does.
202
+ this.stream.write(`printf '%s%s\\n' '${beginParts[0]}' '${beginParts[1]}'; ` +
203
+ `${command}; ` +
204
+ `printf '%s%s__%s__%s\\n' '${endParts[0]}' '${endParts[1]}' "$?" "$PWD"\n`);
205
+ });
206
+ }
207
+ generateMarker() {
208
+ return Math.random().toString(36).substring(2, 14) + Date.now().toString(36);
209
+ }
210
+ async close() {
211
+ try {
212
+ this.stream.end();
213
+ }
214
+ catch { /* ignore */ }
215
+ this._status = 'closed';
216
+ }
217
+ get currentCwd() {
218
+ return this.cwd;
219
+ }
220
+ /** Record the shell's starting directory when the profile sets a workdir. */
221
+ setCwd(dir) {
222
+ this.cwd = dir;
223
+ }
224
+ }
225
+ export class BackgroundSession extends Session {
226
+ stream;
227
+ ringBuffer = [];
228
+ ringBytes = 0;
229
+ ringMax = 10_000;
230
+ /** Tail of the last chunk when it did not end on a line boundary. */
231
+ partialLine = '';
232
+ exitCode = null;
233
+ static RING_CHAR_LIMIT = 100_000;
234
+ constructor(id, name, profile, stream, ttlMs, maxLifetimeMs) {
235
+ super(id, name, profile, 'background', ttlMs, maxLifetimeMs);
236
+ this.stream = stream;
237
+ stream.on('data', (data) => {
238
+ // Splitting each chunk independently corrupted the output twice over: a
239
+ // line spanning two chunks became two entries (readOutput joins with
240
+ // '\n', inventing a break that was never in the stream), and a chunk
241
+ // ending on '\n' produced a trailing '' entry, so a blank line was
242
+ // inserted between every chunk. Carry the incomplete tail instead.
243
+ const text = this.partialLine + data.toString();
244
+ const lines = text.split('\n');
245
+ this.partialLine = lines.pop() ?? '';
246
+ for (const line of lines) {
247
+ this.ringBuffer.push(line);
248
+ this.ringBytes += line.length + 1;
249
+ }
250
+ this.trimRingBuffer();
251
+ this.touch();
252
+ });
253
+ stream.on('close', (code) => {
254
+ // Flush a final line that never got its newline.
255
+ if (this.partialLine) {
256
+ this.ringBuffer.push(this.partialLine);
257
+ this.ringBytes += this.partialLine.length + 1;
258
+ this.partialLine = '';
259
+ this.trimRingBuffer();
260
+ }
261
+ this.exitCode = code;
262
+ this._status = this.isExpired() ? 'expired' : 'closed';
263
+ });
264
+ }
265
+ /**
266
+ * Evict from the head in one splice. Looping `shift()` moved every remaining
267
+ * element per evicted line, so a chatty process paid O(lines x buffer) on the
268
+ * shared event loop once the buffer was full.
269
+ */
270
+ trimRingBuffer() {
271
+ let dropCount = 0;
272
+ let freed = 0;
273
+ while (this.ringBytes - freed > BackgroundSession.RING_CHAR_LIMIT &&
274
+ dropCount < this.ringBuffer.length) {
275
+ freed += this.ringBuffer[dropCount].length + 1;
276
+ dropCount++;
277
+ }
278
+ const remaining = this.ringBuffer.length - dropCount;
279
+ if (remaining > this.ringMax) {
280
+ const extra = remaining - this.ringMax;
281
+ for (let i = 0; i < extra; i++) {
282
+ freed += this.ringBuffer[dropCount + i].length + 1;
283
+ }
284
+ dropCount += extra;
285
+ }
286
+ if (dropCount > 0) {
287
+ this.ringBuffer.splice(0, dropCount);
288
+ this.ringBytes -= freed;
289
+ }
290
+ }
291
+ readOutput(lines = 50) {
292
+ // Include the in-flight partial line so output that has not yet ended with
293
+ // a newline is still visible to a caller polling the session.
294
+ const all = this.partialLine ? [...this.ringBuffer, this.partialLine] : this.ringBuffer;
295
+ return all.slice(-lines).join('\n');
296
+ }
297
+ isRunning() {
298
+ return this._status === 'active';
299
+ }
300
+ getExitCode() {
301
+ return this.exitCode;
302
+ }
303
+ async run(_command) {
304
+ throw new Error('Background sessions do not support run(). The command was started when the session was opened.');
305
+ }
306
+ async close() {
307
+ try {
308
+ this.stream.close();
309
+ }
310
+ catch { /* ignore */ }
311
+ this._status = 'closed';
312
+ }
313
+ }
@@ -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
+ }