ssh-mcp 2.0.3 → 2.1.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 +20 -0
- package/build/index.js +23 -1
- package/build/policy/classifier.js +19 -1
- package/build/policy/engine.js +35 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -244,6 +244,25 @@ without it the tier is guessed from the profile name (`prod`/`staging`/`dev`,
|
|
|
244
244
|
the strictest tier. A production host named `web-01` is therefore treated as
|
|
245
245
|
production rather than silently getting dev permissions.
|
|
246
246
|
|
|
247
|
+
Note what this means for `sudo`: **`admin` has no `privileged` on `prod`**, so
|
|
248
|
+
`privileged-command` is refused there by design — including on a quick-start
|
|
249
|
+
profile, which has no name to infer from and therefore lands on `prod`. If the
|
|
250
|
+
host is not production, say so:
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
npx ssh-mcp --host=10.0.0.5 --user=deploy --group=dev
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
```toml
|
|
257
|
+
[[profiles]]
|
|
258
|
+
name = "build-box"
|
|
259
|
+
group = "dev"
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Leaving a real production host on `prod` and granting sudo there is a policy
|
|
263
|
+
change, not a flag: add `privileged` to `admin.prod` in the config's
|
|
264
|
+
`roleBindings`.
|
|
265
|
+
|
|
247
266
|
### Command Classification
|
|
248
267
|
|
|
249
268
|
Every command is classified before execution:
|
|
@@ -439,6 +458,7 @@ Secrets are **never** passed as CLI arguments.
|
|
|
439
458
|
| `--port` | 22 | Quick start: SSH port |
|
|
440
459
|
| `--key` | — | Quick start: Path to private key |
|
|
441
460
|
| `--workdir` | — | Quick start: Working directory for commands and sessions |
|
|
461
|
+
| `--group` | prod | Quick start: Policy tier — `prod`, `staging` or `dev` |
|
|
442
462
|
| `--timeout` | 60000 | Command timeout in ms |
|
|
443
463
|
| `--maxChars` | 5000 | Max command length (`none` or `0` disables the limit) |
|
|
444
464
|
| `--sessionMax` | 5 | Max concurrent sessions per connection |
|
package/build/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
4
|
import { loadConfig } from './config/loader.js';
|
|
5
5
|
import { ConnectionRegistry } from './ssh/connection-registry.js';
|
|
6
|
-
import { PolicyEngine, DEFAULT_RULES } from './policy/engine.js';
|
|
6
|
+
import { PolicyEngine, DEFAULT_RULES, HOST_GROUPS } from './policy/engine.js';
|
|
7
7
|
import { AuditStore } from './audit/store.js';
|
|
8
8
|
import { registerTools, registerResources, getToolHashes } from './tools/registry.js';
|
|
9
9
|
import { initKeychain } from './config/credential-resolver.js';
|
|
@@ -71,6 +71,27 @@ function resolveHostKeyMode(argv) {
|
|
|
71
71
|
return mode;
|
|
72
72
|
throw new Error(`Invalid --hostKeyMode=${mode}. Expected one of: tofu, strict, insecure.`);
|
|
73
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Which policy tier a CLI-configured host belongs to.
|
|
76
|
+
*
|
|
77
|
+
* Without this there was no way to say it from the command line at all. The
|
|
78
|
+
* inline profile carries no group, so it fell to the strictest tier, where the
|
|
79
|
+
* `admin` role has no `privileged` — meaning `sudo` could never run for anyone
|
|
80
|
+
* who had not written a config file (#91).
|
|
81
|
+
*
|
|
82
|
+
* The default stays `prod`. Guessing an unknown host is production is the safe
|
|
83
|
+
* direction; what was missing was a way to correct the guess.
|
|
84
|
+
*/
|
|
85
|
+
export function resolveHostGroup(argv) {
|
|
86
|
+
const group = argv.group;
|
|
87
|
+
if (group === null || group === undefined)
|
|
88
|
+
return 'prod';
|
|
89
|
+
if (HOST_GROUPS.includes(group))
|
|
90
|
+
return group;
|
|
91
|
+
// Falling through would silently apply the prod bindings to a typo, and the
|
|
92
|
+
// operator would read the refusal as policy rather than as their own slip.
|
|
93
|
+
throw new Error(`Invalid --group=${group}. Expected one of: ${HOST_GROUPS.join(', ')}.`);
|
|
94
|
+
}
|
|
74
95
|
async function buildAppConfig(argv) {
|
|
75
96
|
if (argv.config) {
|
|
76
97
|
return loadConfig(argv.config);
|
|
@@ -111,6 +132,7 @@ async function buildAppConfig(argv) {
|
|
|
111
132
|
maxChars: defaults.commandMaxChars,
|
|
112
133
|
maxOutputBytes: defaults.commandMaxOutputBytes,
|
|
113
134
|
role: 'admin',
|
|
135
|
+
group: resolveHostGroup(argv),
|
|
114
136
|
readOnly: false,
|
|
115
137
|
approvalPolicy: argv.disableApproval ? 'auto' : defaults.approvalMode,
|
|
116
138
|
cert: false,
|
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anything through which the shell can start a second command.
|
|
3
|
+
*
|
|
4
|
+
* This gate decides whether an allowlisted binary counts as read-only, and the
|
|
5
|
+
* allowlist only vouches for the binary — never for what a shell might run
|
|
6
|
+
* beside it. It used to test /[>;|]/, which let `ls $(touch /tmp/x)` through as
|
|
7
|
+
* read-only: `ls` is allowlisted, no listed metacharacter appears, and the
|
|
8
|
+
* remote shell expands the substitution and runs the inner command anyway
|
|
9
|
+
* (GHSA-r8hm-vpm8-cfh6).
|
|
10
|
+
*
|
|
11
|
+
* Listing every dangerous construct is a losing game — `$()`, backticks,
|
|
12
|
+
* `<(...)`, `${x:=...}`, `&&`, a bare newline — so this refuses every character
|
|
13
|
+
* with syntactic meaning to the shell instead, and `$` wholesale rather than
|
|
14
|
+
* just `$(`. The cost is that `echo $HOME` is no longer classified read-only.
|
|
15
|
+
* That is the right trade for the tool whose entire promise is that it cannot
|
|
16
|
+
* write: run-command still accepts it under policy.
|
|
17
|
+
*/
|
|
18
|
+
const SHELL_CONTROL_CHARS = /[;&|<>`$(){}\n\r]/;
|
|
1
19
|
const READ_ONLY_ALLOWLIST = new Set([
|
|
2
20
|
'ls', 'cat', 'grep', 'find', 'stat', 'df', 'du', 'head', 'tail', 'wc',
|
|
3
21
|
'ps', 'uname', 'uptime', 'hostname', 'id', 'who', 'whoami', 'date',
|
|
@@ -98,7 +116,7 @@ export function classifyCommand(command) {
|
|
|
98
116
|
}
|
|
99
117
|
const twoWordPrefix = fullCommand.split(/\s+/).slice(0, 2).join(' ');
|
|
100
118
|
if (READ_ONLY_ALLOWLIST.has(binary) || READ_ONLY_ALLOWLIST.has(twoWordPrefix)) {
|
|
101
|
-
if (
|
|
119
|
+
if (SHELL_CONTROL_CHARS.test(trimmed)) {
|
|
102
120
|
return { binary, fullCommand, class: 'safe' };
|
|
103
121
|
}
|
|
104
122
|
return { binary, fullCommand, class: 'read-only' };
|
package/build/policy/engine.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { classifyCommand, FORBIDDEN_PATTERNS } from './classifier.js';
|
|
2
2
|
/** Tiers, most restrictive first. Unknown/unset tiers resolve to the first. */
|
|
3
|
-
const HOST_GROUPS = ['prod', 'staging', 'dev'];
|
|
3
|
+
export const HOST_GROUPS = ['prod', 'staging', 'dev'];
|
|
4
4
|
export const DEFAULT_RULES = {
|
|
5
5
|
roleBindings: {
|
|
6
6
|
viewer: {
|
|
@@ -66,7 +66,7 @@ export class PolicyEngine {
|
|
|
66
66
|
commandClass: parsed.class,
|
|
67
67
|
binary: parsed.binary,
|
|
68
68
|
ruleId: 'role-binding',
|
|
69
|
-
reason:
|
|
69
|
+
reason: this.explainRoleDenial(profile, parsed.class),
|
|
70
70
|
};
|
|
71
71
|
}
|
|
72
72
|
if (approvalRequired) {
|
|
@@ -152,6 +152,39 @@ export class PolicyEngine {
|
|
|
152
152
|
console.error(`POLICY WARNING: OPA evaluation unavailable (${cause}). ` +
|
|
153
153
|
'Falling back to local policy — commands OPA would deny may now be allowed.');
|
|
154
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* Say which three things produced the refusal, not one of them.
|
|
157
|
+
*
|
|
158
|
+
* The message used to read `Role "admin" cannot run "privileged" commands`,
|
|
159
|
+
* which names the role and the class but not the host group — and the group
|
|
160
|
+
* is usually what decided, because `admin` has `privileged` on staging and
|
|
161
|
+
* dev but not on prod. A reader concludes their role is simply incapable of
|
|
162
|
+
* sudo and goes looking for a bigger role that does not exist (#91).
|
|
163
|
+
*
|
|
164
|
+
* The inferred case matters most: a profile with no group set lands on the
|
|
165
|
+
* strictest tier, so the reason is something the operator never wrote down
|
|
166
|
+
* anywhere and cannot see.
|
|
167
|
+
*/
|
|
168
|
+
explainRoleDenial(profile, commandClass) {
|
|
169
|
+
if (profile.readOnly) {
|
|
170
|
+
return `Profile "${profile.name}" is read-only, so "${commandClass}" commands are refused. ` +
|
|
171
|
+
`Clear readOnly on the profile to allow them.`;
|
|
172
|
+
}
|
|
173
|
+
const group = this.resolveHostGroup(profile);
|
|
174
|
+
const inferred = !profile.group;
|
|
175
|
+
const allowed = this.getAllowedClasses(profile).join(', ');
|
|
176
|
+
let reason = `Role "${profile.role}" on host group "${group}" cannot run "${commandClass}" commands ` +
|
|
177
|
+
`(allowed: ${allowed}).`;
|
|
178
|
+
if (inferred) {
|
|
179
|
+
reason +=
|
|
180
|
+
` No group is set for profile "${profile.name}", so it defaulted to the most restrictive tier.` +
|
|
181
|
+
` Set group = "dev" or "staging" on the profile, or pass --group, if this host is not production.`;
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
reason += ` Change the profile's group, or grant the class to this role in the policy's roleBindings.`;
|
|
185
|
+
}
|
|
186
|
+
return reason;
|
|
187
|
+
}
|
|
155
188
|
getAllowedClasses(profile) {
|
|
156
189
|
if (profile.readOnly) {
|
|
157
190
|
return ['read-only'];
|
package/package.json
CHANGED