ssh-mcp 2.0.4 → 2.2.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 +106 -5
- package/build/config/loader.js +8 -2
- package/build/config/schema.js +35 -1
- package/build/index.js +24 -2
- package/build/policy/engine.js +191 -27
- package/package.json +6 -9
package/README.md
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/ssh-mcp)
|
|
4
4
|
[](https://www.npmjs.com/package/ssh-mcp)
|
|
5
5
|
[](https://github.com/tufantunc/ssh-mcp/actions/workflows/ci.yml)
|
|
6
|
-
[](https://codecov.io/gh/tufantunc/ssh-mcp)
|
|
7
7
|
[](./LICENSE)
|
|
8
8
|
[](https://github.com/tufantunc/ssh-mcp/issues)
|
|
9
9
|
|
|
10
10
|
**SSH MCP Server** is a security-first Model Context Protocol server that gives LLM agents controlled SSH access to remote hosts — with command classification, policy-based authorization, human-in-the-loop approval, and full audit logging.
|
|
11
11
|
|
|
12
|
-
> **
|
|
12
|
+
> **The risk this server exists to manage.** Giving an LLM shell access on a remote host puts private data, untrusted input and network egress in one place — Simon Willison's ["lethal trifecta"](https://simonwillison.net/2025/Jun/16/the-lethal-trifecta/). Prompt injection has no general fix, so ssh-mcp assumes any command may be attacker-influenced: it classifies before executing, authorizes against a role × host-group matrix, gates destructive work behind approval, and records the decision either way. That narrows the blast radius; it does not remove the risk. Two things stay yours: **never point it at a root account**, and **never set `auto` approval on a production profile**. [SECURITY.md](./SECURITY.md) has the full threat model.
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
@@ -166,7 +166,7 @@ auth = "agent" # agent | key | password | keychain
|
|
|
166
166
|
keyRef = "~/.ssh/id_ed25519" # for auth=key
|
|
167
167
|
keychainEntry = "ssh-mcp/prod" # for auth=keychain (requires @napi-rs/keyring)
|
|
168
168
|
via = "bastion" # ProxyJump — route through bastion profile
|
|
169
|
-
group = "prod" # Policy tier: prod | staging | dev
|
|
169
|
+
group = "prod" # Policy tier: prod | staging | dev, or your own (see [policy])
|
|
170
170
|
workdir = "/var/www"
|
|
171
171
|
trustedHostKey = "SHA256:..." # Pin host key (optional)
|
|
172
172
|
tty = false
|
|
@@ -177,8 +177,24 @@ cert = false # SSH CA cert auth — auto-detects keyRef-c
|
|
|
177
177
|
sessionMaxPerConnection = 3 # per-profile override
|
|
178
178
|
sessionIdleTimeoutMs = 300000 # stricter for prod
|
|
179
179
|
commandQuotaPerDay = 200 # per-profile override
|
|
180
|
+
|
|
181
|
+
# Optional. Merged over the built-in role matrix; see "Policy Engine" below.
|
|
182
|
+
# roleBindings is keyed by role and then by tier, so the block below changes
|
|
183
|
+
# operator on prod and leaves operator's other tiers, and viewer and admin,
|
|
184
|
+
# on their defaults.
|
|
185
|
+
[policy]
|
|
186
|
+
denylist = ["^terraform\\s+destroy"]
|
|
187
|
+
|
|
188
|
+
[policy.roleBindings.operator]
|
|
189
|
+
prod = ["read-only", "safe", "destructive"]
|
|
180
190
|
```
|
|
181
191
|
|
|
192
|
+
Unknown sections and keys are a startup error, not a warning, so a typo cannot
|
|
193
|
+
leave you running defaults you thought you had overridden. That extends to role
|
|
194
|
+
and tier names: every one you write under `[policy.roleBindings]` has to be
|
|
195
|
+
reachable by some profile, and every profile's role and tier has to resolve to
|
|
196
|
+
real bindings. Both directions are checked at startup.
|
|
197
|
+
|
|
182
198
|
### ProxyJump (Bastion)
|
|
183
199
|
|
|
184
200
|
Reach internal hosts behind a bastion/jump server. The `via` field specifies a profile name to tunnel through:
|
|
@@ -244,6 +260,85 @@ without it the tier is guessed from the profile name (`prod`/`staging`/`dev`,
|
|
|
244
260
|
the strictest tier. A production host named `web-01` is therefore treated as
|
|
245
261
|
production rather than silently getting dev permissions.
|
|
246
262
|
|
|
263
|
+
Note what this means for `sudo`: **`admin` has no `privileged` on `prod`**, so
|
|
264
|
+
`privileged-command` is refused there by design — including on a quick-start
|
|
265
|
+
profile, which has no name to infer from and therefore lands on `prod`. If the
|
|
266
|
+
host is not production, say so:
|
|
267
|
+
|
|
268
|
+
```bash
|
|
269
|
+
npx ssh-mcp --host=10.0.0.5 --user=deploy --group=dev
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
```toml
|
|
273
|
+
[[profiles]]
|
|
274
|
+
name = "build-box"
|
|
275
|
+
group = "dev"
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
### Configuring the matrix
|
|
279
|
+
|
|
280
|
+
The table above is the default, not a limit. An optional `[policy]` section is
|
|
281
|
+
merged over it at startup, so granting sudo on a host you have honestly
|
|
282
|
+
labelled `prod` is a reviewable line in a config file rather than a relabelling:
|
|
283
|
+
|
|
284
|
+
```toml
|
|
285
|
+
[policy.roleBindings.admin]
|
|
286
|
+
prod = ["read-only", "safe", "destructive", "privileged"]
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
The merge is at role *and* tier depth. That block changes `admin` on `prod` and
|
|
290
|
+
nothing else: `admin` on `staging` and `dev` keep their defaults, and `viewer`
|
|
291
|
+
and `operator` are untouched. Roles and tiers the defaults have never heard of
|
|
292
|
+
are added rather than rejected, which is what makes a custom `group` resolve to
|
|
293
|
+
real bindings instead of falling back to the strictest tier:
|
|
294
|
+
|
|
295
|
+
```toml
|
|
296
|
+
[[profiles]]
|
|
297
|
+
name = "build-box"
|
|
298
|
+
role = "admin"
|
|
299
|
+
group = "tier-1"
|
|
300
|
+
|
|
301
|
+
[policy.roleBindings.admin]
|
|
302
|
+
"tier-1" = ["read-only", "safe", "destructive"]
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
Extra deny patterns live in the same section, and are applied on top of the
|
|
306
|
+
never-allowed list rather than replacing it:
|
|
307
|
+
|
|
308
|
+
```toml
|
|
309
|
+
[policy]
|
|
310
|
+
denylist = ["^terraform\\s+destroy"]
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
Because role and tier names are free strings, nothing in the merge itself can
|
|
314
|
+
tell a new custom role from a misspelling of an existing one. A cross-check at
|
|
315
|
+
startup does, and these all fail there rather than at the point of use:
|
|
316
|
+
|
|
317
|
+
- a command class outside `read-only | safe | destructive | privileged`, so a
|
|
318
|
+
`priviledged` typo cannot parse into a grant of nothing and then read as a
|
|
319
|
+
policy decision when a command is refused;
|
|
320
|
+
- any unrecognised section or key anywhere in the config, so a block the parser
|
|
321
|
+
does not understand is an error rather than a clean startup with none of the
|
|
322
|
+
behaviour you configured;
|
|
323
|
+
- a role or tier under `[policy.roleBindings]` that no profile uses, so
|
|
324
|
+
`[policy.roleBindings.operater]` cannot merge in as a fourth role while the
|
|
325
|
+
profiles you meant to restrict keep running on defaults;
|
|
326
|
+
- a profile whose `role` has no bindings, or whose tier has none under that
|
|
327
|
+
role, so a host cannot end up on `read-only` for a reason nobody wrote down.
|
|
328
|
+
|
|
329
|
+
The last one covers the tier you did not set as well as the one you did. A
|
|
330
|
+
profile with no `group` still resolves to one by name, and that inferred tier
|
|
331
|
+
has to exist under the profile's role like any other.
|
|
332
|
+
|
|
333
|
+
A tier with no bindings for a role grants `read-only`, and never another tier's
|
|
334
|
+
classes. There is no fallback between tiers: while the matrix was compiled in,
|
|
335
|
+
falling back to `prod` meant falling back to a role's strictest cell, but a
|
|
336
|
+
`[policy]` block can write that cell now.
|
|
337
|
+
|
|
338
|
+
An OPA sidecar is not an alternative route to the same grant. OPA is consulted
|
|
339
|
+
only for commands the local policy already allows, so it can refuse more but
|
|
340
|
+
never widen. Widening happens here or not at all.
|
|
341
|
+
|
|
247
342
|
### Command Classification
|
|
248
343
|
|
|
249
344
|
Every command is classified before execution:
|
|
@@ -304,7 +399,11 @@ For organizations that standardize on Open Policy Agent / Rego:
|
|
|
304
399
|
ssh-mcp --opaUrl=http://localhost:8181
|
|
305
400
|
```
|
|
306
401
|
|
|
307
|
-
When `--opaUrl` is set,
|
|
402
|
+
When `--opaUrl` is set, commands the built-in engine allows are additionally
|
|
403
|
+
evaluated by OPA. **OPA can only narrow.** A command the built-in engine has
|
|
404
|
+
already denied returns that denial without OPA being consulted at all, so a
|
|
405
|
+
sidecar answering `allow` cannot grant a class the role bindings withhold. To
|
|
406
|
+
widen, edit `[policy]`. The request shape follows the AuthZEN Access Evaluation contract:
|
|
308
407
|
|
|
309
408
|
```json
|
|
310
409
|
{
|
|
@@ -325,7 +424,8 @@ package ssh.mcp
|
|
|
325
424
|
|
|
326
425
|
default allow := false
|
|
327
426
|
|
|
328
|
-
# Admins
|
|
427
|
+
# Admins pass the OPA gate on dev hosts. The built-in policy still applies on
|
|
428
|
+
# top: this widens nothing that the role bindings withhold.
|
|
329
429
|
allow if {
|
|
330
430
|
input.subject.role == "admin"
|
|
331
431
|
startswith(input.subject.profile, "dev")
|
|
@@ -439,6 +539,7 @@ Secrets are **never** passed as CLI arguments.
|
|
|
439
539
|
| `--port` | 22 | Quick start: SSH port |
|
|
440
540
|
| `--key` | — | Quick start: Path to private key |
|
|
441
541
|
| `--workdir` | — | Quick start: Working directory for commands and sessions |
|
|
542
|
+
| `--group` | prod | Quick start: Policy tier — `prod`, `staging` or `dev` |
|
|
442
543
|
| `--timeout` | 60000 | Command timeout in ms |
|
|
443
544
|
| `--maxChars` | 5000 | Max command length (`none` or `0` disables the limit) |
|
|
444
545
|
| `--sessionMax` | 5 | Max concurrent sessions per connection |
|
package/build/config/loader.js
CHANGED
|
@@ -57,8 +57,11 @@ export async function loadConfig(customPath) {
|
|
|
57
57
|
}
|
|
58
58
|
const result = configSchema.safeParse(parsed);
|
|
59
59
|
if (!result.success) {
|
|
60
|
+
// A root-level issue has an empty path, which used to render as a bare
|
|
61
|
+
// colon. Unreachable while the root schema accepted anything; an unknown
|
|
62
|
+
// top-level section reaches it now.
|
|
60
63
|
const issues = result.error.issues
|
|
61
|
-
.map((i) => ` ${i.path.join('.')}: ${i.message}`)
|
|
64
|
+
.map((i) => ` ${i.path.length ? i.path.join('.') : '(root)'}: ${i.message}`)
|
|
62
65
|
.join('\n');
|
|
63
66
|
throw new Error(`Config validation error:\n${issues}`);
|
|
64
67
|
}
|
|
@@ -86,7 +89,10 @@ function normalizeConfig(raw) {
|
|
|
86
89
|
sessionBackgroundMaxMs: p.sessionBackgroundMaxMs ?? defaults.sessionBackgroundMaxMs,
|
|
87
90
|
commandQuotaPerDay: p.commandQuotaPerDay ?? defaults.commandQuotaPerDay,
|
|
88
91
|
}));
|
|
89
|
-
|
|
92
|
+
// Passed through untouched. mergePolicyRules() owns the layering over
|
|
93
|
+
// DEFAULT_RULES, so the loader has no policy semantics of its own to get
|
|
94
|
+
// wrong.
|
|
95
|
+
return { defaults, profiles, policy: raw.policy };
|
|
90
96
|
}
|
|
91
97
|
export function getProfile(config, name) {
|
|
92
98
|
const targetName = name || config.defaults.defaultProfile;
|
package/build/config/schema.js
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
const authMethodSchema = z.enum(['agent', 'key', 'password', 'keychain']);
|
|
3
3
|
const approvalModeSchema = z.enum(['auto', 'ask-destructive', 'ask-all', 'deny']);
|
|
4
|
+
const commandClassSchema = z.enum(['read-only', 'safe', 'destructive', 'privileged']);
|
|
5
|
+
/**
|
|
6
|
+
* Role and tier names, which are free strings so operators can define their own.
|
|
7
|
+
*
|
|
8
|
+
* `__proto__` is excluded because merging assigns into a plain object, and that
|
|
9
|
+
* assignment lands on the prototype instead of creating a key: a role by that
|
|
10
|
+
* name would be accepted and then not exist. `constructor` and `prototype` are
|
|
11
|
+
* excluded alongside it rather than reasoned about case by case.
|
|
12
|
+
*/
|
|
13
|
+
const RESERVED_BINDING_KEYS = ['__proto__', 'constructor', 'prototype'];
|
|
14
|
+
const bindingKeySchema = z.string().min(1).refine((key) => !RESERVED_BINDING_KEYS.includes(key), { message: `Reserved name (${RESERVED_BINDING_KEYS.join(', ')}). Rename the role or tier.` });
|
|
4
15
|
export const defaultsSchema = z.object({
|
|
5
16
|
defaultProfile: z.string().optional(),
|
|
6
17
|
sessionMaxPerConnection: z.number().int().positive().default(5),
|
|
@@ -47,7 +58,30 @@ export const profileSchema = z.object({
|
|
|
47
58
|
sessionBackgroundMaxMs: z.number().int().positive().optional(),
|
|
48
59
|
commandQuotaPerDay: z.number().int().nonnegative().optional(),
|
|
49
60
|
}).strict();
|
|
61
|
+
/**
|
|
62
|
+
* Overrides merged over the compiled-in DEFAULT_RULES at startup.
|
|
63
|
+
*
|
|
64
|
+
* `roleBindings` is keyed role → host group → command classes, and the group
|
|
65
|
+
* key is a free string on purpose: a config that defines its own tier makes
|
|
66
|
+
* `group = "tier-1"` on a profile resolve to that tier instead of falling back
|
|
67
|
+
* to the strictest one.
|
|
68
|
+
*
|
|
69
|
+
* Command classes are validated against the enum rather than accepted as free
|
|
70
|
+
* strings, so `priviledged` fails at load instead of parsing to a grant of
|
|
71
|
+
* nothing, which reads at runtime exactly like a policy decision.
|
|
72
|
+
*
|
|
73
|
+
* `allowlist` is deliberately absent: PolicyRules declares the field but no
|
|
74
|
+
* code reads it, so accepting it here would document a key that does nothing.
|
|
75
|
+
*/
|
|
76
|
+
export const policySchema = z.object({
|
|
77
|
+
roleBindings: z.record(bindingKeySchema, z.record(bindingKeySchema, z.array(commandClassSchema))).optional(),
|
|
78
|
+
denylist: z.array(z.string()).optional(),
|
|
79
|
+
}).strict();
|
|
80
|
+
// .strict() on the root as well: without it an unknown section (a typo, or a
|
|
81
|
+
// key from a newer version) parses cleanly and is dropped with no warning, so
|
|
82
|
+
// the operator gets a clean startup and none of the behaviour they configured.
|
|
50
83
|
export const configSchema = z.object({
|
|
51
84
|
defaults: defaultsSchema.default({}),
|
|
52
85
|
profiles: z.array(profileSchema).min(1),
|
|
53
|
-
|
|
86
|
+
policy: policySchema.optional(),
|
|
87
|
+
}).strict();
|
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,
|
|
6
|
+
import { PolicyEngine, HOST_GROUPS, resolvePolicyRules } 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,
|
|
@@ -138,7 +160,7 @@ async function main() {
|
|
|
138
160
|
await initTracing(argv.otelEndpoint, argv.otelServiceName || 'ssh-mcp');
|
|
139
161
|
}
|
|
140
162
|
const registry = new ConnectionRegistry(config, hostKeyMode);
|
|
141
|
-
const policy = new PolicyEngine(
|
|
163
|
+
const policy = new PolicyEngine(resolvePolicyRules(config.profiles, config.policy));
|
|
142
164
|
const audit = new AuditStore(undefined, entropyScan, tamperEvident);
|
|
143
165
|
if (argv.opaUrl) {
|
|
144
166
|
policy.setOpaUrl(argv.opaUrl);
|
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: {
|
|
@@ -20,6 +20,144 @@ export const DEFAULT_RULES = {
|
|
|
20
20
|
},
|
|
21
21
|
},
|
|
22
22
|
};
|
|
23
|
+
/**
|
|
24
|
+
* Layer an operator's `[policy]` section over the compiled-in defaults.
|
|
25
|
+
*
|
|
26
|
+
* The merge is at role → group depth, not role depth. Replacing a whole role's
|
|
27
|
+
* bindings from a partial table is the trap here: a config naming only
|
|
28
|
+
* `admin.prod` would otherwise wipe `admin.staging` and `admin.dev` back to
|
|
29
|
+
* nothing, widening or narrowing two tiers the operator never mentioned.
|
|
30
|
+
*
|
|
31
|
+
* Roles and groups absent from the override keep their defaults, and a role or
|
|
32
|
+
* tier the defaults have never heard of is added rather than rejected. That is
|
|
33
|
+
* what makes a custom `group = "tier-1"` resolve to real bindings.
|
|
34
|
+
*
|
|
35
|
+
* `denylist` replaces rather than appends, because the defaults carry none and
|
|
36
|
+
* FORBIDDEN_PATTERNS is applied on top by the engine regardless.
|
|
37
|
+
*
|
|
38
|
+
* With nothing to layer, `base` is returned as-is rather than copied. The
|
|
39
|
+
* result is read-only either way — PolicyEngine only reads `rules`, and the
|
|
40
|
+
* copying below exists so a *later* merge cannot reach DEFAULT_RULES through a
|
|
41
|
+
* shared tier object, not because callers are expected to mutate what they get.
|
|
42
|
+
*/
|
|
43
|
+
export function mergePolicyRules(base, override) {
|
|
44
|
+
if (!override?.roleBindings && !override?.denylist)
|
|
45
|
+
return base;
|
|
46
|
+
// Copy every tier map: assigning base's objects straight through would let a
|
|
47
|
+
// later merge mutate DEFAULT_RULES, which is a module-level singleton.
|
|
48
|
+
const roleBindings = {};
|
|
49
|
+
for (const [role, groups] of Object.entries(base.roleBindings)) {
|
|
50
|
+
roleBindings[role] = { ...groups };
|
|
51
|
+
}
|
|
52
|
+
for (const [role, groups] of Object.entries(override.roleBindings ?? {})) {
|
|
53
|
+
roleBindings[role] = { ...(roleBindings[role] ?? {}), ...groups };
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
roleBindings,
|
|
57
|
+
denylist: override.denylist ?? base.denylist,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Which tier's role binding applies to this profile.
|
|
62
|
+
*
|
|
63
|
+
* `profile.group` is authoritative. Without it we still infer from the name
|
|
64
|
+
* for convenience, but an unrecognised name resolves to the most restrictive
|
|
65
|
+
* tier: the previous default sent every unrecognised profile to `dev`, so a
|
|
66
|
+
* production host merely named "web-01" silently received the loosest
|
|
67
|
+
* permissions in the matrix.
|
|
68
|
+
*
|
|
69
|
+
* Exported rather than kept private because startup validation has to reach the
|
|
70
|
+
* same answer the engine will reach, inference included. Checking only the
|
|
71
|
+
* explicit `group` would pass every profile that never set one, which is where
|
|
72
|
+
* the widening below actually happens.
|
|
73
|
+
*/
|
|
74
|
+
export function resolveProfileGroup(profile) {
|
|
75
|
+
if (profile.group)
|
|
76
|
+
return profile.group;
|
|
77
|
+
const name = profile.name.toLowerCase();
|
|
78
|
+
if (name.includes('prod'))
|
|
79
|
+
return 'prod';
|
|
80
|
+
if (name.includes('staging'))
|
|
81
|
+
return 'staging';
|
|
82
|
+
if (/\b(dev|local|test|sandbox)\b/.test(name) || /(^|[-_])(dev|local|test|sandbox)([-_]|$)/.test(name)) {
|
|
83
|
+
return 'dev';
|
|
84
|
+
}
|
|
85
|
+
return HOST_GROUPS[0];
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Every way a config's policy can mean something other than what it says.
|
|
89
|
+
*
|
|
90
|
+
* Checked in both directions, because a silent no-op is reachable from either:
|
|
91
|
+
* a profile can name a role or tier that nothing defines, and a `[policy]`
|
|
92
|
+
* block can define a role or tier that no profile can reach. #95 was the second
|
|
93
|
+
* kind (written, parsed, dropped), and the first kind lands on `['read-only']`,
|
|
94
|
+
* which at runtime is indistinguishable from a deliberate policy decision.
|
|
95
|
+
*
|
|
96
|
+
* The `[policy]` side is checked against `override`, never the merged table:
|
|
97
|
+
* the merge carries the three compiled-in roles, and no real config references
|
|
98
|
+
* all of them.
|
|
99
|
+
*
|
|
100
|
+
* Returns every problem rather than the first, matching the config validation
|
|
101
|
+
* error in loader.ts. An operator fixing a config file wants the whole list.
|
|
102
|
+
*/
|
|
103
|
+
export function findPolicyProblems(merged, profiles, override) {
|
|
104
|
+
const problems = [];
|
|
105
|
+
for (const profile of profiles) {
|
|
106
|
+
const roleBinding = merged.roleBindings[profile.role];
|
|
107
|
+
if (!roleBinding) {
|
|
108
|
+
problems.push(`profile "${profile.name}": role "${profile.role}" has no role bindings, so it can only ever ` +
|
|
109
|
+
`run read-only commands. Define [policy.roleBindings.${profile.role}], or set role to one of: ` +
|
|
110
|
+
`${Object.keys(merged.roleBindings).join(', ')}.`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const group = resolveProfileGroup(profile);
|
|
114
|
+
if (roleBinding[group])
|
|
115
|
+
continue;
|
|
116
|
+
const tiers = Object.keys(roleBinding).join(', ');
|
|
117
|
+
problems.push(profile.group
|
|
118
|
+
? `profile "${profile.name}": role "${profile.role}" has no bindings for group "${group}". ` +
|
|
119
|
+
`Add a "${group}" key under [policy.roleBindings.${profile.role}], or set group to one of: ${tiers}.`
|
|
120
|
+
: `profile "${profile.name}": no group is set, so the tier is inferred from the name as "${group}", ` +
|
|
121
|
+
`and role "${profile.role}" has no bindings for it. Set group explicitly to one of: ${tiers}, ` +
|
|
122
|
+
`or add a "${group}" key under [policy.roleBindings.${profile.role}].`);
|
|
123
|
+
}
|
|
124
|
+
const rolesInUse = [...new Set(profiles.map((p) => p.role))];
|
|
125
|
+
for (const [role, tiers] of Object.entries(override?.roleBindings ?? {})) {
|
|
126
|
+
if (!rolesInUse.includes(role)) {
|
|
127
|
+
problems.push(`[policy.roleBindings.${role}]: no profile uses role "${role}", so this block changes nothing. ` +
|
|
128
|
+
`Roles in use: ${rolesInUse.join(', ')}.`);
|
|
129
|
+
}
|
|
130
|
+
for (const tier of Object.keys(tiers)) {
|
|
131
|
+
if (HOST_GROUPS.includes(tier))
|
|
132
|
+
continue;
|
|
133
|
+
if (profiles.some((p) => p.group === tier))
|
|
134
|
+
continue;
|
|
135
|
+
problems.push(`[policy.roleBindings.${role}]: key "${tier}" matches no profile's group and is not a built-in ` +
|
|
136
|
+
`tier (${HOST_GROUPS.join(', ')}), so it changes nothing. Set group = "${tier}" on a profile, ` +
|
|
137
|
+
`or remove the key.`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return problems;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The rules main() runs on: the compiled-in defaults, the operator's overrides
|
|
144
|
+
* layered on top, and a refusal to start when the two do not describe the same
|
|
145
|
+
* world.
|
|
146
|
+
*
|
|
147
|
+
* Refusing rather than warning. A warning here goes to stderr, which for a
|
|
148
|
+
* stdio MCP server means a log file the operator is not reading, and "written,
|
|
149
|
+
* ignored, nothing said" is the whole of #95.
|
|
150
|
+
*/
|
|
151
|
+
export function resolvePolicyRules(profiles, override) {
|
|
152
|
+
const merged = mergePolicyRules(DEFAULT_RULES, override);
|
|
153
|
+
const problems = findPolicyProblems(merged, profiles, override);
|
|
154
|
+
if (problems.length > 0) {
|
|
155
|
+
throw new Error('Policy configuration error:\n' +
|
|
156
|
+
problems.map((p) => ` ${p}`).join('\n') +
|
|
157
|
+
'\nSee the "Configuring the matrix" section of the README.');
|
|
158
|
+
}
|
|
159
|
+
return merged;
|
|
160
|
+
}
|
|
23
161
|
export class PolicyEngine {
|
|
24
162
|
rules;
|
|
25
163
|
opaUrl = null;
|
|
@@ -66,7 +204,7 @@ export class PolicyEngine {
|
|
|
66
204
|
commandClass: parsed.class,
|
|
67
205
|
binary: parsed.binary,
|
|
68
206
|
ruleId: 'role-binding',
|
|
69
|
-
reason:
|
|
207
|
+
reason: this.explainRoleDenial(profile, parsed.class),
|
|
70
208
|
};
|
|
71
209
|
}
|
|
72
210
|
if (approvalRequired) {
|
|
@@ -152,6 +290,44 @@ export class PolicyEngine {
|
|
|
152
290
|
console.error(`POLICY WARNING: OPA evaluation unavailable (${cause}). ` +
|
|
153
291
|
'Falling back to local policy — commands OPA would deny may now be allowed.');
|
|
154
292
|
}
|
|
293
|
+
/**
|
|
294
|
+
* Say which three things produced the refusal, not one of them.
|
|
295
|
+
*
|
|
296
|
+
* The message used to read `Role "admin" cannot run "privileged" commands`,
|
|
297
|
+
* which names the role and the class but not the host group — and the group
|
|
298
|
+
* is usually what decided, because `admin` has `privileged` on staging and
|
|
299
|
+
* dev but not on prod. A reader concludes their role is simply incapable of
|
|
300
|
+
* sudo and goes looking for a bigger role that does not exist (#91).
|
|
301
|
+
*
|
|
302
|
+
* The inferred case matters most: a profile with no group set has its tier
|
|
303
|
+
* guessed from its name, so the reason is something the operator never wrote
|
|
304
|
+
* down anywhere and cannot see.
|
|
305
|
+
*/
|
|
306
|
+
explainRoleDenial(profile, commandClass) {
|
|
307
|
+
if (profile.readOnly) {
|
|
308
|
+
return `Profile "${profile.name}" is read-only, so "${commandClass}" commands are refused. ` +
|
|
309
|
+
`Clear readOnly on the profile to allow them.`;
|
|
310
|
+
}
|
|
311
|
+
const group = resolveProfileGroup(profile);
|
|
312
|
+
const inferred = !profile.group;
|
|
313
|
+
const allowed = this.getAllowedClasses(profile).join(', ');
|
|
314
|
+
let reason = `Role "${profile.role}" on host group "${group}" cannot run "${commandClass}" commands ` +
|
|
315
|
+
`(allowed: ${allowed}).`;
|
|
316
|
+
if (inferred) {
|
|
317
|
+
// Not "defaulted to the most restrictive tier": the name is matched
|
|
318
|
+
// against prod/staging/dev first, so a profile called "staging-web" lands
|
|
319
|
+
// on staging and the old wording described a fallback that did not
|
|
320
|
+
// happen. What the operator needs to know is that nothing they wrote
|
|
321
|
+
// chose this tier.
|
|
322
|
+
reason +=
|
|
323
|
+
` No group is set for profile "${profile.name}", so the tier was inferred from its name as "${group}".` +
|
|
324
|
+
` Set group explicitly on the profile, or pass --group, if that is not where this host belongs.`;
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
reason += ` Change the profile's group, or grant the class to this role in the policy's roleBindings.`;
|
|
328
|
+
}
|
|
329
|
+
return reason;
|
|
330
|
+
}
|
|
155
331
|
getAllowedClasses(profile) {
|
|
156
332
|
if (profile.readOnly) {
|
|
157
333
|
return ['read-only'];
|
|
@@ -160,31 +336,19 @@ export class PolicyEngine {
|
|
|
160
336
|
if (!roleBinding) {
|
|
161
337
|
return ['read-only'];
|
|
162
338
|
}
|
|
163
|
-
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
resolveHostGroup(profile) {
|
|
177
|
-
if (profile.group)
|
|
178
|
-
return profile.group;
|
|
179
|
-
const name = profile.name.toLowerCase();
|
|
180
|
-
if (name.includes('prod'))
|
|
181
|
-
return 'prod';
|
|
182
|
-
if (name.includes('staging'))
|
|
183
|
-
return 'staging';
|
|
184
|
-
if (/\b(dev|local|test|sandbox)\b/.test(name) || /(^|[-_])(dev|local|test|sandbox)([-_]|$)/.test(name)) {
|
|
185
|
-
return 'dev';
|
|
186
|
-
}
|
|
187
|
-
return HOST_GROUPS[0];
|
|
339
|
+
// No hop to another tier when this one has no bindings. While roleBindings
|
|
340
|
+
// were compiled in, falling back to HOST_GROUPS[0] meant falling back to
|
|
341
|
+
// that role's *strictest* cell, so the old comment here was true. Once a
|
|
342
|
+
// [policy] block can write the prod cell, the same hop hands an unresolved
|
|
343
|
+
// tier whatever prod was granted. That is a widening, and a silent one.
|
|
344
|
+
//
|
|
345
|
+
// An unresolved tier is a startup error now (resolvePolicyRules), so
|
|
346
|
+
// reaching this line means the engine was constructed directly rather than
|
|
347
|
+
// from a config file. Fail closed.
|
|
348
|
+
//
|
|
349
|
+
// `??` rather than `||`: an empty class list is a deliberate lockdown, and
|
|
350
|
+
// whether it survives should not rest on `[]` being truthy.
|
|
351
|
+
return roleBinding[resolveProfileGroup(profile)] ?? ['read-only'];
|
|
188
352
|
}
|
|
189
353
|
/**
|
|
190
354
|
* Whether this command trips the profile's approval gate. What happens at the
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ssh-mcp",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "2.0
|
|
4
|
+
"version": "2.2.0",
|
|
5
5
|
"description": "MCP server exposing policy-gated, audited SSH access for Linux and Windows hosts via the Model Context Protocol.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -41,17 +41,14 @@
|
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@changesets/changelog-github": "^0.7.0",
|
|
43
43
|
"@changesets/cli": "^2.31.0",
|
|
44
|
-
"@types/node": "^
|
|
44
|
+
"@types/node": "^26.2.0",
|
|
45
45
|
"@types/ssh2": "^1.15.5",
|
|
46
|
-
"@vitest/coverage-v8": "^
|
|
47
|
-
"cross-env": "^
|
|
48
|
-
"fast-check": "^
|
|
46
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
47
|
+
"cross-env": "^10.1.0",
|
|
48
|
+
"fast-check": "^4.9.0",
|
|
49
49
|
"shx": "^0.4.0",
|
|
50
|
-
"testcontainers": "^11.7.0",
|
|
51
|
-
"ts-node": "^10.9.2",
|
|
52
|
-
"tsx": "^4.20.6",
|
|
53
50
|
"typescript": "^5.9.2",
|
|
54
|
-
"vitest": "^
|
|
51
|
+
"vitest": "^4.1.10"
|
|
55
52
|
},
|
|
56
53
|
"homepage": "https://github.com/tufantunc/ssh-mcp#readme",
|
|
57
54
|
"repository": {
|