ssh-mcp 2.4.1 → 2.4.2
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/build/policy/classifier.js +67 -0
- package/package.json +1 -1
|
@@ -543,6 +543,62 @@ export function extractBinary(command) {
|
|
|
543
543
|
const parts = cmd.split(/\s+/);
|
|
544
544
|
return parts[0] || '';
|
|
545
545
|
}
|
|
546
|
+
/**
|
|
547
|
+
* The word a segment will actually execute, read past anything that is not it.
|
|
548
|
+
*
|
|
549
|
+
* Distinct from `elevatedBinary`, which answers "does this ask for elevation".
|
|
550
|
+
* This answers "what is the name of the thing that runs", so it can be asked
|
|
551
|
+
* whether that name is knowable at all.
|
|
552
|
+
*/
|
|
553
|
+
function effectiveCommandWord(words) {
|
|
554
|
+
let i = 0;
|
|
555
|
+
while (i < words.length) {
|
|
556
|
+
const raw = words[i];
|
|
557
|
+
const word = stripPath(unquote(raw));
|
|
558
|
+
if (PRIVILEGE_PREFIXES.has(word) || EXEC_WRAPPERS.has(word)) {
|
|
559
|
+
i++;
|
|
560
|
+
while (i < words.length && words[i].startsWith('-')) {
|
|
561
|
+
const consumesValue = PREFIX_VALUE_FLAGS.has(words[i]);
|
|
562
|
+
i++;
|
|
563
|
+
if (consumesValue)
|
|
564
|
+
i++;
|
|
565
|
+
}
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
if (ASSIGNMENT.test(raw) || raw.startsWith('-') || BARE_NUMERIC.test(raw)) {
|
|
569
|
+
i++;
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
return raw;
|
|
573
|
+
}
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Whether any segment runs a command this process cannot name.
|
|
578
|
+
*
|
|
579
|
+
* The class — and with it the approval gate — is decided from the literal text of
|
|
580
|
+
* the command word. A word carrying `$` or a backtick is a name the shell resolves
|
|
581
|
+
* at run time, so `$S id` was classified as though `$S` were a binary, and came
|
|
582
|
+
* out `safe` (GHSA-fj9r-f47j-c73x).
|
|
583
|
+
*
|
|
584
|
+
* Resolving the variable is not the answer and cannot be: a session run keeps the
|
|
585
|
+
* caller's shell state, so `S=sudo` and `$S id` can arrive as two separate calls,
|
|
586
|
+
* and a variable exported in the target's own profile is never visible here at
|
|
587
|
+
* all. What is answerable is whether we know the name — and when we do not, saying
|
|
588
|
+
* so is the only honest class.
|
|
589
|
+
*
|
|
590
|
+
* Only the command word, never the arguments. `echo $HOME` names a command we know;
|
|
591
|
+
* promoting that would put a prompt on most ordinary shell usage.
|
|
592
|
+
*/
|
|
593
|
+
function hasUnnameableCommand(command) {
|
|
594
|
+
for (const raw of command.split(/[;&|\n]/)) {
|
|
595
|
+
const words = raw.trim().split(/\s+/).filter(Boolean);
|
|
596
|
+
const head = effectiveCommandWord(words);
|
|
597
|
+
if (head !== null && /[$`]/.test(head))
|
|
598
|
+
return true;
|
|
599
|
+
}
|
|
600
|
+
return false;
|
|
601
|
+
}
|
|
546
602
|
export function classifyCommand(command, depth = 0) {
|
|
547
603
|
const trimmed = command.trim();
|
|
548
604
|
const binary = extractBinary(trimmed);
|
|
@@ -588,6 +644,17 @@ export function classifyCommand(command, depth = 0) {
|
|
|
588
644
|
if (isDestructive(trimmed) || hasDisqualifyingArgs(trimmed)) {
|
|
589
645
|
return { binary, fullCommand, class: 'destructive' };
|
|
590
646
|
}
|
|
647
|
+
// Below this point every branch assumes the command word names something we
|
|
648
|
+
// recognised. When it is a variable expansion it names nothing we can check, so
|
|
649
|
+
// the allowlist must not be consulted — `$S` is not on it, and falling through to
|
|
650
|
+
// the default made an unknown command `safe`.
|
|
651
|
+
//
|
|
652
|
+
// `destructive` rather than `privileged`: this is "we cannot tell", not "this is
|
|
653
|
+
// root". It gates on approval instead of refusing outright, which keeps
|
|
654
|
+
// `$PREFIX/bin/tool` usable for a role that holds `destructive` on the tier.
|
|
655
|
+
if (hasUnnameableCommand(trimmed)) {
|
|
656
|
+
return { binary, fullCommand, class: 'destructive' };
|
|
657
|
+
}
|
|
591
658
|
const twoWordPrefix = fullCommand.split(/\s+/).slice(0, 2).join(' ');
|
|
592
659
|
if (READ_ONLY_ALLOWLIST.has(binary) || READ_ONLY_ALLOWLIST.has(twoWordPrefix)) {
|
|
593
660
|
if (SHELL_CONTROL_CHARS.test(trimmed)) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ssh-mcp",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "2.4.
|
|
4
|
+
"version": "2.4.2",
|
|
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",
|