ssh-mcp 2.1.0 → 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 +89 -8
- package/build/config/loader.js +8 -2
- package/build/config/schema.js +35 -1
- package/build/index.js +2 -2
- package/build/policy/engine.js +162 -31
- 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:
|
|
@@ -259,9 +275,69 @@ name = "build-box"
|
|
|
259
275
|
group = "dev"
|
|
260
276
|
```
|
|
261
277
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
`
|
|
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.
|
|
265
341
|
|
|
266
342
|
### Command Classification
|
|
267
343
|
|
|
@@ -323,7 +399,11 @@ For organizations that standardize on Open Policy Agent / Rego:
|
|
|
323
399
|
ssh-mcp --opaUrl=http://localhost:8181
|
|
324
400
|
```
|
|
325
401
|
|
|
326
|
-
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:
|
|
327
407
|
|
|
328
408
|
```json
|
|
329
409
|
{
|
|
@@ -344,7 +424,8 @@ package ssh.mcp
|
|
|
344
424
|
|
|
345
425
|
default allow := false
|
|
346
426
|
|
|
347
|
-
# 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.
|
|
348
429
|
allow if {
|
|
349
430
|
input.subject.role == "admin"
|
|
350
431
|
startswith(input.subject.profile, "dev")
|
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';
|
|
@@ -160,7 +160,7 @@ async function main() {
|
|
|
160
160
|
await initTracing(argv.otelEndpoint, argv.otelServiceName || 'ssh-mcp');
|
|
161
161
|
}
|
|
162
162
|
const registry = new ConnectionRegistry(config, hostKeyMode);
|
|
163
|
-
const policy = new PolicyEngine(
|
|
163
|
+
const policy = new PolicyEngine(resolvePolicyRules(config.profiles, config.policy));
|
|
164
164
|
const audit = new AuditStore(undefined, entropyScan, tamperEvident);
|
|
165
165
|
if (argv.opaUrl) {
|
|
166
166
|
policy.setOpaUrl(argv.opaUrl);
|
package/build/policy/engine.js
CHANGED
|
@@ -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;
|
|
@@ -161,24 +299,29 @@ export class PolicyEngine {
|
|
|
161
299
|
* dev but not on prod. A reader concludes their role is simply incapable of
|
|
162
300
|
* sudo and goes looking for a bigger role that does not exist (#91).
|
|
163
301
|
*
|
|
164
|
-
* The inferred case matters most: a profile with no group set
|
|
165
|
-
*
|
|
166
|
-
* anywhere and cannot see.
|
|
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.
|
|
167
305
|
*/
|
|
168
306
|
explainRoleDenial(profile, commandClass) {
|
|
169
307
|
if (profile.readOnly) {
|
|
170
308
|
return `Profile "${profile.name}" is read-only, so "${commandClass}" commands are refused. ` +
|
|
171
309
|
`Clear readOnly on the profile to allow them.`;
|
|
172
310
|
}
|
|
173
|
-
const group =
|
|
311
|
+
const group = resolveProfileGroup(profile);
|
|
174
312
|
const inferred = !profile.group;
|
|
175
313
|
const allowed = this.getAllowedClasses(profile).join(', ');
|
|
176
314
|
let reason = `Role "${profile.role}" on host group "${group}" cannot run "${commandClass}" commands ` +
|
|
177
315
|
`(allowed: ${allowed}).`;
|
|
178
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.
|
|
179
322
|
reason +=
|
|
180
|
-
` No group is set for profile "${profile.name}", so
|
|
181
|
-
` Set group
|
|
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.`;
|
|
182
325
|
}
|
|
183
326
|
else {
|
|
184
327
|
reason += ` Change the profile's group, or grant the class to this role in the policy's roleBindings.`;
|
|
@@ -193,31 +336,19 @@ export class PolicyEngine {
|
|
|
193
336
|
if (!roleBinding) {
|
|
194
337
|
return ['read-only'];
|
|
195
338
|
}
|
|
196
|
-
|
|
197
|
-
//
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
resolveHostGroup(profile) {
|
|
210
|
-
if (profile.group)
|
|
211
|
-
return profile.group;
|
|
212
|
-
const name = profile.name.toLowerCase();
|
|
213
|
-
if (name.includes('prod'))
|
|
214
|
-
return 'prod';
|
|
215
|
-
if (name.includes('staging'))
|
|
216
|
-
return 'staging';
|
|
217
|
-
if (/\b(dev|local|test|sandbox)\b/.test(name) || /(^|[-_])(dev|local|test|sandbox)([-_]|$)/.test(name)) {
|
|
218
|
-
return 'dev';
|
|
219
|
-
}
|
|
220
|
-
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'];
|
|
221
352
|
}
|
|
222
353
|
/**
|
|
223
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.
|
|
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": {
|