ssh-mcp 2.0.4 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -244,6 +244,25 @@ without it the tier is guessed from the profile name (`prod`/`staging`/`dev`,
244
244
  the strictest tier. A production host named `web-01` is therefore treated as
245
245
  production rather than silently getting dev permissions.
246
246
 
247
+ Note what this means for `sudo`: **`admin` has no `privileged` on `prod`**, so
248
+ `privileged-command` is refused there by design — including on a quick-start
249
+ profile, which has no name to infer from and therefore lands on `prod`. If the
250
+ host is not production, say so:
251
+
252
+ ```bash
253
+ npx ssh-mcp --host=10.0.0.5 --user=deploy --group=dev
254
+ ```
255
+
256
+ ```toml
257
+ [[profiles]]
258
+ name = "build-box"
259
+ group = "dev"
260
+ ```
261
+
262
+ Leaving a real production host on `prod` and granting sudo there is a policy
263
+ change, not a flag: add `privileged` to `admin.prod` in the config's
264
+ `roleBindings`.
265
+
247
266
  ### Command Classification
248
267
 
249
268
  Every command is classified before execution:
@@ -439,6 +458,7 @@ Secrets are **never** passed as CLI arguments.
439
458
  | `--port` | 22 | Quick start: SSH port |
440
459
  | `--key` | — | Quick start: Path to private key |
441
460
  | `--workdir` | — | Quick start: Working directory for commands and sessions |
461
+ | `--group` | prod | Quick start: Policy tier — `prod`, `staging` or `dev` |
442
462
  | `--timeout` | 60000 | Command timeout in ms |
443
463
  | `--maxChars` | 5000 | Max command length (`none` or `0` disables the limit) |
444
464
  | `--sessionMax` | 5 | Max concurrent sessions per connection |
package/build/index.js CHANGED
@@ -3,7 +3,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
4
  import { loadConfig } from './config/loader.js';
5
5
  import { ConnectionRegistry } from './ssh/connection-registry.js';
6
- import { PolicyEngine, DEFAULT_RULES } from './policy/engine.js';
6
+ import { PolicyEngine, DEFAULT_RULES, HOST_GROUPS } from './policy/engine.js';
7
7
  import { AuditStore } from './audit/store.js';
8
8
  import { registerTools, registerResources, getToolHashes } from './tools/registry.js';
9
9
  import { initKeychain } from './config/credential-resolver.js';
@@ -71,6 +71,27 @@ function resolveHostKeyMode(argv) {
71
71
  return mode;
72
72
  throw new Error(`Invalid --hostKeyMode=${mode}. Expected one of: tofu, strict, insecure.`);
73
73
  }
74
+ /**
75
+ * Which policy tier a CLI-configured host belongs to.
76
+ *
77
+ * Without this there was no way to say it from the command line at all. The
78
+ * inline profile carries no group, so it fell to the strictest tier, where the
79
+ * `admin` role has no `privileged` — meaning `sudo` could never run for anyone
80
+ * who had not written a config file (#91).
81
+ *
82
+ * The default stays `prod`. Guessing an unknown host is production is the safe
83
+ * direction; what was missing was a way to correct the guess.
84
+ */
85
+ export function resolveHostGroup(argv) {
86
+ const group = argv.group;
87
+ if (group === null || group === undefined)
88
+ return 'prod';
89
+ if (HOST_GROUPS.includes(group))
90
+ return group;
91
+ // Falling through would silently apply the prod bindings to a typo, and the
92
+ // operator would read the refusal as policy rather than as their own slip.
93
+ throw new Error(`Invalid --group=${group}. Expected one of: ${HOST_GROUPS.join(', ')}.`);
94
+ }
74
95
  async function buildAppConfig(argv) {
75
96
  if (argv.config) {
76
97
  return loadConfig(argv.config);
@@ -111,6 +132,7 @@ async function buildAppConfig(argv) {
111
132
  maxChars: defaults.commandMaxChars,
112
133
  maxOutputBytes: defaults.commandMaxOutputBytes,
113
134
  role: 'admin',
135
+ group: resolveHostGroup(argv),
114
136
  readOnly: false,
115
137
  approvalPolicy: argv.disableApproval ? 'auto' : defaults.approvalMode,
116
138
  cert: false,
@@ -1,6 +1,6 @@
1
1
  import { classifyCommand, FORBIDDEN_PATTERNS } from './classifier.js';
2
2
  /** Tiers, most restrictive first. Unknown/unset tiers resolve to the first. */
3
- const HOST_GROUPS = ['prod', 'staging', 'dev'];
3
+ export const HOST_GROUPS = ['prod', 'staging', 'dev'];
4
4
  export const DEFAULT_RULES = {
5
5
  roleBindings: {
6
6
  viewer: {
@@ -66,7 +66,7 @@ export class PolicyEngine {
66
66
  commandClass: parsed.class,
67
67
  binary: parsed.binary,
68
68
  ruleId: 'role-binding',
69
- reason: `Role "${profile.role}" cannot run "${parsed.class}" commands`,
69
+ reason: this.explainRoleDenial(profile, parsed.class),
70
70
  };
71
71
  }
72
72
  if (approvalRequired) {
@@ -152,6 +152,39 @@ export class PolicyEngine {
152
152
  console.error(`POLICY WARNING: OPA evaluation unavailable (${cause}). ` +
153
153
  'Falling back to local policy — commands OPA would deny may now be allowed.');
154
154
  }
155
+ /**
156
+ * Say which three things produced the refusal, not one of them.
157
+ *
158
+ * The message used to read `Role "admin" cannot run "privileged" commands`,
159
+ * which names the role and the class but not the host group — and the group
160
+ * is usually what decided, because `admin` has `privileged` on staging and
161
+ * dev but not on prod. A reader concludes their role is simply incapable of
162
+ * sudo and goes looking for a bigger role that does not exist (#91).
163
+ *
164
+ * The inferred case matters most: a profile with no group set lands on the
165
+ * strictest tier, so the reason is something the operator never wrote down
166
+ * anywhere and cannot see.
167
+ */
168
+ explainRoleDenial(profile, commandClass) {
169
+ if (profile.readOnly) {
170
+ return `Profile "${profile.name}" is read-only, so "${commandClass}" commands are refused. ` +
171
+ `Clear readOnly on the profile to allow them.`;
172
+ }
173
+ const group = this.resolveHostGroup(profile);
174
+ const inferred = !profile.group;
175
+ const allowed = this.getAllowedClasses(profile).join(', ');
176
+ let reason = `Role "${profile.role}" on host group "${group}" cannot run "${commandClass}" commands ` +
177
+ `(allowed: ${allowed}).`;
178
+ if (inferred) {
179
+ reason +=
180
+ ` No group is set for profile "${profile.name}", so it defaulted to the most restrictive tier.` +
181
+ ` Set group = "dev" or "staging" on the profile, or pass --group, if this host is not production.`;
182
+ }
183
+ else {
184
+ reason += ` Change the profile's group, or grant the class to this role in the policy's roleBindings.`;
185
+ }
186
+ return reason;
187
+ }
155
188
  getAllowedClasses(profile) {
156
189
  if (profile.readOnly) {
157
190
  return ['read-only'];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ssh-mcp",
3
3
  "license": "MIT",
4
- "version": "2.0.4",
4
+ "version": "2.1.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": {