ssh-mcp 1.4.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.
- package/README.md +488 -141
- package/build/audit/store.js +178 -0
- package/build/config/credential-resolver.js +107 -0
- package/build/config/loader.js +100 -0
- package/build/config/schema.js +53 -0
- package/build/guard/approval-grants.js +0 -0
- package/build/guard/elicitation.js +38 -0
- package/build/guard/redactor.js +67 -0
- package/build/guard/sanitizer.js +24 -0
- package/build/index.js +183 -618
- package/build/observability/tracer.js +27 -0
- package/build/policy/classifier.js +99 -0
- package/build/policy/engine.js +205 -0
- package/build/policy/quota.js +59 -0
- package/build/ssh/algorithms.js +33 -0
- package/build/ssh/channel-retry.js +15 -0
- package/build/ssh/connection-registry.js +102 -0
- package/build/ssh/connection.js +375 -0
- package/build/ssh/host-key.js +23 -0
- package/build/ssh/session-manager.js +164 -0
- package/build/ssh/session.js +313 -0
- package/build/ssh/sftp.js +130 -0
- package/build/tools/command-tools.js +82 -0
- package/build/tools/descriptions.js +21 -0
- package/build/tools/file-tools.js +36 -0
- package/build/tools/pipeline.js +185 -0
- package/build/tools/registry.js +23 -0
- package/build/tools/resources.js +73 -0
- package/build/tools/results.js +31 -0
- package/build/tools/session-tools.js +97 -0
- package/build/transport/http.js +237 -0
- package/build/types.js +2 -0
- package/build/version.js +9 -0
- package/package.json +28 -7
|
@@ -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
|
+
}
|