@akms/mcp-ssh 0.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.
- package/LICENSE.md +11 -0
- package/README.md +332 -0
- package/lib/cli.cjs +3280 -0
- package/lib/cli.cjs.map +1 -0
- package/lib/cli.d.cts +1 -0
- package/lib/cli.d.ts +1 -0
- package/lib/cli.js +3271 -0
- package/lib/cli.js.map +1 -0
- package/lib/index.cjs +3108 -0
- package/lib/index.cjs.map +1 -0
- package/lib/index.d.cts +606 -0
- package/lib/index.d.ts +606 -0
- package/lib/index.js +3069 -0
- package/lib/index.js.map +1 -0
- package/package.json +61 -0
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { SFTPWrapper } from 'ssh2';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Guard rules applied to every command / file operation on a host.
|
|
7
|
+
* Resolved per profile: config `defaults` merged with the profile's own overrides.
|
|
8
|
+
*/
|
|
9
|
+
interface SshPolicy {
|
|
10
|
+
/** Blocks write-shaped commands, output redirection, uploads and remote writes. */
|
|
11
|
+
readonly: boolean;
|
|
12
|
+
/** When false, any segment starting with `sudo` / `su` / `doas` is rejected. */
|
|
13
|
+
allowSudo: boolean;
|
|
14
|
+
/** Per-command wall clock limit; the channel is killed once it elapses. */
|
|
15
|
+
execTimeoutMs: number;
|
|
16
|
+
/** TCP + handshake limit for opening the connection. */
|
|
17
|
+
connectTimeoutMs: number;
|
|
18
|
+
/** stdout and stderr are each truncated past this many characters. */
|
|
19
|
+
maxOutputCharacters: number;
|
|
20
|
+
/** Byte ceiling for `ssh_read_file`, guarding against dumping a huge log into context. */
|
|
21
|
+
maxReadFileBytes: number;
|
|
22
|
+
/** Allowed command binaries. Empty means "anything that no deny rule matches". */
|
|
23
|
+
allowCommands: string[];
|
|
24
|
+
/**
|
|
25
|
+
* Extra deny rules, compiled at config load so an invalid pattern fails startup
|
|
26
|
+
* instead of silently not being in force at the moment it matters.
|
|
27
|
+
*/
|
|
28
|
+
denyPatterns: RegExp[];
|
|
29
|
+
/** Remote path prefixes the file tools may touch. Empty means unrestricted. */
|
|
30
|
+
allowedPaths: string[];
|
|
31
|
+
}
|
|
32
|
+
/** One registered host: where to connect, how to authenticate, what is permitted. */
|
|
33
|
+
interface SshHostProfile {
|
|
34
|
+
/** Profile alias — the `target` argument of every tool. */
|
|
35
|
+
name: string;
|
|
36
|
+
host: string;
|
|
37
|
+
port: number;
|
|
38
|
+
username: string;
|
|
39
|
+
description?: string;
|
|
40
|
+
/** Expanded absolute path to a private key file. */
|
|
41
|
+
privateKeyPath?: string;
|
|
42
|
+
/** Private key contents, used when `privateKeyPath` is absent. */
|
|
43
|
+
privateKey?: string;
|
|
44
|
+
passphrase?: string;
|
|
45
|
+
password?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Expected server host key, as `SHA256:…` — the string `ssh-keyscan` prints and this
|
|
48
|
+
* server logs on first connect. When set, a mismatch aborts the connection.
|
|
49
|
+
*/
|
|
50
|
+
hostKeyFingerprint?: string;
|
|
51
|
+
/** Directory every session starts in; falls back to the login shell's default. */
|
|
52
|
+
defaultCwd?: string;
|
|
53
|
+
policy: SshPolicy;
|
|
54
|
+
}
|
|
55
|
+
/** Loaded profiles, keyed by alias. */
|
|
56
|
+
interface SshMcpConfig {
|
|
57
|
+
profiles: Map<string, SshHostProfile>;
|
|
58
|
+
/**
|
|
59
|
+
* Absolute path the config was read from, or null when it came from inline JSON or
|
|
60
|
+
* nothing was found. Only a real file needs protecting from the download tool.
|
|
61
|
+
*/
|
|
62
|
+
sourcePath: string | null;
|
|
63
|
+
/** Where the config came from, for tool output — a path, "inline", or "none". */
|
|
64
|
+
sourceLabel: string;
|
|
65
|
+
}
|
|
66
|
+
/** Credential-free view of a profile, safe to hand back to the model. */
|
|
67
|
+
interface SshHostSummary {
|
|
68
|
+
name: string;
|
|
69
|
+
host: string;
|
|
70
|
+
port: number;
|
|
71
|
+
username: string;
|
|
72
|
+
description?: string;
|
|
73
|
+
authMethod: "privateKey" | "password" | "agent";
|
|
74
|
+
readonly: boolean;
|
|
75
|
+
allowSudo: boolean;
|
|
76
|
+
allowedPaths: string[];
|
|
77
|
+
allowCommands: string[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Outcome of one remote command. */
|
|
81
|
+
interface SshExecResult {
|
|
82
|
+
stdout: string;
|
|
83
|
+
stderr: string;
|
|
84
|
+
/** Remote process exit status; null when the process was killed by a signal. */
|
|
85
|
+
exitCode: number | null;
|
|
86
|
+
/** Signal name that killed the remote process, e.g. "TERM". */
|
|
87
|
+
signal?: string;
|
|
88
|
+
/** True when the local timeout fired before the remote process finished. */
|
|
89
|
+
timedOut: boolean;
|
|
90
|
+
/** True when stdout or stderr hit `maxOutputCharacters` and was cut. */
|
|
91
|
+
truncated: boolean;
|
|
92
|
+
/** Working directory after the command ran; only tracked for session-bound execs. */
|
|
93
|
+
cwd?: string;
|
|
94
|
+
durationMs: number;
|
|
95
|
+
}
|
|
96
|
+
/** Live session as reported by `ssh_list_sessions`. */
|
|
97
|
+
interface SshSessionInfo {
|
|
98
|
+
sessionId: string;
|
|
99
|
+
profileName: string;
|
|
100
|
+
host: string;
|
|
101
|
+
username: string;
|
|
102
|
+
cwd: string;
|
|
103
|
+
/** ISO-8601 timestamps. */
|
|
104
|
+
openedAt: string;
|
|
105
|
+
lastUsedAt: string;
|
|
106
|
+
execCount: number;
|
|
107
|
+
}
|
|
108
|
+
/** One entry of an SFTP directory listing. */
|
|
109
|
+
interface SshRemoteEntry {
|
|
110
|
+
name: string;
|
|
111
|
+
type: "file" | "directory" | "symlink" | "other";
|
|
112
|
+
sizeBytes: number;
|
|
113
|
+
/** Octal permission string, e.g. "0644". */
|
|
114
|
+
mode: string;
|
|
115
|
+
modifiedAt: string;
|
|
116
|
+
}
|
|
117
|
+
/** Verdict from the command / path guards. */
|
|
118
|
+
interface GuardVerdict {
|
|
119
|
+
allowed: boolean;
|
|
120
|
+
/** Human-readable rejection cause; only set when `allowed` is false. */
|
|
121
|
+
reason?: string;
|
|
122
|
+
/** The command segment or path that tripped the rule. */
|
|
123
|
+
offendingText?: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
interface SshExecOptions {
|
|
127
|
+
command: string;
|
|
128
|
+
/** Directory to `cd` into first; the login shell's default is used when absent. */
|
|
129
|
+
cwd?: string;
|
|
130
|
+
timeoutMs: number;
|
|
131
|
+
maxOutputCharacters: number;
|
|
132
|
+
/** Appends a `$PWD` marker so a session can follow `cd` between commands. */
|
|
133
|
+
trackCwd: boolean;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* One ssh2 client bound to a profile, plus the exec / SFTP operations run over it.
|
|
137
|
+
*
|
|
138
|
+
* This class is the chokepoint: it holds the profile, so it applies the command guard
|
|
139
|
+
* itself rather than trusting each caller to remember. Enforcement in the tool handlers
|
|
140
|
+
* was one forgotten line away from shipping an unscreened command — and one such caller
|
|
141
|
+
* (the `ssh_connect` probe) already existed.
|
|
142
|
+
*/
|
|
143
|
+
declare class SshConnection {
|
|
144
|
+
readonly profile: SshHostProfile;
|
|
145
|
+
private client;
|
|
146
|
+
private pendingClient;
|
|
147
|
+
private connecting;
|
|
148
|
+
private disposed;
|
|
149
|
+
/** Set when the peer closed a previously established connection. */
|
|
150
|
+
private droppedAfterReady;
|
|
151
|
+
private observedFingerprint;
|
|
152
|
+
constructor(profile: SshHostProfile);
|
|
153
|
+
get isConnected(): boolean;
|
|
154
|
+
/** Host key the server presented, once a handshake has been attempted. */
|
|
155
|
+
get hostKeyFingerprint(): string | null;
|
|
156
|
+
/** True when a connection was established and then lost, so shell state is gone. */
|
|
157
|
+
get wasDropped(): boolean;
|
|
158
|
+
/**
|
|
159
|
+
* Opens the connection, or joins the in-flight attempt when one is already running.
|
|
160
|
+
*
|
|
161
|
+
* @throws {Error} If the connection was closed, authentication fails, the host is
|
|
162
|
+
* unreachable, or the handshake times out.
|
|
163
|
+
*/
|
|
164
|
+
connect(): Promise<void>;
|
|
165
|
+
/**
|
|
166
|
+
* Screens a command against this profile's policy, then runs it on its own channel.
|
|
167
|
+
*
|
|
168
|
+
* The canonical form is what gets screened *and* what gets sent — validating one
|
|
169
|
+
* string while executing another is a bypass by construction.
|
|
170
|
+
*
|
|
171
|
+
* @throws {GuardRejectionError} If the policy refuses the command or the cwd.
|
|
172
|
+
* @throws {Error} If the connection is closed or the channel cannot be opened.
|
|
173
|
+
*/
|
|
174
|
+
exec(options: SshExecOptions): Promise<SshExecResult>;
|
|
175
|
+
/**
|
|
176
|
+
* Opens an SFTP channel, hands it to `handler`, and closes it afterwards.
|
|
177
|
+
*
|
|
178
|
+
* A channel per operation costs one round trip but keeps a broken transfer from
|
|
179
|
+
* poisoning later ones — SFTP channels do not recover from a mid-stream error.
|
|
180
|
+
*
|
|
181
|
+
* The channel's own `error` event is wired into the returned promise: ssh2 strips its
|
|
182
|
+
* setup listeners before handing the wrapper over, so an unhandled SFTP protocol error
|
|
183
|
+
* would otherwise crash the process rather than fail this one call.
|
|
184
|
+
*
|
|
185
|
+
* @throws {Error} If the SFTP subsystem cannot be started or the channel errors.
|
|
186
|
+
*/
|
|
187
|
+
withSftp<T>(handler: (sftp: SFTPWrapper) => Promise<T>): Promise<T>;
|
|
188
|
+
/** Closes the connection for good; a disposed connection will not re-dial. */
|
|
189
|
+
disconnect(): void;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** A connection plus the shell state (working directory) carried across commands. */
|
|
193
|
+
declare class SshSession {
|
|
194
|
+
readonly sessionId: string;
|
|
195
|
+
readonly connection: SshConnection;
|
|
196
|
+
/** Empty until the first command reports `$PWD`; then the real remote path. */
|
|
197
|
+
cwd: string;
|
|
198
|
+
lastUsedAt: Date;
|
|
199
|
+
execCount: number;
|
|
200
|
+
readonly openedAt: Date;
|
|
201
|
+
/** Tail of the serialized operation chain; see `runExclusive`. */
|
|
202
|
+
private operationChain;
|
|
203
|
+
private activeOperationCount;
|
|
204
|
+
constructor(sessionId: string, connection: SshConnection);
|
|
205
|
+
get profile(): SshHostProfile;
|
|
206
|
+
/** True while an operation is running, so the idle sweeper leaves it alone. */
|
|
207
|
+
get isBusy(): boolean;
|
|
208
|
+
/** Marks the session as in use. */
|
|
209
|
+
touch(): void;
|
|
210
|
+
/**
|
|
211
|
+
* Runs `operation` with exclusive use of this session.
|
|
212
|
+
*
|
|
213
|
+
* Serialized because `cwd` is read-modify-write across a network round trip: two
|
|
214
|
+
* concurrent calls both read the old directory, and the one that finishes second
|
|
215
|
+
* writes it back — silently undoing the other's `cd`. Bracketing with `touch()` also
|
|
216
|
+
* keeps the idle sweeper off a session that is mid-transfer.
|
|
217
|
+
*/
|
|
218
|
+
runExclusive<T>(operation: (connection: SshConnection) => Promise<T>): Promise<T>;
|
|
219
|
+
/**
|
|
220
|
+
* Runs a command in this session's working directory and adopts wherever it ended up,
|
|
221
|
+
* so a `cd` in one call is still in effect on the next.
|
|
222
|
+
*
|
|
223
|
+
* Passing `cwd` runs this command elsewhere *and* moves the session there — same
|
|
224
|
+
* result as prefixing `cd <cwd> &&`, which is what a shell user would expect.
|
|
225
|
+
*
|
|
226
|
+
* @throws {Error} If the connection was dropped since the session opened. A silent
|
|
227
|
+
* re-dial would hand back a fresh login shell while the model still
|
|
228
|
+
* believes it holds the old one — environment, sudo cache and
|
|
229
|
+
* background jobs all gone with no signal.
|
|
230
|
+
*/
|
|
231
|
+
exec(command: string, options?: {
|
|
232
|
+
cwd?: string;
|
|
233
|
+
timeoutMs?: number;
|
|
234
|
+
}): Promise<SshExecResult>;
|
|
235
|
+
toInfo(): SshSessionInfo;
|
|
236
|
+
}
|
|
237
|
+
/** Owns every open session: creation, lookup, idle expiry and shutdown. */
|
|
238
|
+
declare class SshSessionManager {
|
|
239
|
+
private readonly sessions;
|
|
240
|
+
private sequence;
|
|
241
|
+
private pendingOpenCount;
|
|
242
|
+
private sweepTimer;
|
|
243
|
+
/**
|
|
244
|
+
* Opens a connection and registers it as a session.
|
|
245
|
+
*
|
|
246
|
+
* `pendingOpenCount` reserves the slot across the `await`: checking `sessions.size`
|
|
247
|
+
* alone let N concurrent calls all observe the same pre-connect count and blow past
|
|
248
|
+
* the ceiling together.
|
|
249
|
+
*
|
|
250
|
+
* @throws {Error} If the session ceiling is reached or the connection fails.
|
|
251
|
+
*/
|
|
252
|
+
open(profile: SshHostProfile): Promise<SshSession>;
|
|
253
|
+
/**
|
|
254
|
+
* Looks up a session, failing with the list of live ids so the caller can self-correct.
|
|
255
|
+
*
|
|
256
|
+
* @throws {Error} If no session matches `sessionId`.
|
|
257
|
+
*/
|
|
258
|
+
require(sessionId: string): SshSession;
|
|
259
|
+
close(sessionId: string): boolean;
|
|
260
|
+
closeAll(): void;
|
|
261
|
+
list(): SshSessionInfo[];
|
|
262
|
+
/** Starts the idle sweeper; the timer is unref'd so it never holds the process open. */
|
|
263
|
+
startIdleSweeper(): void;
|
|
264
|
+
stopIdleSweeper(): void;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
interface RemoteDirectoryListing {
|
|
268
|
+
entries: SshRemoteEntry[];
|
|
269
|
+
/** Entries the remote host reported, before the cap was applied. */
|
|
270
|
+
totalCount: number;
|
|
271
|
+
truncated: boolean;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Lists a remote directory, directories first then files, each group alphabetical.
|
|
275
|
+
*
|
|
276
|
+
* Capped at `MAX_DIRECTORY_ENTRIES`: this is the one output path with no byte ceiling,
|
|
277
|
+
* and a spool directory with half a million files would otherwise return megabytes.
|
|
278
|
+
*
|
|
279
|
+
* @throws {Error} If the path does not exist or is not readable.
|
|
280
|
+
*/
|
|
281
|
+
declare function listRemoteDirectory(connection: SshConnection, remotePath: string): Promise<RemoteDirectoryListing>;
|
|
282
|
+
interface RemoteFileContent {
|
|
283
|
+
content: string;
|
|
284
|
+
/** Size the remote host reported; 0 for virtual files that do not declare one. */
|
|
285
|
+
sizeBytes: number;
|
|
286
|
+
/** Bytes actually read. */
|
|
287
|
+
readBytes: number;
|
|
288
|
+
truncated: boolean;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Reads a remote file as UTF-8, stopping after `maxBytes`.
|
|
292
|
+
*
|
|
293
|
+
* Oversized files return their leading bytes rather than an error — a 2 GB log is exactly
|
|
294
|
+
* the case where the first chunk is what you wanted anyway.
|
|
295
|
+
*
|
|
296
|
+
* A reported size of 0 does not mean empty: every file under /proc and most of /sys says 0
|
|
297
|
+
* while holding real content. Those are read unbounded up to `maxBytes` instead of being
|
|
298
|
+
* range-limited, which previously returned exactly one byte and called it the whole file.
|
|
299
|
+
*
|
|
300
|
+
* @throws {Error} If the path is missing, is a directory, or is not readable.
|
|
301
|
+
*/
|
|
302
|
+
declare function readRemoteFile(connection: SshConnection, remotePath: string, maxBytes: number): Promise<RemoteFileContent>;
|
|
303
|
+
/**
|
|
304
|
+
* Writes UTF-8 text to a remote path, replacing or appending.
|
|
305
|
+
*
|
|
306
|
+
* @throws {Error} If the parent directory is missing or the account cannot write there.
|
|
307
|
+
*/
|
|
308
|
+
declare function writeRemoteFile(connection: SshConnection, args: {
|
|
309
|
+
remotePath: string;
|
|
310
|
+
content: string;
|
|
311
|
+
append: boolean;
|
|
312
|
+
}): Promise<number>;
|
|
313
|
+
/**
|
|
314
|
+
* Uploads a local file with SFTP.
|
|
315
|
+
*
|
|
316
|
+
* @returns Bytes transferred.
|
|
317
|
+
* @throws {Error} If the local file is missing or the remote path is not writable.
|
|
318
|
+
*/
|
|
319
|
+
declare function uploadFile(connection: SshConnection, localPath: string, remotePath: string): Promise<number>;
|
|
320
|
+
/**
|
|
321
|
+
* Downloads a remote file with SFTP.
|
|
322
|
+
*
|
|
323
|
+
* @returns Bytes written locally.
|
|
324
|
+
* @throws {Error} If the remote file is missing or the local path is not writable.
|
|
325
|
+
*/
|
|
326
|
+
declare function downloadFile(connection: SshConnection, remotePath: string, localPath: string): Promise<number>;
|
|
327
|
+
|
|
328
|
+
interface SshMcpServerOptions {
|
|
329
|
+
/** Explicit config path; otherwise `SSH_MCP_CONFIG`, then the home-directory candidates. */
|
|
330
|
+
configPath?: string;
|
|
331
|
+
}
|
|
332
|
+
interface SshMcpServerInstance {
|
|
333
|
+
server: McpServer;
|
|
334
|
+
sessions: SshSessionManager;
|
|
335
|
+
/** Always the config currently in force, including after a reload. */
|
|
336
|
+
readonly config: SshMcpConfig;
|
|
337
|
+
/**
|
|
338
|
+
* Re-reads the config source and applies it.
|
|
339
|
+
*
|
|
340
|
+
* @throws {Error} If the source is now missing or malformed; the previous config
|
|
341
|
+
* stays in force in that case.
|
|
342
|
+
*/
|
|
343
|
+
reloadConfig(): SshMcpConfig;
|
|
344
|
+
/** Stops the sweeper and closes every open session. Call before the process exits. */
|
|
345
|
+
shutdown(): void;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Builds the MCP server: loads host profiles, wires the session manager, registers tools.
|
|
349
|
+
*
|
|
350
|
+
* Transport is deliberately left to the caller — `cli.ts` attaches stdio, tests can attach
|
|
351
|
+
* an in-memory pair.
|
|
352
|
+
*
|
|
353
|
+
* @throws {Error} If a config file exists but is malformed (a bad policy field must not
|
|
354
|
+
* silently degrade into "no guard").
|
|
355
|
+
*/
|
|
356
|
+
declare function createSshMcpServer(options?: SshMcpServerOptions): SshMcpServerInstance;
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Loads host profiles from disk.
|
|
360
|
+
*
|
|
361
|
+
* A missing config is not an error — the server still starts and reports "no profiles"
|
|
362
|
+
* through the tools, which is a far clearer signal to the model than a dead server.
|
|
363
|
+
* A config that exists but is malformed *is* an error: silently ignoring it would let a
|
|
364
|
+
* typo in a policy field quietly drop a `readonly` guard.
|
|
365
|
+
*
|
|
366
|
+
* @throws {Error} If the resolved path is missing, unreadable, not JSON, or fails validation.
|
|
367
|
+
*/
|
|
368
|
+
declare function loadConfig(explicitPath?: string): SshMcpConfig;
|
|
369
|
+
/** Strips every secret so a profile can be described back to the model. */
|
|
370
|
+
declare function toHostSummary(profile: SshHostProfile): SshHostSummary;
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Policy knobs, all optional — unset fields fall back to `defaults`, then to the built-ins.
|
|
374
|
+
*
|
|
375
|
+
* Strict on purpose. A permissive object *strips* unknown keys, so `readOnly: true`
|
|
376
|
+
* (capital O) parsed successfully and silently vanished, leaving a host the operator
|
|
377
|
+
* believed was read-only fully writable. An unknown key must fail startup instead.
|
|
378
|
+
*/
|
|
379
|
+
declare const POLICY_INPUT_SCHEMA: z.ZodObject<{
|
|
380
|
+
readonly: z.ZodOptional<z.ZodBoolean>;
|
|
381
|
+
allowSudo: z.ZodOptional<z.ZodBoolean>;
|
|
382
|
+
execTimeoutMs: z.ZodOptional<z.ZodNumber>;
|
|
383
|
+
connectTimeoutMs: z.ZodOptional<z.ZodNumber>;
|
|
384
|
+
maxOutputCharacters: z.ZodOptional<z.ZodNumber>;
|
|
385
|
+
maxReadFileBytes: z.ZodOptional<z.ZodNumber>;
|
|
386
|
+
allowCommands: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
387
|
+
denyPatterns: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
388
|
+
allowedPaths: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
389
|
+
}, z.core.$strict>;
|
|
390
|
+
/** One `hosts` entry: connection fields plus the same policy knobs as an override. */
|
|
391
|
+
declare const HOST_INPUT_SCHEMA: z.ZodObject<{
|
|
392
|
+
readonly: z.ZodOptional<z.ZodBoolean>;
|
|
393
|
+
allowSudo: z.ZodOptional<z.ZodBoolean>;
|
|
394
|
+
execTimeoutMs: z.ZodOptional<z.ZodNumber>;
|
|
395
|
+
connectTimeoutMs: z.ZodOptional<z.ZodNumber>;
|
|
396
|
+
maxOutputCharacters: z.ZodOptional<z.ZodNumber>;
|
|
397
|
+
maxReadFileBytes: z.ZodOptional<z.ZodNumber>;
|
|
398
|
+
allowCommands: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
399
|
+
denyPatterns: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
400
|
+
allowedPaths: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
401
|
+
host: z.ZodString;
|
|
402
|
+
port: z.ZodOptional<z.ZodNumber>;
|
|
403
|
+
username: z.ZodString;
|
|
404
|
+
description: z.ZodOptional<z.ZodString>;
|
|
405
|
+
privateKeyPath: z.ZodOptional<z.ZodString>;
|
|
406
|
+
privateKey: z.ZodOptional<z.ZodString>;
|
|
407
|
+
passphrase: z.ZodOptional<z.ZodString>;
|
|
408
|
+
passphraseEnv: z.ZodOptional<z.ZodString>;
|
|
409
|
+
password: z.ZodOptional<z.ZodString>;
|
|
410
|
+
passwordEnv: z.ZodOptional<z.ZodString>;
|
|
411
|
+
hostKeyFingerprint: z.ZodOptional<z.ZodString>;
|
|
412
|
+
defaultCwd: z.ZodOptional<z.ZodString>;
|
|
413
|
+
}, z.core.$strict>;
|
|
414
|
+
declare const CONFIG_INPUT_SCHEMA: z.ZodObject<{
|
|
415
|
+
$schema: z.ZodOptional<z.ZodString>;
|
|
416
|
+
defaults: z.ZodOptional<z.ZodObject<{
|
|
417
|
+
readonly: z.ZodOptional<z.ZodBoolean>;
|
|
418
|
+
allowSudo: z.ZodOptional<z.ZodBoolean>;
|
|
419
|
+
execTimeoutMs: z.ZodOptional<z.ZodNumber>;
|
|
420
|
+
connectTimeoutMs: z.ZodOptional<z.ZodNumber>;
|
|
421
|
+
maxOutputCharacters: z.ZodOptional<z.ZodNumber>;
|
|
422
|
+
maxReadFileBytes: z.ZodOptional<z.ZodNumber>;
|
|
423
|
+
allowCommands: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
424
|
+
denyPatterns: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
425
|
+
allowedPaths: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
426
|
+
}, z.core.$strict>>;
|
|
427
|
+
hosts: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
428
|
+
readonly: z.ZodOptional<z.ZodBoolean>;
|
|
429
|
+
allowSudo: z.ZodOptional<z.ZodBoolean>;
|
|
430
|
+
execTimeoutMs: z.ZodOptional<z.ZodNumber>;
|
|
431
|
+
connectTimeoutMs: z.ZodOptional<z.ZodNumber>;
|
|
432
|
+
maxOutputCharacters: z.ZodOptional<z.ZodNumber>;
|
|
433
|
+
maxReadFileBytes: z.ZodOptional<z.ZodNumber>;
|
|
434
|
+
allowCommands: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
435
|
+
denyPatterns: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
436
|
+
allowedPaths: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
437
|
+
host: z.ZodString;
|
|
438
|
+
port: z.ZodOptional<z.ZodNumber>;
|
|
439
|
+
username: z.ZodString;
|
|
440
|
+
description: z.ZodOptional<z.ZodString>;
|
|
441
|
+
privateKeyPath: z.ZodOptional<z.ZodString>;
|
|
442
|
+
privateKey: z.ZodOptional<z.ZodString>;
|
|
443
|
+
passphrase: z.ZodOptional<z.ZodString>;
|
|
444
|
+
passphraseEnv: z.ZodOptional<z.ZodString>;
|
|
445
|
+
password: z.ZodOptional<z.ZodString>;
|
|
446
|
+
passwordEnv: z.ZodOptional<z.ZodString>;
|
|
447
|
+
hostKeyFingerprint: z.ZodOptional<z.ZodString>;
|
|
448
|
+
defaultCwd: z.ZodOptional<z.ZodString>;
|
|
449
|
+
}, z.core.$strict>>;
|
|
450
|
+
}, z.core.$strict>;
|
|
451
|
+
type PolicyInput = z.infer<typeof POLICY_INPUT_SCHEMA>;
|
|
452
|
+
type HostInput = z.infer<typeof HOST_INPUT_SCHEMA>;
|
|
453
|
+
type ConfigInput = z.infer<typeof CONFIG_INPUT_SCHEMA>;
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Rewrites shell constructs that change how the text tokenizes but not what it runs,
|
|
457
|
+
* so the rules below see the same command the remote shell will.
|
|
458
|
+
*
|
|
459
|
+
* - `\` + newline is a line continuation. POSIX removes it entirely — folding it to a
|
|
460
|
+
* space instead would split `r\⏎m` into two words and hide `rm` from every rule, so it
|
|
461
|
+
* is removed, and the *canonical* text is what gets executed (see `canonicalizeCommand`).
|
|
462
|
+
* - `${IFS}` / `$IFS` expand to whitespace, the standard way to write a command with no
|
|
463
|
+
* literal spaces in it.
|
|
464
|
+
*/
|
|
465
|
+
declare function normalizeCommandText(command: string): string;
|
|
466
|
+
/**
|
|
467
|
+
* The exact text that must be sent to the remote shell.
|
|
468
|
+
*
|
|
469
|
+
* Validating one string and executing another is a bypass by construction: the guard saw
|
|
470
|
+
* `r m -rf /` while the host ran `rm -rf /`. Callers run this, then send its result.
|
|
471
|
+
*/
|
|
472
|
+
declare function canonicalizeCommand(command: string): string;
|
|
473
|
+
/**
|
|
474
|
+
* Splits a command line on `;`, `&&`, `||`, `|`, `&` and newlines, leaving quoted text intact.
|
|
475
|
+
* `2>&1` keeps its `&` — a `&` directly after `>` is redirection, not a background operator.
|
|
476
|
+
*/
|
|
477
|
+
declare function splitCommandSegments(command: string): string[];
|
|
478
|
+
/**
|
|
479
|
+
* Drops quoted spans so `echo "a > b"` isn't read as a redirection.
|
|
480
|
+
*
|
|
481
|
+
* Quote state is checked *before* the backslash branch: POSIX single quotes take no
|
|
482
|
+
* escapes, so treating `'x\'` as an escaped quote left the span open and swallowed the
|
|
483
|
+
* rest of the line — including a real `> /etc/…` redirection.
|
|
484
|
+
*/
|
|
485
|
+
declare function stripQuotedText(segment: string): string;
|
|
486
|
+
/**
|
|
487
|
+
* Commands nested inside the text: `$(…)`, backticks, and process substitution
|
|
488
|
+
* `<(…)` / `>(…)`. Each runs as a command in its own right.
|
|
489
|
+
*/
|
|
490
|
+
declare function extractSubstitutions(command: string): string[];
|
|
491
|
+
interface SegmentAnalysis {
|
|
492
|
+
/** Wrapper binaries preceding the real command, in order — e.g. `["sudo", "env"]`. */
|
|
493
|
+
wrappers: string[];
|
|
494
|
+
/** Basename of the binary that actually runs; empty when the segment has no command. */
|
|
495
|
+
binary: string;
|
|
496
|
+
/** Flags that follow the binary, unquoted. */
|
|
497
|
+
flags: string[];
|
|
498
|
+
/** Non-flag operands that follow the binary, unquoted. */
|
|
499
|
+
operands: string[];
|
|
500
|
+
/**
|
|
501
|
+
* The segment reduced to `<basename> <args…>`, with assignment, redirection, wrapper
|
|
502
|
+
* and shell-keyword prefixes removed. Rules are `^`-anchored, so they must see the
|
|
503
|
+
* command at position zero — otherwise `nohup rm -rf /` and `/sbin/reboot` slide past.
|
|
504
|
+
*/
|
|
505
|
+
normalizedText: string;
|
|
506
|
+
/** Tokens of the segment, computed once and reused. */
|
|
507
|
+
tokens: string[];
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Walks past `FOO=bar` assignments, redirection tokens, shell keywords, wrapper binaries,
|
|
511
|
+
* their flags and their numeric operands (`timeout 30`) to the command that executes.
|
|
512
|
+
*/
|
|
513
|
+
declare function analyzeSegment(segment: string): SegmentAnalysis;
|
|
514
|
+
/**
|
|
515
|
+
* Decides whether a command may run against a host.
|
|
516
|
+
*
|
|
517
|
+
* Ordered cheapest-to-strictest so the reported reason is the most fundamental one:
|
|
518
|
+
* pipeline-wide disasters → command substitutions → then, per segment, profile deny
|
|
519
|
+
* patterns → privilege escalation → always-dangerous rules → read-only rules →
|
|
520
|
+
* allow-list → nested shell payloads.
|
|
521
|
+
*
|
|
522
|
+
* **Scope.** This is a mistake-stopper, not a sandbox. It reads the command as text, so
|
|
523
|
+
* anything that hides the real operation — an interpreter one-liner, an encoded payload,
|
|
524
|
+
* a tool named something else — runs unexamined, and no amount of pattern-chasing closes
|
|
525
|
+
* that. The layer that actually contains an agent is the remote account's own permissions.
|
|
526
|
+
*/
|
|
527
|
+
declare function inspectCommand(command: string, policy: SshPolicy, depth?: number): GuardVerdict;
|
|
528
|
+
|
|
529
|
+
type RemotePathAccess = "read" | "write";
|
|
530
|
+
/**
|
|
531
|
+
* Decides whether a file tool may touch a remote path.
|
|
532
|
+
*
|
|
533
|
+
* With `allowedPaths` set, only absolute paths are accepted: a relative path resolves
|
|
534
|
+
* against the SFTP session's own working directory, which the guard cannot see, so it
|
|
535
|
+
* could escape the allow-list without any way to detect it here.
|
|
536
|
+
*
|
|
537
|
+
* Symlinks on the remote side are not resolved — the guard has no view of that
|
|
538
|
+
* filesystem. `allowedPaths` therefore bounds *names*, not inodes.
|
|
539
|
+
*/
|
|
540
|
+
declare function inspectRemotePath(remotePath: string, policy: SshPolicy, access: RemotePathAccess): GuardVerdict;
|
|
541
|
+
/**
|
|
542
|
+
* Decides whether a file tool may touch a path on the machine running this server.
|
|
543
|
+
*
|
|
544
|
+
* Separate from the remote guard because the threat is inverted: here the danger is
|
|
545
|
+
* reading the operator's own credentials out (upload) or overwriting the files that
|
|
546
|
+
* define the guards themselves (download).
|
|
547
|
+
*
|
|
548
|
+
* @param resolvedLocalPath Absolute path, already `~`-expanded.
|
|
549
|
+
* @param extraProtectedPaths Runtime-known paths to protect, such as the loaded config file.
|
|
550
|
+
*/
|
|
551
|
+
declare function inspectLocalPath(resolvedLocalPath: string, access: RemotePathAccess, extraProtectedPaths?: string[]): GuardVerdict;
|
|
552
|
+
/**
|
|
553
|
+
* Decides whether a `cwd` argument is safe to interpolate into the generated `cd` prefix.
|
|
554
|
+
*
|
|
555
|
+
* `cwd` never reaches the command guard — it is not a command — yet it lands in the same
|
|
556
|
+
* remote shell script. Without this check, `cwd: '/tmp"; rm -rf /; #'` executes with every
|
|
557
|
+
* command guard bypassed.
|
|
558
|
+
*/
|
|
559
|
+
declare function inspectWorkingDirectory(cwd: string): GuardVerdict;
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Thrown when a guard refuses an operation.
|
|
563
|
+
*
|
|
564
|
+
* A distinct class because callers genuinely branch on it: the tool layer renders a
|
|
565
|
+
* rejection with `formatGuardRejection` and marks it a policy decision, while any other
|
|
566
|
+
* error is an operational failure worth retrying. It carries the verdict so that
|
|
567
|
+
* rendering needs no string parsing.
|
|
568
|
+
*/
|
|
569
|
+
declare class GuardRejectionError extends Error {
|
|
570
|
+
readonly verdict: GuardVerdict;
|
|
571
|
+
constructor(verdict: GuardVerdict);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** Everything the tool handlers share: the loaded profiles and the live sessions. */
|
|
575
|
+
interface ToolContext {
|
|
576
|
+
config: SshMcpConfig;
|
|
577
|
+
sessions: SshSessionManager;
|
|
578
|
+
/**
|
|
579
|
+
* Re-reads the config source and swaps it in, so `context.config` is always current.
|
|
580
|
+
* Implemented by the server, which owns the source path.
|
|
581
|
+
*/
|
|
582
|
+
reloadConfig(): SshMcpConfig;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** Registers every tool this server exposes, in the order the model should discover them. */
|
|
586
|
+
declare function registerAllTools(server: McpServer, context: ToolContext): void;
|
|
587
|
+
|
|
588
|
+
type LogLevel = "silent" | "debug" | "info" | "warn" | "error";
|
|
589
|
+
interface Logger {
|
|
590
|
+
/** Returns a logger that appends `meta` to every record it writes. */
|
|
591
|
+
child(meta: Record<string, unknown>): Logger;
|
|
592
|
+
debug(message: string, meta?: Record<string, unknown>): void;
|
|
593
|
+
info(message: string, meta?: Record<string, unknown>): void;
|
|
594
|
+
warn(message: string, meta?: Record<string, unknown>): void;
|
|
595
|
+
error(error: unknown, message: string, meta?: Record<string, unknown>): void;
|
|
596
|
+
}
|
|
597
|
+
declare const $logger: Logger;
|
|
598
|
+
|
|
599
|
+
/** Env var holding an explicit config file path. */
|
|
600
|
+
declare const CONFIG_PATH_ENV = "SSH_MCP_CONFIG";
|
|
601
|
+
/** Env var overriding the stderr log level ("debug" | "info" | "warn" | "error" | "silent"). */
|
|
602
|
+
declare const LOG_LEVEL_ENV = "SSH_MCP_LOG_LEVEL";
|
|
603
|
+
declare const SERVER_NAME = "akms-ssh";
|
|
604
|
+
declare const SERVER_VERSION = "0.0.1";
|
|
605
|
+
|
|
606
|
+
export { $logger, CONFIG_INPUT_SCHEMA, CONFIG_PATH_ENV, type ConfigInput, GuardRejectionError, type GuardVerdict, HOST_INPUT_SCHEMA, type HostInput, LOG_LEVEL_ENV, type LogLevel, type Logger, POLICY_INPUT_SCHEMA, type PolicyInput, type RemoteDirectoryListing, type RemoteFileContent, type RemotePathAccess, SERVER_NAME, SERVER_VERSION, type SegmentAnalysis, SshConnection, type SshExecOptions, type SshExecResult, type SshHostProfile, type SshHostSummary, type SshMcpConfig, type SshMcpServerInstance, type SshMcpServerOptions, type SshPolicy, type SshRemoteEntry, SshSession, type SshSessionInfo, SshSessionManager, type ToolContext, analyzeSegment, canonicalizeCommand, createSshMcpServer, downloadFile, extractSubstitutions, inspectCommand, inspectLocalPath, inspectRemotePath, inspectWorkingDirectory, listRemoteDirectory, loadConfig, normalizeCommandText, readRemoteFile, registerAllTools, splitCommandSegments, stripQuotedText, toHostSummary, uploadFile, writeRemoteFile };
|