ssh-mcp 2.3.3 → 2.4.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 +16 -0
- package/build/cli.js +259 -0
- package/build/config/loader.js +9 -1
- package/build/config/sddl.js +453 -0
- package/build/config/windows-acl.js +1 -423
- package/build/errors.js +23 -0
- package/build/index.js +3 -212
- package/build/ssh/connection-registry.js +20 -1
- package/build/tools/command-tools.js +5 -2
- package/build/tools/pipeline.js +21 -6
- package/build/tools/session-tools.js +26 -10
- package/build/transport/http.js +13 -2
- package/package.json +3 -2
package/build/index.js
CHANGED
|
@@ -1,222 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
-
import {
|
|
5
|
-
import { OperatorError, ConfigNotFoundError, reportFatal } from './errors.js';
|
|
4
|
+
import { reportFatal } from './errors.js';
|
|
6
5
|
import { ConnectionRegistry } from './ssh/connection-registry.js';
|
|
7
|
-
import { PolicyEngine,
|
|
6
|
+
import { PolicyEngine, resolvePolicyRules } from './policy/engine.js';
|
|
8
7
|
import { AuditStore } from './audit/store.js';
|
|
9
8
|
import { registerTools, registerResources, getToolHashes } from './tools/registry.js';
|
|
10
9
|
import { initKeychain } from './config/credential-resolver.js';
|
|
11
10
|
import { SERVER_VERSION } from './version.js';
|
|
12
|
-
|
|
13
|
-
const args = process.argv.slice(2);
|
|
14
|
-
const config = {};
|
|
15
|
-
for (const arg of args) {
|
|
16
|
-
if (arg.startsWith('--')) {
|
|
17
|
-
const eq = arg.indexOf('=');
|
|
18
|
-
if (eq === -1) {
|
|
19
|
-
config[arg.slice(2)] = null;
|
|
20
|
-
}
|
|
21
|
-
else {
|
|
22
|
-
config[arg.slice(2, eq)] = arg.slice(eq + 1);
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
return config;
|
|
27
|
-
}
|
|
28
|
-
/**
|
|
29
|
-
* Whether a boolean flag was passed at all.
|
|
30
|
-
*
|
|
31
|
-
* `parseArgv` stores `null` for a flag written without `=`, which is how every
|
|
32
|
-
* documented boolean flag is written. Testing that value for truthiness makes
|
|
33
|
-
* the flag a no-op: `--disableApproval`, `--auditEntropyScan` and
|
|
34
|
-
* `--auditTamperEvident` all did nothing unless spelled `--flag=1`, which
|
|
35
|
-
* nothing documents (#91). The two call sites that got it right already used
|
|
36
|
-
* `!== undefined`; this gives the check a name so the next one cannot miss it.
|
|
37
|
-
*
|
|
38
|
-
* `--flag=false` and `--flag=0` turn it off, because a flag that cannot be
|
|
39
|
-
* turned off once written into a wrapper script is its own annoyance.
|
|
40
|
-
*/
|
|
41
|
-
function flagEnabled(argv, name) {
|
|
42
|
-
if (!(name in argv))
|
|
43
|
-
return false;
|
|
44
|
-
const value = argv[name];
|
|
45
|
-
if (value === null || value === '')
|
|
46
|
-
return true;
|
|
47
|
-
return !['false', '0', 'no', 'off'].includes(value.toLowerCase());
|
|
48
|
-
}
|
|
49
|
-
/**
|
|
50
|
-
* v1 flags that v2 removed. Silently ignoring them means the user only finds
|
|
51
|
-
* out at the first command, as an auth failure with no hint about the cause.
|
|
52
|
-
*/
|
|
53
|
-
const REMOVED_V1_FLAGS = {
|
|
54
|
-
password: 'Use the SSH_MCP_PASSWORD env var (or a per-profile SSH_MCP_<PROFILE>_PASSWORD).',
|
|
55
|
-
suPassword: 'Use the SSH_MCP_SUDO_PASSWORD env var.',
|
|
56
|
-
sudoPassword: 'Use the SSH_MCP_SUDO_PASSWORD env var.',
|
|
57
|
-
disableSudo: 'Sudo is now a separate tool; restrict it with a role/policy that disallows the "privileged" class.',
|
|
58
|
-
};
|
|
59
|
-
function checkRemovedFlags(argv) {
|
|
60
|
-
const found = Object.keys(REMOVED_V1_FLAGS).filter((f) => f in argv);
|
|
61
|
-
if (found.length === 0)
|
|
62
|
-
return;
|
|
63
|
-
throw new OperatorError('These flags were removed in v2:\n' +
|
|
64
|
-
found.map((f) => ` --${f}: ${REMOVED_V1_FLAGS[f]}`).join('\n') +
|
|
65
|
-
'\nSee the "Migrating from v1" section of the README.');
|
|
66
|
-
}
|
|
67
|
-
/**
|
|
68
|
-
* v1 documented `--maxChars=none` (and 0/negative) as "no limit". Parsing it
|
|
69
|
-
* with `parseInt(...) || 5000` silently turned that into a 5000-char cap.
|
|
70
|
-
*/
|
|
71
|
-
function parseMaxChars(raw) {
|
|
72
|
-
if (typeof raw !== 'string' || raw === '')
|
|
73
|
-
return 5_000;
|
|
74
|
-
if (raw.toLowerCase() === 'none')
|
|
75
|
-
return Number.MAX_SAFE_INTEGER;
|
|
76
|
-
const parsed = parseInt(raw);
|
|
77
|
-
if (isNaN(parsed))
|
|
78
|
-
return 5_000;
|
|
79
|
-
return parsed <= 0 ? Number.MAX_SAFE_INTEGER : parsed;
|
|
80
|
-
}
|
|
81
|
-
/**
|
|
82
|
-
* `strict` was previously unreachable: nothing but test code could select it,
|
|
83
|
-
* yet host-key.ts pointed users at a `--acceptNewHostKey` flag that never
|
|
84
|
-
* existed. `--insecureHostKey` is kept as an alias for the documented flag.
|
|
85
|
-
*/
|
|
86
|
-
function resolveHostKeyMode(argv) {
|
|
87
|
-
if (flagEnabled(argv, 'insecureHostKey'))
|
|
88
|
-
return 'insecure';
|
|
89
|
-
const mode = argv.hostKeyMode;
|
|
90
|
-
if (mode === null || mode === undefined)
|
|
91
|
-
return 'tofu';
|
|
92
|
-
if (mode === 'tofu' || mode === 'strict' || mode === 'insecure')
|
|
93
|
-
return mode;
|
|
94
|
-
throw new OperatorError(`Invalid --hostKeyMode=${mode}. Expected one of: tofu, strict, insecure.`);
|
|
95
|
-
}
|
|
96
|
-
/**
|
|
97
|
-
* Which policy tier a CLI-configured host belongs to.
|
|
98
|
-
*
|
|
99
|
-
* Without this there was no way to say it from the command line at all. The
|
|
100
|
-
* inline profile carries no group, so it fell to the strictest tier, where the
|
|
101
|
-
* `admin` role has no `privileged` — meaning `sudo` could never run for anyone
|
|
102
|
-
* who had not written a config file (#91).
|
|
103
|
-
*
|
|
104
|
-
* The default stays `prod`. Guessing an unknown host is production is the safe
|
|
105
|
-
* direction; what was missing was a way to correct the guess.
|
|
106
|
-
*/
|
|
107
|
-
export function resolveHostGroup(argv) {
|
|
108
|
-
const group = argv.group;
|
|
109
|
-
if (group === null || group === undefined)
|
|
110
|
-
return 'prod';
|
|
111
|
-
if (HOST_GROUPS.includes(group))
|
|
112
|
-
return group;
|
|
113
|
-
// Falling through would silently apply the prod bindings to a typo, and the
|
|
114
|
-
// operator would read the refusal as policy rather than as their own slip.
|
|
115
|
-
throw new OperatorError(`Invalid --group=${group}. Expected one of: ${HOST_GROUPS.join(', ')}.`);
|
|
116
|
-
}
|
|
117
|
-
/**
|
|
118
|
-
* Every ACL finding, in the order it happened — both "readable beyond its owner" and
|
|
119
|
-
* "could not be checked".
|
|
120
|
-
*
|
|
121
|
-
* Collected rather than only printed so `main()` can act on them once there is somewhere
|
|
122
|
-
* durable to put them; see the note at the sink below.
|
|
123
|
-
*/
|
|
124
|
-
const aclFindings = [];
|
|
125
|
-
async function buildAppConfig(argv) {
|
|
126
|
-
// The way past an ACL that could not be read, rather than an operator with no
|
|
127
|
-
// exit. See assertPrivateOnWindows.
|
|
128
|
-
// The sink is supplied here rather than left to the module's default, which is the
|
|
129
|
-
// point of having one: "this security check did not run" is a decision about what the
|
|
130
|
-
// operator must be told, and the loader is not the layer that owns that. Routing it to
|
|
131
|
-
// the audit store as well needs an AuditRecord variant for a startup event — that type
|
|
132
|
-
// is hash-chained and tamper-evident, so it is its own change, and it is queued.
|
|
133
|
-
const aclOpts = {
|
|
134
|
-
enforce: flagEnabled(argv, 'strictConfigAcl'),
|
|
135
|
-
allowUnchecked: flagEnabled(argv, 'allowUncheckedConfigAcl'),
|
|
136
|
-
onFinding: (f) => {
|
|
137
|
-
aclFindings.push(f);
|
|
138
|
-
console.error(`Warning: ${f.message}`);
|
|
139
|
-
},
|
|
140
|
-
};
|
|
141
|
-
if (argv.config !== undefined) {
|
|
142
|
-
// `parseArgv` stores null for a flag written without `=`, so a bare `--config` used to
|
|
143
|
-
// be falsy here and fall into the default-path branch — which now refuses rather than
|
|
144
|
-
// silently ignoring an unusable file, so the operator got a refusal naming a path they
|
|
145
|
-
// never asked for.
|
|
146
|
-
if (argv.config === null || argv.config === '') {
|
|
147
|
-
throw new OperatorError('--config needs a path: --config=<path>');
|
|
148
|
-
}
|
|
149
|
-
return loadConfig(argv.config, aclOpts);
|
|
150
|
-
}
|
|
151
|
-
try {
|
|
152
|
-
const fromFile = await loadConfig(undefined, aclOpts);
|
|
153
|
-
// Before #138 every Windows config was rejected by the mode-bit check and
|
|
154
|
-
// the rejection was swallowed, so an operator who also passed --host/--user
|
|
155
|
-
// was silently running off the flags. Now that the file loads, it takes
|
|
156
|
-
// precedence — a flip worth one line rather than a surprise about which host
|
|
157
|
-
// a command reached.
|
|
158
|
-
if (argv.host || argv.user) {
|
|
159
|
-
console.error(`Note: ${getConfigPath()} was loaded, so --host/--user are ignored. ` +
|
|
160
|
-
'Pass --config <path> to choose a different file.');
|
|
161
|
-
}
|
|
162
|
-
return fromFile;
|
|
163
|
-
}
|
|
164
|
-
catch (err) {
|
|
165
|
-
// Only "there is no file" means fall through. A file that exists and is
|
|
166
|
-
// unusable — malformed TOML, a schema violation, a permission failure —
|
|
167
|
-
// used to be swallowed here too and reported as a missing config, which is
|
|
168
|
-
// how #138 stayed unfindable: the operator's file was in the right place,
|
|
169
|
-
// and the real error was discarded before anyone could read it.
|
|
170
|
-
//
|
|
171
|
-
// Falling through would also be wrong on its own terms whenever
|
|
172
|
-
// --host/--user happen to be present: the server would start, silently
|
|
173
|
-
// ignoring every profile, policy and role binding the operator wrote.
|
|
174
|
-
if (!(err instanceof ConfigNotFoundError))
|
|
175
|
-
throw err;
|
|
176
|
-
}
|
|
177
|
-
if (!argv.host || !argv.user) {
|
|
178
|
-
throw new OperatorError('No config file found and missing required --host/--user.\n' +
|
|
179
|
-
// Was hardcoded to ~/.config/ssh-mcp/config.toml on every platform, so on
|
|
180
|
-
// Windows and macOS it named a path this code never reads.
|
|
181
|
-
`Either create a config file at ${getConfigPath()} or pass --config <path>.\n` +
|
|
182
|
-
'For quick start: --host=<host> --user=<user> (credentials via env vars).');
|
|
183
|
-
}
|
|
184
|
-
const defaults = {
|
|
185
|
-
sessionMaxPerConnection: parseInt(argv.sessionMax) || 5,
|
|
186
|
-
sessionIdleTimeoutMs: parseInt(argv.sessionTtl) || 600_000,
|
|
187
|
-
sessionBackgroundMaxMs: 3_600_000,
|
|
188
|
-
commandTimeoutMs: parseInt(argv.timeout) || 60_000,
|
|
189
|
-
commandMaxChars: parseMaxChars(argv.maxChars),
|
|
190
|
-
commandMaxOutputBytes: 1_048_576,
|
|
191
|
-
connectionIdleReapMs: 900_000,
|
|
192
|
-
commandQuotaPerDay: parseInt(argv.commandQuota) || 0,
|
|
193
|
-
approvalGrantTtlMs: parseInt(argv.approvalGrantTtl) || 0,
|
|
194
|
-
approvalMode: 'ask-destructive',
|
|
195
|
-
};
|
|
196
|
-
const profile = {
|
|
197
|
-
name: 'default',
|
|
198
|
-
host: argv.host,
|
|
199
|
-
port: parseInt(argv.port) || 22,
|
|
200
|
-
user: argv.user,
|
|
201
|
-
auth: argv.key ? 'key' : 'password',
|
|
202
|
-
keyRef: argv.key || undefined,
|
|
203
|
-
workdir: argv.workdir || undefined,
|
|
204
|
-
tty: false,
|
|
205
|
-
timeout: defaults.commandTimeoutMs,
|
|
206
|
-
maxChars: defaults.commandMaxChars,
|
|
207
|
-
maxOutputBytes: defaults.commandMaxOutputBytes,
|
|
208
|
-
role: 'admin',
|
|
209
|
-
group: resolveHostGroup(argv),
|
|
210
|
-
readOnly: false,
|
|
211
|
-
approvalPolicy: flagEnabled(argv, 'disableApproval') ? 'auto' : defaults.approvalMode,
|
|
212
|
-
cert: false,
|
|
213
|
-
sessionMaxPerConnection: defaults.sessionMaxPerConnection,
|
|
214
|
-
sessionIdleTimeoutMs: defaults.sessionIdleTimeoutMs,
|
|
215
|
-
sessionBackgroundMaxMs: defaults.sessionBackgroundMaxMs,
|
|
216
|
-
commandQuotaPerDay: defaults.commandQuotaPerDay,
|
|
217
|
-
};
|
|
218
|
-
return { defaults, profiles: [profile] };
|
|
219
|
-
}
|
|
11
|
+
import { parseArgv, buildAppConfig, checkRemovedFlags, flagEnabled, resolveHostKeyMode, } from './cli.js';
|
|
220
12
|
async function main() {
|
|
221
13
|
const argv = parseArgv();
|
|
222
14
|
checkRemovedFlags(argv);
|
|
@@ -342,4 +134,3 @@ if (isCliEnabled || isTestMode) {
|
|
|
342
134
|
process.exit(reportFatal(error));
|
|
343
135
|
});
|
|
344
136
|
}
|
|
345
|
-
export { parseArgv, buildAppConfig, checkRemovedFlags, parseMaxChars, flagEnabled, resolveHostKeyMode };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { getProfile as getConfigProfile } from '../config/loader.js';
|
|
1
|
+
import { getProfile as getConfigProfile, getConfigPath } from '../config/loader.js';
|
|
2
|
+
import { UnconfiguredError } from '../errors.js';
|
|
2
3
|
import { resolveCredentials } from '../config/credential-resolver.js';
|
|
3
4
|
import { SSHConnection } from './connection.js';
|
|
4
5
|
export class ConnectionRegistry {
|
|
@@ -69,7 +70,25 @@ export class ConnectionRegistry {
|
|
|
69
70
|
const profile = this.getProfile(profileName);
|
|
70
71
|
return this.connections.get(profile.name);
|
|
71
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Refuse everything while nothing is configured.
|
|
75
|
+
*
|
|
76
|
+
* The server now starts with `profiles: []` so a directory or a client's "add server"
|
|
77
|
+
* flow can read `tools/list` (see cli.ts). This is where that state stops being
|
|
78
|
+
* readable and starts being refused, and it sits here rather than in the loader's
|
|
79
|
+
* `getProfile` for one measured reason: the loader's empty check can only live in the
|
|
80
|
+
* no-profile-named branch, so a client calling `run-command` with `profile: "prod"`
|
|
81
|
+
* against an unconfigured server got `Profile "prod" not found` — a message telling the
|
|
82
|
+
* operator they mistyped a name when in fact they have no config at all, and omitting
|
|
83
|
+
* the config path this whole change exists to deliver. Checking before the lookup
|
|
84
|
+
* covers both.
|
|
85
|
+
*/
|
|
86
|
+
assertConfigured() {
|
|
87
|
+
if (this.config.profiles.length === 0)
|
|
88
|
+
throw new UnconfiguredError(getConfigPath());
|
|
89
|
+
}
|
|
72
90
|
getProfile(profileName) {
|
|
91
|
+
this.assertConfigured();
|
|
73
92
|
return getConfigProfile(this.config, profileName);
|
|
74
93
|
}
|
|
75
94
|
listConnections() {
|
|
@@ -42,7 +42,6 @@ export function registerCommandTools({ server, registry, policy }, pipeline) {
|
|
|
42
42
|
command: z.string().describe('Command to execute with sudo'),
|
|
43
43
|
profile: z.string().optional().describe('Profile name'),
|
|
44
44
|
}, { destructiveHint: true }, async ({ command, profile }, extra) => {
|
|
45
|
-
const profileName = defaultProfileName(profile);
|
|
46
45
|
return runAudited(command, {
|
|
47
46
|
toolName: 'privileged-command',
|
|
48
47
|
failureClass: 'privileged',
|
|
@@ -51,7 +50,11 @@ export function registerCommandTools({ server, registry, policy }, pipeline) {
|
|
|
51
50
|
// Evaluate the bare command too: the sudo wrapper would otherwise
|
|
52
51
|
// hide a forbidden command inside a quoted `sh -c` argument.
|
|
53
52
|
preCheck: (cleanCmd) => {
|
|
54
|
-
|
|
53
|
+
// Resolved here rather than before `runAudited`: `defaultProfileName` reaches
|
|
54
|
+
// `registry.getProfile()`, which refuses on an unconfigured server, and outside
|
|
55
|
+
// the pipeline's try that refusal escaped without an audit record. `preCheck`
|
|
56
|
+
// runs inside it.
|
|
57
|
+
const rawEval = policy.evaluate(cleanCmd, registry.getProfile(defaultProfileName(profile)), 'privileged-command');
|
|
55
58
|
if (rawEval.decision === 'deny') {
|
|
56
59
|
throw new PolicyRefusedError(`POLICY_DENIED: ${rawEval.reason || 'Command not allowed'}`, rawEval);
|
|
57
60
|
}
|
package/build/tools/pipeline.js
CHANGED
|
@@ -75,7 +75,12 @@ export function createPipeline({ server, registry, policy, audit, approvalGrantT
|
|
|
75
75
|
await audit.record({
|
|
76
76
|
mcpRequestId: ctx.requestId,
|
|
77
77
|
profile: profileName,
|
|
78
|
-
|
|
78
|
+
// Looked up without throwing. This used to be `registry.getProfile(profileName).user`,
|
|
79
|
+
// which throws for exactly the reason the tool call is being audited as a failure —
|
|
80
|
+
// an unconfigured server, or a profile name that does not exist — and `auditFailure`
|
|
81
|
+
// swallows that by design. The record the operator most needs was the one record that
|
|
82
|
+
// could not be written.
|
|
83
|
+
user: profileUser(profileName),
|
|
79
84
|
command,
|
|
80
85
|
commandClass: evaluation.commandClass,
|
|
81
86
|
binary: evaluation.binary,
|
|
@@ -113,6 +118,10 @@ export function createPipeline({ server, registry, policy, audit, approvalGrantT
|
|
|
113
118
|
function defaultProfileName(profile) {
|
|
114
119
|
return profile || registry.getProfile().name;
|
|
115
120
|
}
|
|
121
|
+
/** The profile's SSH user, or a placeholder — never throws, so an audit write cannot fail. */
|
|
122
|
+
function profileUser(profileName) {
|
|
123
|
+
return registry.listAllProfiles().find((p) => p.name === profileName)?.user ?? '(unresolved)';
|
|
124
|
+
}
|
|
116
125
|
function makeProgressSender(extra) {
|
|
117
126
|
const token = extra?._meta?.progressToken;
|
|
118
127
|
if (token === undefined)
|
|
@@ -135,15 +144,21 @@ export function createPipeline({ server, registry, policy, audit, approvalGrantT
|
|
|
135
144
|
*/
|
|
136
145
|
async function runAudited(command, opts, run) {
|
|
137
146
|
const ctx = makeCtx(opts.extra, opts.profile, opts.session);
|
|
138
|
-
const profileName = defaultProfileName(opts.profile);
|
|
139
|
-
const profile = registry.getProfile(profileName);
|
|
140
147
|
const onProgress = makeProgressSender(opts.extra);
|
|
141
148
|
const abortSignal = opts.extra?.signal;
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
149
|
+
// Named before the try because `auditFailure` needs something to file the record
|
|
150
|
+
// under even when resolution itself is what failed. Reassigned to the resolved name as
|
|
151
|
+
// the first statement inside, so the quota key and the audit record are unchanged on
|
|
152
|
+
// every path that gets that far.
|
|
153
|
+
let profileName = opts.profile ?? '(default)';
|
|
154
|
+
// Profile resolution and sanitization both run inside the try, so a rejected call
|
|
155
|
+
// still leaves an audit trail — a client probing with malformed payloads, or probing
|
|
156
|
+
// a server that has no config at all, used to leave none. The unconfigured refusal
|
|
157
|
+
// reaches this the same way a bad command does.
|
|
145
158
|
const state = { command };
|
|
146
159
|
try {
|
|
160
|
+
profileName = defaultProfileName(opts.profile);
|
|
161
|
+
const profile = registry.getProfile(profileName);
|
|
147
162
|
let effective = opts.synthetic ? command : sanitizeCommand(command, profile.maxChars);
|
|
148
163
|
state.command = effective;
|
|
149
164
|
opts.preCheck?.(effective);
|
|
@@ -38,6 +38,14 @@ export function registerSessionTools({ server, registry }, pipeline) {
|
|
|
38
38
|
const { runAudited, resolveConn, auditResult, auditFailure, makeCtx, defaultProfileName } = pipeline;
|
|
39
39
|
// ─── list-connections ──────────────────────────────────────────────────
|
|
40
40
|
server.tool('list-connections', D["list-connections"], {}, { readOnlyHint: true }, async () => {
|
|
41
|
+
// The one tool that reads only `listAllProfiles()` and so never touches
|
|
42
|
+
// `getProfile`. Without this it answered an unconfigured server with a
|
|
43
|
+
// zero-length success — on the tool whose own description sends an agent here
|
|
44
|
+
// first ("discover available hosts before running commands"), and on the one
|
|
45
|
+
// channel where the startup stderr warning is not visible. The agent saw "no
|
|
46
|
+
// hosts" rather than "not configured". Refusing here is what makes the claim in
|
|
47
|
+
// the README, the changeset and the startup warning true of all eleven tools.
|
|
48
|
+
registry.assertConfigured();
|
|
41
49
|
const connections = registry.listConnections();
|
|
42
50
|
const profiles = registry.listAllProfiles();
|
|
43
51
|
const lines = profiles.map((p) => {
|
|
@@ -104,19 +112,27 @@ export function registerSessionTools({ server, registry }, pipeline) {
|
|
|
104
112
|
profile: z.string().optional().describe('Profile name'),
|
|
105
113
|
}, { destructiveHint: true }, async ({ name, profile }, extra) => {
|
|
106
114
|
const cleanName = sanitizeSessionName(name);
|
|
107
|
-
|
|
115
|
+
// Both the profile name and the connection are resolved inside the try below. They
|
|
116
|
+
// used to be resolved above it, so a refusal — an unconfigured server, an unknown
|
|
117
|
+
// profile name — escaped before `auditFailure` could record that a release was
|
|
118
|
+
// attempted. The placeholders are what the record is filed under if resolution is
|
|
119
|
+
// itself what failed.
|
|
120
|
+
let profileName = profile ?? '(default)';
|
|
108
121
|
const ctx = makeCtx(extra, profileName, cleanName);
|
|
109
|
-
|
|
110
|
-
// Recorded with the session's kind, so a remote SIGKILL is greppable in the log and
|
|
111
|
-
// distinguishable from ending a local shell — `open-session` encodes its type the
|
|
112
|
-
// same way.
|
|
113
|
-
// Through `toInfo()` rather than an `instanceof` check: the type is public API, and a
|
|
114
|
-
// tool handler reaching for a constructor identity is the layer leak this repo's own
|
|
115
|
-
// rules call out.
|
|
116
|
-
const kind = conn.getSession(cleanName)?.toInfo().type ?? 'unknown';
|
|
117
|
-
const command = `session:close ${kind} ${cleanName}`;
|
|
122
|
+
let command = `session:close unknown ${cleanName}`;
|
|
118
123
|
const startedAt = Date.now();
|
|
119
124
|
try {
|
|
125
|
+
profileName = defaultProfileName(profile);
|
|
126
|
+
ctx.profile = profileName;
|
|
127
|
+
const conn = await resolveConn(profile);
|
|
128
|
+
// Recorded with the session's kind, so a remote SIGKILL is greppable in the log and
|
|
129
|
+
// distinguishable from ending a local shell — `open-session` encodes its type the
|
|
130
|
+
// same way.
|
|
131
|
+
// Through `toInfo()` rather than an `instanceof` check: the type is public API, and a
|
|
132
|
+
// tool handler reaching for a constructor identity is the layer leak this repo's own
|
|
133
|
+
// rules call out.
|
|
134
|
+
const kind = conn.getSession(cleanName)?.toInfo().type ?? 'unknown';
|
|
135
|
+
command = `session:close ${kind} ${cleanName}`;
|
|
120
136
|
const outcome = await conn.closeSession(cleanName);
|
|
121
137
|
await auditResult(ctx, profileName, command, RELEASE, {
|
|
122
138
|
stdout: '',
|
package/build/transport/http.js
CHANGED
|
@@ -98,7 +98,8 @@ export async function startHttpServer(createMcpServer, opts) {
|
|
|
98
98
|
const httpServer = createServer(async (req, res) => {
|
|
99
99
|
const url = new URL(req.url || '/', `http://${req.headers.host}`);
|
|
100
100
|
// Liveness probes are conventionally unauthenticated, and the README lists
|
|
101
|
-
// /health without an auth caveat. It exposes
|
|
101
|
+
// /health without an auth caveat. It exposes "process is up" and whether any profile
|
|
102
|
+
// is configured — see the handler below for why that second bit is not a secret.
|
|
102
103
|
const isHealthProbe = req.method === 'GET' && url.pathname === '/health';
|
|
103
104
|
if (!isHealthProbe) {
|
|
104
105
|
const auth = req.headers.authorization || '';
|
|
@@ -210,8 +211,18 @@ export async function startHttpServer(createMcpServer, opts) {
|
|
|
210
211
|
return;
|
|
211
212
|
}
|
|
212
213
|
if (req.method === 'GET' && url.pathname === '/health') {
|
|
214
|
+
// `configured` is liveness telling the truth about readiness. Since the server
|
|
215
|
+
// learned to start with nothing configured, an HTTP deployment whose config bind
|
|
216
|
+
// mount silently did not attach comes up, binds the port, and fails 100% of tool
|
|
217
|
+
// calls — while this probe said `healthy: true` and the explaining stderr warning
|
|
218
|
+
// scrolled past at boot. The status stays 200 so an existing probe does not start
|
|
219
|
+
// failing on upgrade; the field is what an operator can alert on.
|
|
220
|
+
//
|
|
221
|
+
// It widens what this unauthenticated route discloses by exactly one bit, and that
|
|
222
|
+
// bit is worth little to anyone: a server with no profile is one that cannot reach
|
|
223
|
+
// any host. `/status` carries the profile list itself and stays behind the token.
|
|
213
224
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
214
|
-
res.end(JSON.stringify({ healthy: true }));
|
|
225
|
+
res.end(JSON.stringify({ healthy: true, configured: registry.listAllProfiles().length > 0 }));
|
|
215
226
|
return;
|
|
216
227
|
}
|
|
217
228
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ssh-mcp",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.4.0",
|
|
5
5
|
"description": "MCP server exposing policy-gated, audited SSH access for Linux and Windows hosts via the Model Context Protocol.",
|
|
6
|
+
"mcpName": "io.github.tufantunc/ssh-mcp",
|
|
6
7
|
"type": "module",
|
|
7
8
|
"bin": {
|
|
8
9
|
"ssh-mcp": "build/index.js"
|
|
@@ -13,7 +14,7 @@
|
|
|
13
14
|
"typecheck": "tsc --noEmit -p tsconfig.test.json",
|
|
14
15
|
"inspect": "npx @modelcontextprotocol/inspector node build/index.js",
|
|
15
16
|
"changeset": "changeset",
|
|
16
|
-
"version": "changeset version",
|
|
17
|
+
"version": "changeset version && node scripts/sync-server-json.mjs",
|
|
17
18
|
"test": "cross-env SSH_MCP_DISABLE_MAIN=1 vitest --run",
|
|
18
19
|
"test:unit": "cross-env SSH_MCP_DISABLE_MAIN=1 vitest --run test/unit/ test/property/",
|
|
19
20
|
"test:integration": "cross-env SSH_MCP_DISABLE_MAIN=1 vitest --run test/integration/",
|