ssh-mcp 2.11.0 → 2.13.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.
@@ -1,4 +1,4 @@
1
- import { classifyCommand, findForbiddenMatch } from './classifier.js';
1
+ import { classifyCommand, findForbiddenMatch, formatReadOnlyRejection } from './classifier.js';
2
2
  import { OperatorError } from '../errors.js';
3
3
  /**
4
4
  * How long to wait for the OPA sidecar before treating it as unavailable.
@@ -230,6 +230,11 @@ export class PolicyEngine {
230
230
  const parsed = classifyCommand(command);
231
231
  const allowedClasses = this.getAllowedClasses(profile);
232
232
  const classAllowed = allowedClasses.includes(parsed.class);
233
+ // Copied onto every branch below rather than just the role-binding one:
234
+ // `read-command`'s `enforceClass` check reads it off an `allow` decision,
235
+ // since a grammar-rejected reader is allowed as `safe` for most roles and
236
+ // only refused one layer up, in the tool itself.
237
+ const readOnlyRejection = parsed.readOnlyRejection;
233
238
  const denied = this.findDenyMatch(command);
234
239
  if (denied) {
235
240
  return {
@@ -238,6 +243,7 @@ export class PolicyEngine {
238
243
  binary: parsed.binary,
239
244
  ruleId: 'denylist',
240
245
  reason: denied,
246
+ readOnlyRejection,
241
247
  };
242
248
  }
243
249
  const needsApproval = parsed.class === 'destructive' || parsed.class === 'privileged';
@@ -248,7 +254,8 @@ export class PolicyEngine {
248
254
  commandClass: parsed.class,
249
255
  binary: parsed.binary,
250
256
  ruleId: 'role-binding',
251
- reason: this.explainRoleDenial(profile, parsed.class),
257
+ reason: this.explainRoleDenial(profile, parsed),
258
+ readOnlyRejection,
252
259
  };
253
260
  }
254
261
  if (approvalRequired) {
@@ -261,6 +268,7 @@ export class PolicyEngine {
261
268
  binary: parsed.binary,
262
269
  ruleId: 'approval-policy',
263
270
  reason: `Profile "${profile.name}" denies ${parsed.class} commands (approvalPolicy = "deny")`,
271
+ readOnlyRejection,
264
272
  };
265
273
  }
266
274
  return {
@@ -269,6 +277,7 @@ export class PolicyEngine {
269
277
  binary: parsed.binary,
270
278
  ruleId: 'approval-policy',
271
279
  reason: `Profile "${profile.name}" requires approval for ${parsed.class} commands`,
280
+ readOnlyRejection,
272
281
  };
273
282
  }
274
283
  return {
@@ -276,6 +285,7 @@ export class PolicyEngine {
276
285
  commandClass: parsed.class,
277
286
  binary: parsed.binary,
278
287
  ruleId: 'default',
288
+ readOnlyRejection,
279
289
  };
280
290
  }
281
291
  async evaluateWithOpa(command, profile, toolName) {
@@ -393,28 +403,40 @@ export class PolicyEngine {
393
403
  * guessed from its name, so the reason is something the operator never wrote
394
404
  * down anywhere and cannot see.
395
405
  */
396
- explainRoleDenial(profile, commandClass) {
406
+ explainRoleDenial(profile, parsed) {
407
+ const commandClass = parsed.class;
408
+ let reason;
397
409
  if (profile.readOnly) {
398
- return `Profile "${profile.name}" is read-only, so "${commandClass}" commands are refused. ` +
410
+ reason = `Profile "${profile.name}" is read-only, so "${commandClass}" commands are refused. ` +
399
411
  `Clear readOnly on the profile to allow them.`;
400
412
  }
401
- const group = resolveProfileGroup(profile);
402
- const inferred = !profile.group;
403
- const allowed = this.getAllowedClasses(profile).join(', ');
404
- let reason = `Role "${profile.role}" on host group "${group}" cannot run "${commandClass}" commands ` +
405
- `(allowed: ${allowed}).`;
406
- if (inferred) {
407
- // Not "defaulted to the most restrictive tier": the name is matched
408
- // against prod/staging/dev first, so a profile called "staging-web" lands
409
- // on staging and the old wording described a fallback that did not
410
- // happen. What the operator needs to know is that nothing they wrote
411
- // chose this tier.
412
- reason +=
413
- ` No group is set for profile "${profile.name}", so the tier was inferred from its name as "${group}".` +
414
- ` Set group explicitly on the profile, or pass --group, if that is not where this host belongs.`;
415
- }
416
413
  else {
417
- reason += ` Change the profile's group, or grant the class to this role in the policy's roleBindings.`;
414
+ const group = resolveProfileGroup(profile);
415
+ const inferred = !profile.group;
416
+ const allowed = this.getAllowedClasses(profile).join(', ');
417
+ reason =
418
+ `Role "${profile.role}" on host group "${group}" cannot run "${commandClass}" commands ` +
419
+ `(allowed: ${allowed}).`;
420
+ if (inferred) {
421
+ // Not "defaulted to the most restrictive tier": the name is matched
422
+ // against prod/staging/dev first, so a profile called "staging-web" lands
423
+ // on staging and the old wording described a fallback that did not
424
+ // happen. What the operator needs to know is that nothing they wrote
425
+ // chose this tier.
426
+ reason +=
427
+ ` No group is set for profile "${profile.name}", so the tier was inferred from its name as "${group}".` +
428
+ ` Set group explicitly on the profile, or pass --group, if that is not where this host belongs.`;
429
+ }
430
+ else {
431
+ reason += ` Change the profile's group, or grant the class to this role in the policy's roleBindings.`;
432
+ }
433
+ }
434
+ // A `safe` command refused here is often not a hard "no" — it is one
435
+ // argv word away from `read-only`, and without this the reader concludes
436
+ // the binary is forbidden outright and never learns which spelling would
437
+ // have worked.
438
+ if (parsed.readOnlyRejection) {
439
+ reason += ` ${formatReadOnlyRejection(parsed.readOnlyRejection)}`;
418
440
  }
419
441
  return reason;
420
442
  }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Deciding whether a reader's arguments are provably data, not more commands.
3
+ *
4
+ * `READERS` in the classifier grants `read-only` to any command whose first
5
+ * word is allowlisted, without looking at the words after it. That is safe
6
+ * only because a later stage rejects shell metacharacters, but `cat -- "$x"`
7
+ * and `cat $(id)` both clear that gate today; only the outer shell-control
8
+ * scan (or the caller's own audit) stands between an allowlisted reader and
9
+ * an argument that isn't data. This module is the piece that closes that gap
10
+ * for readers whose flags are simple enough to describe with a grammar: given
11
+ * the argv words that follow the command name and a description of what the
12
+ * binary accepts, say whether every word is an option this grammar
13
+ * recognises, a value an option consumes, or an operand that satisfies the
14
+ * `operands` shape. Nothing here decides *class* — that stays the caller's
15
+ * job, together with the correctness conditions in the design doc (an
16
+ * optional-argument flag never goes in `valueFlags`; an option is listed only
17
+ * if every implementation the command name can resolve to reads it the same
18
+ * way).
19
+ *
20
+ * Pure and side-effect free: no filesystem, no `classifier.ts` import. The
21
+ * table that wires this into a command's class is a later task.
22
+ */
23
+ /**
24
+ * Does every word in `words` come from what `grammar` describes?
25
+ *
26
+ * The scan is word-by-word, left to right, mirroring how getopt (or, for
27
+ * `exact`, the binary's own hand-rolled parser) actually reads argv: a lone
28
+ * `-` is always an operand (stdin), and everything left over after options
29
+ * are stripped is checked against `operands`. `--` ends option parsing for
30
+ * every later word under `getopt` only — an `exact` parser keeps evaluating
31
+ * its option words after `--` (measured: GNU `find -- d -fprint /tmp/x`
32
+ * wrote the file), so there `--` is refused as an unrecognised word rather
33
+ * than treated as end-of-checks.
34
+ */
35
+ export function matchesGrammar(words, grammar) {
36
+ if (grammar.args === 'any') {
37
+ return { ok: true };
38
+ }
39
+ const flags = new Set(grammar.flags ?? []);
40
+ const valueFlags = new Set(grammar.valueFlags ?? []);
41
+ const optionalValueFlags = new Set(grammar.optionalValueFlags ?? []);
42
+ const operands = [];
43
+ let optionsEnded = false;
44
+ for (let i = 0; i < words.length; i++) {
45
+ const word = words[i];
46
+ // A lone '-' is stdin, not an option — and once '--' has been seen,
47
+ // nothing looks like an option any more.
48
+ const looksLikeOption = !optionsEnded && word.length > 1 && word.startsWith('-');
49
+ // POSIX option order, opt-in per entry — and ahead of the getopt `--`
50
+ // branch below on purpose: `--` is itself an option-looking word, and an
51
+ // implementation that does not permute (macOS uniq's `+`-prefixed
52
+ // optstring, BSD ifconfig) has already stopped option scanning at the
53
+ // first operand, so it reads `--` as an operand — uniq's is the OUTFILE
54
+ // it overwrites (a file literally named `--`), ifconfig's is an interface
55
+ // configuration word. A permuting implementation (GNU) would treat the
56
+ // same word as the terminator it spells, so the word cannot be judged one
57
+ // way for both: `optionsBeforeOperands` refuses it, and only entries
58
+ // where some implementation does not permute set the flag.
59
+ if (grammar.optionsBeforeOperands && operands.length > 0 && looksLikeOption) {
60
+ return { ok: false, word };
61
+ }
62
+ // getopt only. An `exact` parser never hands `--` this meaning: find
63
+ // keeps reading primaries after it, so accepting `--` there would end
64
+ // the checks at exactly the word an attacker can hide behind.
65
+ if (grammar.args === 'getopt' && !optionsEnded && word === '--') {
66
+ optionsEnded = true; // every later word is an operand, `--` itself is not one
67
+ continue;
68
+ }
69
+ if (!looksLikeOption) {
70
+ operands.push(word);
71
+ continue;
72
+ }
73
+ // git's `-<n>` revision limit (`-20`): a whole word of digits, accepted
74
+ // as a no-value option before any cluster or long-option parsing sees it.
75
+ // `-2x` is not all digits, so it falls through to the cluster path below
76
+ // and is refused unless its characters are listed. getopt style only.
77
+ //
78
+ // An explicit entry always wins over this shortcut, but only the leading
79
+ // `-<digit>` *character* is effective: the cluster parser below reads
80
+ // getopt's way, one character at a time, and never the whole word. An
81
+ // entry naming just `-20` therefore does not make `-20` pass — the
82
+ // shortcut stands down (the word is "explicitly listed") and the cluster
83
+ // parser then refuses it unless `-2` is listed too. Fail-closed, which is
84
+ // the right failure for a listing the binary would not read that way.
85
+ // Ambiguity is refused here as well, the same as every other path —
86
+ // otherwise a name the caller listed in more than one list would be
87
+ // silently accepted through this shortcut instead of being caught.
88
+ if (grammar.args === 'getopt' && grammar.numericShort && /^-\d+$/.test(word)) {
89
+ const shortChar = word.slice(0, 2); // '-' + first digit
90
+ if (isAmbiguousName(word, flags, valueFlags, optionalValueFlags) ||
91
+ isAmbiguousName(shortChar, flags, valueFlags, optionalValueFlags)) {
92
+ return { ok: false, word };
93
+ }
94
+ const explicitlyListed = flags.has(word) ||
95
+ valueFlags.has(word) ||
96
+ optionalValueFlags.has(word) ||
97
+ flags.has(shortChar) ||
98
+ valueFlags.has(shortChar) ||
99
+ optionalValueFlags.has(shortChar);
100
+ if (!explicitlyListed) {
101
+ continue; // no explicit entry claims this digit — take the numeric shortcut
102
+ }
103
+ // An explicit entry claims this word or its leading digit character:
104
+ // fall through to ordinary short-cluster parsing below, which applies
105
+ // it correctly (including consuming a separate value word for a
106
+ // `valueFlags` entry, which this shortcut must never do).
107
+ }
108
+ if (grammar.args === 'getopt' && !word.startsWith('--')) {
109
+ // Short cluster: read character by character, the way getopt does.
110
+ const cluster = matchShortCluster(word, words, i, flags, valueFlags, optionalValueFlags);
111
+ if (!cluster.ok)
112
+ return cluster;
113
+ i = cluster.nextIndex; // the `for` loop's own i++ then lands on the next unread word
114
+ continue;
115
+ }
116
+ // A long option (getopt) or any '-'-prefixed word (exact — there is no
117
+ // separate short-cluster path, so this is also where '-n' is checked).
118
+ // Only getopt splits '=value' off the name; exact matches the raw word,
119
+ // so '--name=value' matches only if that whole string was listed.
120
+ const eqIndex = grammar.args === 'getopt' ? word.indexOf('=') : -1;
121
+ const name = eqIndex === -1 ? word : word.slice(0, eqIndex);
122
+ // A name in more than one of the three lists is a grammar bug, not a
123
+ // call this matcher can make on the binary's behalf — accepting it would
124
+ // have to pick a side (does it take a value, an optional value, or
125
+ // none?), and picking wrong is exactly the hole the correctness
126
+ // conditions warn about. Refuse rather than guess.
127
+ if (isAmbiguousName(name, flags, valueFlags, optionalValueFlags)) {
128
+ return { ok: false, word };
129
+ }
130
+ if (valueFlags.has(name)) {
131
+ if (eqIndex !== -1) {
132
+ continue; // '--name=value': the value is attached, nothing left to consume
133
+ }
134
+ if (i + 1 >= words.length) {
135
+ return { ok: false, word: name }; // a value flag with nothing after it
136
+ }
137
+ i++; // consume the next word as the value
138
+ continue;
139
+ }
140
+ if (optionalValueFlags.has(name)) {
141
+ // Bare, or (getopt) '--name=value' with the value already attached to
142
+ // this same word via `name`/`eqIndex` above. Either way there is
143
+ // nothing left to consume, and the next word is never touched.
144
+ continue;
145
+ }
146
+ if (flags.has(name) && eqIndex === -1) {
147
+ continue; // a no-value flag never accepts an attached '=value'
148
+ }
149
+ return { ok: false, word };
150
+ }
151
+ return checkOperands(operands, grammar.operands);
152
+ }
153
+ /**
154
+ * True when `name` appears in more than one of the three option lists — the
155
+ * ambiguity the correctness conditions require every path to refuse.
156
+ */
157
+ function isAmbiguousName(name, flags, valueFlags, optionalValueFlags) {
158
+ const inFlags = flags.has(name);
159
+ const inValueFlags = valueFlags.has(name);
160
+ const inOptionalValueFlags = optionalValueFlags.has(name);
161
+ return ((inFlags && inValueFlags) || (inFlags && inOptionalValueFlags) || (inValueFlags && inOptionalValueFlags));
162
+ }
163
+ /**
164
+ * Reads one getopt-style short-option cluster (`-rn5`) character by
165
+ * character. `flags` characters just continue the scan; a `valueFlags`
166
+ * character takes the rest of the word as its value, or the next word if
167
+ * the rest is empty; an `optionalValueFlags` character takes the rest of the
168
+ * word as its value if there is any, never the next word, and either way
169
+ * ends the scan of this word; anything else refuses the whole cluster.
170
+ *
171
+ * Returns the index the outer loop's own `i++` should land past — unchanged
172
+ * when the value was attached, optional, or there was none to take,
173
+ * `index + 1` only when a required value flag consumed the next word.
174
+ */
175
+ function matchShortCluster(word, words, index, flags, valueFlags, optionalValueFlags) {
176
+ for (let c = 1; c < word.length; c++) {
177
+ const ch = '-' + word[c];
178
+ // Same ambiguity refusal as the long/exact path, checked first so a
179
+ // char present in more than one list is refused here too, not silently
180
+ // treated as belonging to just one of them (which the long/exact path
181
+ // would not do either).
182
+ if (isAmbiguousName(ch, flags, valueFlags, optionalValueFlags))
183
+ return { ok: false, word };
184
+ if (flags.has(ch))
185
+ continue;
186
+ if (valueFlags.has(ch)) {
187
+ const rest = word.slice(c + 1);
188
+ if (rest.length > 0)
189
+ return { ok: true, nextIndex: index };
190
+ if (index + 1 >= words.length)
191
+ return { ok: false, word: ch };
192
+ return { ok: true, nextIndex: index + 1 };
193
+ }
194
+ if (optionalValueFlags.has(ch)) {
195
+ // The rest of the word (if any) is this option's value; the scan of
196
+ // this word ends here either way, and the next word is never consumed.
197
+ return { ok: true, nextIndex: index };
198
+ }
199
+ return { ok: false, word };
200
+ }
201
+ return { ok: true, nextIndex: index };
202
+ }
203
+ /**
204
+ * Checks the operands collected from a pass over `words` against `rules`.
205
+ * `first` is checked only against `operands[0]` — a reader with no operands
206
+ * at all has nothing to violate it with — `each` against every operand, and
207
+ * `max` last, since going over budget is a property of the whole list rather
208
+ * than of any one word.
209
+ */
210
+ function checkOperands(operands, rules) {
211
+ if (!rules)
212
+ return { ok: true };
213
+ for (let i = 0; i < operands.length; i++) {
214
+ const operand = operands[i];
215
+ if (i === 0 && rules.first && !rules.first.includes(operand)) {
216
+ return { ok: false, word: operand };
217
+ }
218
+ if (rules.each) {
219
+ // A caller's /g or /y regex makes RegExp#test stateful: lastIndex
220
+ // carries over from the previous call, so without resetting it here
221
+ // one operand's match position can leak into the next — accepting an
222
+ // operand that should be refused, or the reverse. Reset before every
223
+ // test so each operand is checked fresh, the way a plain (non-sticky,
224
+ // non-global) pattern already behaves.
225
+ rules.each.lastIndex = 0;
226
+ if (!rules.each.test(operand)) {
227
+ return { ok: false, word: operand };
228
+ }
229
+ }
230
+ }
231
+ if (rules.max !== undefined) {
232
+ // A `max` that isn't a non-negative integer (NaN, 1.5, -1, ...) can't be
233
+ // compared against a count safely: `length > NaN` is always false,
234
+ // which would accept any number of operands, and a fractional max reads
235
+ // back out of the operands array at a non-existent index. Treat any
236
+ // such value as the strictest possible bound — no operand is safe to
237
+ // let through under a limit that couldn't be checked — rather than let
238
+ // a malformed grammar entry accept (or silently under-report) unbounded
239
+ // operands.
240
+ const validMax = Number.isInteger(rules.max) && rules.max >= 0;
241
+ const limit = validMax ? rules.max : 0;
242
+ if (operands.length > limit) {
243
+ return { ok: false, word: operands[limit] };
244
+ }
245
+ }
246
+ return { ok: true };
247
+ }
@@ -3,6 +3,7 @@ import { sanitizeCommand } from '../guard/sanitizer.js';
3
3
  import { requestApproval } from '../guard/elicitation.js';
4
4
  import { commandOutput } from './results.js';
5
5
  import { CommandQuota } from '../policy/quota.js';
6
+ import { formatReadOnlyRejection } from '../policy/classifier.js';
6
7
  import { ApprovalGrants } from '../guard/approval-grants.js';
7
8
  /** Policy never ran — the input was rejected at the boundary. */
8
9
  function rejectedEvaluation(commandClass) {
@@ -169,7 +170,15 @@ export function createPipeline({ server, registry, policy, audit, approvalGrantT
169
170
  const { conn, evaluation, approver } = await checkPolicyAndApprove(effective, profileName, opts.toolName);
170
171
  state.evaluation = evaluation;
171
172
  if (opts.enforceClass && evaluation.commandClass !== opts.enforceClass) {
172
- throw new Error(`${opts.toolName} only accepts ${opts.enforceClass} commands, got: ${evaluation.commandClass}`);
173
+ // A command that fell to `safe` because a reader's grammar rejected one
174
+ // of its argv words is one spelling away from the class this tool
175
+ // demands — say which word, or the caller only learns that the tool
176
+ // refused it, not why the reader that ran the exact same binary
177
+ // would not have.
178
+ const rejectionSuffix = evaluation.readOnlyRejection
179
+ ? ` ${formatReadOnlyRejection(evaluation.readOnlyRejection)}`
180
+ : '';
181
+ throw new Error(`${opts.toolName} only accepts ${opts.enforceClass} commands, got: ${evaluation.commandClass}${rejectionSuffix}`);
173
182
  }
174
183
  // Counted after the policy allowed it and before it runs: a denied
175
184
  // command should not burn quota, and an allowed one should be counted
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ssh-mcp",
3
3
  "license": "MIT",
4
- "version": "2.11.0",
4
+ "version": "2.13.0",
5
5
  "description": "MCP server exposing policy-gated, audited SSH access for Linux and Windows hosts via the Model Context Protocol.",
6
6
  "mcpName": "io.github.tufantunc/ssh-mcp",
7
7
  "type": "module",