ssh-mcp 2.1.0 → 2.2.1

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:
@@ -259,9 +275,69 @@ name = "build-box"
259
275
  group = "dev"
260
276
  ```
261
277
 
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`.
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, 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:
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 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.
348
429
  allow if {
349
430
  input.subject.role == "admin"
350
431
  startswith(input.subject.profile, "dev")
@@ -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();
@@ -31,8 +31,17 @@ export async function requestApproval(server, command, profileName, evaluation)
31
31
  return { approved: false };
32
32
  }
33
33
  catch (err) {
34
+ const cause = err instanceof Error ? err.message : String(err);
35
+ // stderr still gets it for the operator's logs, but it cannot be the only
36
+ // copy: for a stdio MCP server that is a client log file nobody reads, so
37
+ // the caller gets the diagnosis too and can put it in front of the user.
34
38
  console.error(`Approval request failed for ${evaluation.commandClass} command on "${profileName}" — denying. ` +
35
- `Does this client support elicitation? Cause: ${err instanceof Error ? err.message : String(err)}`);
36
- return { approved: false };
39
+ `Does this client support elicitation? Cause: ${cause}`);
40
+ return {
41
+ approved: false,
42
+ unavailable: `this MCP client could not be asked to approve it. The likely cause is a client without ` +
43
+ `elicitation support; a transport failure or a malformed reply does the same. Approval fails ` +
44
+ `closed, so the command was refused rather than run. Underlying error: ${cause}`,
45
+ };
37
46
  }
38
47
  }
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, HOST_GROUPS } 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';
@@ -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(DEFAULT_RULES);
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);
@@ -60,14 +60,9 @@ const FORBIDDEN_PATTERNS = [
60
60
  /mkfs\./,
61
61
  /dd\s.*\bof=\/dev\//,
62
62
  />\s*\/dev\/sd/,
63
- /\bshutdown\b/,
64
- /\breboot\b/,
65
- /\bhalt\b/,
66
- /\bpoweroff\b/,
67
63
  /:\(\)\s*\{\s*:\|:\&\s*\}\s*;\s*:/, // fork bomb
68
64
  /curl\s[^|]*\|\s*(sh|bash|zsh)/,
69
65
  /wget\s[^|]*\|\s*(sh|bash|zsh)/,
70
- /\beval\b/,
71
66
  />\s*\/etc\/cron/,
72
67
  />\s*\/etc\/systemd/,
73
68
  />\s*~\/.ssh\/authorized_keys/,
@@ -75,6 +70,98 @@ const FORBIDDEN_PATTERNS = [
75
70
  /\bchmod\s+-R\s+777\s+\//,
76
71
  /\bchown\s+-R\s.*\s\/\s*$/,
77
72
  ];
73
+ /**
74
+ * Words that are forbidden when *invoked*, which the patterns above cannot
75
+ * express.
76
+ *
77
+ * These used to be `/\breboot\b/` and friends, matching the word anywhere in
78
+ * the string. `last reboot` reads a log and was refused for containing the
79
+ * word; so were `grep -r reboot /etc/`, `cat /var/run/reboot-required` and
80
+ * `journalctl | grep shutdown`. On a NAS an agent checking boot history trips
81
+ * this on its first command, which is how it was reported (#91).
82
+ *
83
+ * Matching an invocation rather than a mention needs to know where a command
84
+ * word can start, and that is a tokenizer's job, not a regex's — the regex
85
+ * forms that come close all reintroduce the `\S*` backtracking the block above
86
+ * exists to avoid.
87
+ */
88
+ const FORBIDDEN_INVOCATIONS = new Set(['shutdown', 'reboot', 'halt', 'poweroff', 'eval']);
89
+ /** Binaries that take the dangerous action as an argument: `systemctl reboot`. */
90
+ const ACTION_MULTIPLEXERS = new Set(['systemctl', 'init', 'telinit']);
91
+ const PRIVILEGE_PREFIXES = new Set(['sudo', 'su', 'doas', 'pkexec']);
92
+ /**
93
+ * Privilege-prefix flags that consume the next argument, so `sudo -u root
94
+ * reboot` is not read as invoking `root`. Enumerable because it is one tool's
95
+ * option set, unlike "every flag of every binary".
96
+ */
97
+ const PREFIX_VALUE_FLAGS = new Set([
98
+ '-u', '-g', '-p', '-C', '-h', '-r', '-t', '-U', '-c',
99
+ '--user', '--group', '--prompt', '--close-from', '--host', '--role', '--type',
100
+ '--other-user', '--command',
101
+ ]);
102
+ /** `/sbin/reboot` and `reboot` are the same invocation. */
103
+ function stripPath(word) {
104
+ const slash = word.lastIndexOf('/');
105
+ return slash === -1 ? word : word.slice(slash + 1);
106
+ }
107
+ /**
108
+ * The command words a shell would actually execute — the head of every
109
+ * `;`/`&&`/`||`/`|`/newline-separated segment, past any privilege prefix, plus
110
+ * the arguments of a multiplexer like `systemctl`.
111
+ *
112
+ * Pure string work: split, trim, set lookups. No quantifiers, so nothing here
113
+ * can backtrack.
114
+ */
115
+ function invokedWords(command) {
116
+ const invoked = [];
117
+ for (const segment of command.split(/[;&|\n]/)) {
118
+ const words = segment.trim().split(/\s+/).filter(Boolean).map(stripPath);
119
+ let i = 0;
120
+ while (i < words.length && PRIVILEGE_PREFIXES.has(words[i])) {
121
+ i++;
122
+ while (i < words.length && words[i].startsWith('-')) {
123
+ const consumesValue = PREFIX_VALUE_FLAGS.has(words[i]);
124
+ i++;
125
+ if (consumesValue)
126
+ i++;
127
+ }
128
+ }
129
+ const head = words[i];
130
+ if (!head)
131
+ continue;
132
+ invoked.push(head);
133
+ // `systemctl reboot` restarts the host. Reading a unit that happens to be
134
+ // named after a power action is rare enough that erring towards refusal
135
+ // here costs little.
136
+ if (ACTION_MULTIPLEXERS.has(head)) {
137
+ invoked.push(...words.slice(i + 1).filter((w) => !w.startsWith('-')));
138
+ }
139
+ }
140
+ return invoked;
141
+ }
142
+ const FORBIDDEN_RULES = [
143
+ ...FORBIDDEN_PATTERNS.map((re) => ({ label: String(re), test: (c) => re.test(c) })),
144
+ {
145
+ label: 'invoking a power-state command (shutdown, reboot, halt, poweroff) or eval',
146
+ test: (command) => invokedWords(command).some((w) => FORBIDDEN_INVOCATIONS.has(w)),
147
+ },
148
+ ];
149
+ /**
150
+ * Which never-allowed rule this command trips, or null. The single entry point:
151
+ * FORBIDDEN_PATTERNS is deliberately not exported, because half of the list
152
+ * lives in FORBIDDEN_RULES and a caller checking only the regexes would quietly
153
+ * permit `sudo reboot`.
154
+ */
155
+ export function findForbiddenMatch(command) {
156
+ for (const rule of FORBIDDEN_RULES) {
157
+ if (rule.test(command))
158
+ return rule.label;
159
+ }
160
+ return null;
161
+ }
162
+ export function isForbidden(command) {
163
+ return findForbiddenMatch(command) !== null;
164
+ }
78
165
  /**
79
166
  * Commands that are destructive but legitimate under approval — e.g.
80
167
  * `rm -rf /tmp/build`, which must NOT be confused with `rm -rf /`.
@@ -82,8 +169,17 @@ const FORBIDDEN_PATTERNS = [
82
169
  const DESTRUCTIVE_PATTERNS = [
83
170
  /rm\s+-rf?\s+\//,
84
171
  ];
85
- /** Everything that classifies as destructive: forbidden commands included. */
86
- const DESTRUCTIVE_DENYLIST = [...FORBIDDEN_PATTERNS, ...DESTRUCTIVE_PATTERNS];
172
+ /**
173
+ * Everything that classifies as destructive: forbidden commands included.
174
+ *
175
+ * Goes through isForbidden() rather than the regex list, so a command caught by
176
+ * an invocation rule is classified destructive too — and, just as importantly,
177
+ * reading a log that mentions `reboot` is no longer classified destructive
178
+ * either.
179
+ */
180
+ function isDestructive(command) {
181
+ return isForbidden(command) || DESTRUCTIVE_PATTERNS.some((re) => re.test(command));
182
+ }
87
183
  const PRIVILEGED_INDICATORS = [
88
184
  /^\s*sudo\b/,
89
185
  /^\s*su\b/,
@@ -109,10 +205,8 @@ export function classifyCommand(command) {
109
205
  if (isPrivileged) {
110
206
  return { binary, fullCommand, class: 'privileged' };
111
207
  }
112
- for (const pattern of DESTRUCTIVE_DENYLIST) {
113
- if (pattern.test(trimmed)) {
114
- return { binary, fullCommand, class: 'destructive' };
115
- }
208
+ if (isDestructive(trimmed)) {
209
+ return { binary, fullCommand, class: 'destructive' };
116
210
  }
117
211
  const twoWordPrefix = fullCommand.split(/\s+/).slice(0, 2).join(' ');
118
212
  if (READ_ONLY_ALLOWLIST.has(binary) || READ_ONLY_ALLOWLIST.has(twoWordPrefix)) {
@@ -123,4 +217,4 @@ export function classifyCommand(command) {
123
217
  }
124
218
  return { binary, fullCommand, class: 'safe' };
125
219
  }
126
- export { READ_ONLY_ALLOWLIST, DESTRUCTIVE_DENYLIST, FORBIDDEN_PATTERNS };
220
+ export { READ_ONLY_ALLOWLIST, isDestructive };
@@ -1,4 +1,4 @@
1
- import { classifyCommand, FORBIDDEN_PATTERNS } from './classifier.js';
1
+ import { classifyCommand, findForbiddenMatch } from './classifier.js';
2
2
  /** Tiers, most restrictive first. Unknown/unset tiers resolve to the first. */
3
3
  export const HOST_GROUPS = ['prod', 'staging', 'dev'];
4
4
  export const DEFAULT_RULES = {
@@ -20,19 +20,157 @@ 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;
26
164
  /** Rate-limits the fail-open warning so one outage can't flood stderr. */
27
165
  lastOpaWarning = 0;
28
- /** Canonical patterns plus the operator's, compiled once at construction. */
29
- denyPatterns;
166
+ /** The operator's patterns, compiled once. The built-ins live in classifier.ts. */
167
+ userPatterns;
30
168
  constructor(rules = DEFAULT_RULES) {
31
169
  this.rules = rules;
32
170
  // Compile eagerly: a deny rule that silently degrades (the old code fell
33
171
  // back to substring-matching the command against the regex *source*) is
34
172
  // worse than a startup failure, because nothing surfaces the degradation.
35
- const userPatterns = (rules.denylist ?? []).map((pattern) => {
173
+ this.userPatterns = (rules.denylist ?? []).map((pattern) => {
36
174
  try {
37
175
  return new RegExp(pattern);
38
176
  }
@@ -40,7 +178,6 @@ export class PolicyEngine {
40
178
  throw new Error(`Invalid denylist pattern ${JSON.stringify(pattern)}: ${err instanceof Error ? err.message : String(err)}`);
41
179
  }
42
180
  });
43
- this.denyPatterns = [...FORBIDDEN_PATTERNS, ...userPatterns];
44
181
  }
45
182
  setOpaUrl(url) {
46
183
  this.opaUrl = url;
@@ -49,13 +186,14 @@ export class PolicyEngine {
49
186
  const parsed = classifyCommand(command);
50
187
  const allowedClasses = this.getAllowedClasses(profile);
51
188
  const classAllowed = allowedClasses.includes(parsed.class);
52
- if (this.matchesDenylist(command)) {
189
+ const denied = this.findDenyMatch(command);
190
+ if (denied) {
53
191
  return {
54
192
  decision: 'deny',
55
193
  commandClass: parsed.class,
56
194
  binary: parsed.binary,
57
195
  ruleId: 'denylist',
58
- reason: 'Command matches denylist pattern',
196
+ reason: denied,
59
197
  };
60
198
  }
61
199
  const needsApproval = parsed.class === 'destructive' || parsed.class === 'privileged';
@@ -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 lands on the
165
- * strictest tier, so the reason is something the operator never wrote down
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 = this.resolveHostGroup(profile);
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 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.`;
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
- const hostGroup = this.resolveHostGroup(profile);
197
- // Falls back to the most restrictive tier, never to the loosest one.
198
- return roleBinding[hostGroup] || roleBinding[HOST_GROUPS[0]] || ['read-only'];
199
- }
200
- /**
201
- * Which tier's role binding applies to this profile.
202
- *
203
- * `profile.group` is authoritative. Without it we still infer from the name
204
- * for convenience, but an unrecognised name resolves to the most restrictive
205
- * tier: the previous default sent every unrecognised profile to `dev`, so a
206
- * production host merely named "web-01" silently received the loosest
207
- * permissions in the matrix.
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
@@ -232,7 +363,28 @@ export class PolicyEngine {
232
363
  // ask-destructive and deny both gate on destructive/privileged commands.
233
364
  return needsApproval;
234
365
  }
235
- matchesDenylist(command) {
236
- return this.denyPatterns.some((pattern) => pattern.test(command));
366
+ /**
367
+ * Which deny rule refused this command, worded so the reader can act on it.
368
+ *
369
+ * The old message was `Command matches denylist pattern` and named nothing —
370
+ * not the rule, not where it came from, not whether the operator could change
371
+ * it. A user who hit it had no way to tell a built-in refusal from one they
372
+ * had written themselves (#91). explainRoleDenial already names the role, the
373
+ * group, the class and the allowed set; this path never got the same
374
+ * treatment.
375
+ */
376
+ findDenyMatch(command) {
377
+ const builtIn = findForbiddenMatch(command);
378
+ if (builtIn) {
379
+ return `Command matches a built-in never-allowed rule: ${builtIn}. ` +
380
+ `This list cannot be switched off — the [policy].denylist key adds patterns, it does not remove these.`;
381
+ }
382
+ for (const pattern of this.userPatterns) {
383
+ if (pattern.test(command)) {
384
+ return `Command matches /${pattern.source}/, a pattern from [policy].denylist in your config file. ` +
385
+ `Remove or narrow it there to allow this command.`;
386
+ }
387
+ }
388
+ return null;
237
389
  }
238
390
  }
@@ -55,7 +55,12 @@ export function createPipeline({ server, registry, policy, audit, approvalGrantT
55
55
  }
56
56
  const approval = await requestApproval(server, command, conn.profile.name, evaluation);
57
57
  if (!approval.approved) {
58
- throw new PolicyRefusedError('APPROVAL_DENIED: User did not approve this command', evaluation);
58
+ // Two different failures used to share one message. "User did not
59
+ // approve" is true when the user declined and a lie when the client
60
+ // could not be asked, and the reader cannot tell which they got (#91).
61
+ throw new PolicyRefusedError(approval.unavailable
62
+ ? `APPROVAL_UNAVAILABLE: ${approval.unavailable}`
63
+ : 'APPROVAL_DENIED: User did not approve this command', evaluation);
59
64
  }
60
65
  grants.record(conn.profile.name, command, evaluation.commandClass);
61
66
  return { conn, evaluation, approver: approval.approver };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ssh-mcp",
3
3
  "license": "MIT",
4
- "version": "2.1.0",
4
+ "version": "2.2.1",
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": {