ssh-mcp 2.12.0 → 2.14.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 +18 -1
- package/build/policy/classifier.js +702 -153
- package/build/policy/engine.js +42 -20
- package/build/policy/reader-grammar.js +247 -0
- package/build/tools/pipeline.js +10 -1
- package/package.json +1 -1
package/build/policy/engine.js
CHANGED
|
@@ -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
|
|
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,
|
|
406
|
+
explainRoleDenial(profile, parsed) {
|
|
407
|
+
const commandClass = parsed.class;
|
|
408
|
+
let reason;
|
|
397
409
|
if (profile.readOnly) {
|
|
398
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/build/tools/pipeline.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
4
|
+
"version": "2.14.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",
|