ssh-mcp 2.2.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.
@@ -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
  }
@@ -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 = {
@@ -163,14 +163,14 @@ export class PolicyEngine {
163
163
  opaUrl = null;
164
164
  /** Rate-limits the fail-open warning so one outage can't flood stderr. */
165
165
  lastOpaWarning = 0;
166
- /** Canonical patterns plus the operator's, compiled once at construction. */
167
- denyPatterns;
166
+ /** The operator's patterns, compiled once. The built-ins live in classifier.ts. */
167
+ userPatterns;
168
168
  constructor(rules = DEFAULT_RULES) {
169
169
  this.rules = rules;
170
170
  // Compile eagerly: a deny rule that silently degrades (the old code fell
171
171
  // back to substring-matching the command against the regex *source*) is
172
172
  // worse than a startup failure, because nothing surfaces the degradation.
173
- const userPatterns = (rules.denylist ?? []).map((pattern) => {
173
+ this.userPatterns = (rules.denylist ?? []).map((pattern) => {
174
174
  try {
175
175
  return new RegExp(pattern);
176
176
  }
@@ -178,7 +178,6 @@ export class PolicyEngine {
178
178
  throw new Error(`Invalid denylist pattern ${JSON.stringify(pattern)}: ${err instanceof Error ? err.message : String(err)}`);
179
179
  }
180
180
  });
181
- this.denyPatterns = [...FORBIDDEN_PATTERNS, ...userPatterns];
182
181
  }
183
182
  setOpaUrl(url) {
184
183
  this.opaUrl = url;
@@ -187,13 +186,14 @@ export class PolicyEngine {
187
186
  const parsed = classifyCommand(command);
188
187
  const allowedClasses = this.getAllowedClasses(profile);
189
188
  const classAllowed = allowedClasses.includes(parsed.class);
190
- if (this.matchesDenylist(command)) {
189
+ const denied = this.findDenyMatch(command);
190
+ if (denied) {
191
191
  return {
192
192
  decision: 'deny',
193
193
  commandClass: parsed.class,
194
194
  binary: parsed.binary,
195
195
  ruleId: 'denylist',
196
- reason: 'Command matches denylist pattern',
196
+ reason: denied,
197
197
  };
198
198
  }
199
199
  const needsApproval = parsed.class === 'destructive' || parsed.class === 'privileged';
@@ -363,7 +363,28 @@ export class PolicyEngine {
363
363
  // ask-destructive and deny both gate on destructive/privileged commands.
364
364
  return needsApproval;
365
365
  }
366
- matchesDenylist(command) {
367
- 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;
368
389
  }
369
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.2.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": {