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 CHANGED
@@ -3,13 +3,13 @@
3
3
  [![NPM Version](https://img.shields.io/npm/v/ssh-mcp)](https://www.npmjs.com/package/ssh-mcp)
4
4
  [![Downloads](https://img.shields.io/npm/dm/ssh-mcp)](https://www.npmjs.com/package/ssh-mcp)
5
5
  [![CI](https://github.com/tufantunc/ssh-mcp/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/tufantunc/ssh-mcp/actions/workflows/ci.yml)
6
- [![codecov](https://codecov.io/gh/tufantunc/ssh-mcp/branch/main/graph/badge.svg)](https://codecov.io/gh/tufantunc/ssh-mcp)
6
+ [![codecov](https://codecov.io/gh/tufantunc/ssh-mcp/graph/badge.svg?branch=main)](https://codecov.io/gh/tufantunc/ssh-mcp)
7
7
  [![License](https://img.shields.io/github/license/tufantunc/ssh-mcp)](./LICENSE)
8
8
  [![GitHub issues](https://img.shields.io/github/issues/tufantunc/ssh-mcp)](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
- > **WARNING — Lethal Trifecta Risk.** Giving an LLM SSH access creates a ["Lethal Trifecta"](https://simonwillison.net/2025/Jun/16/the-lethal-trifecta/) (private data + untrusted input + network egress). **Never run as root. Never enable `auto` approval on production.** See [SECURITY.md](./SECURITY.md) for the full threat model and mitigation checklist.
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, every command is additionally evaluated by OPA after the built-in policy engine. The request shape follows the AuthZEN Access Evaluation contract:
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 can run anything on dev hosts
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 |
@@ -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
- return { defaults, profiles };
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;
@@ -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, DEFAULT_RULES } from './policy/engine.js';
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(DEFAULT_RULES);
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);
@@ -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: `Role "${profile.role}" cannot run "${parsed.class}" commands`,
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
- const hostGroup = this.resolveHostGroup(profile);
164
- // Falls back to the most restrictive tier, never to the loosest one.
165
- return roleBinding[hostGroup] || roleBinding[HOST_GROUPS[0]] || ['read-only'];
166
- }
167
- /**
168
- * Which tier's role binding applies to this profile.
169
- *
170
- * `profile.group` is authoritative. Without it we still infer from the name
171
- * for convenience, but an unrecognised name resolves to the most restrictive
172
- * tier: the previous default sent every unrecognised profile to `dev`, so a
173
- * production host merely named "web-01" silently received the loosest
174
- * permissions in the matrix.
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",
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": "^24.5.2",
44
+ "@types/node": "^26.2.0",
45
45
  "@types/ssh2": "^1.15.5",
46
- "@vitest/coverage-v8": "^3.2.7",
47
- "cross-env": "^7.0.3",
48
- "fast-check": "^3.23.2",
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": "^3.2.4"
51
+ "vitest": "^4.1.10"
55
52
  },
56
53
  "homepage": "https://github.com/tufantunc/ssh-mcp#readme",
57
54
  "repository": {