ssh-mcp 2.19.0 → 2.19.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.
- package/README.md +8 -0
- package/build/policy/classifier.js +52 -21
- package/build/policy/engine.js +3 -3
- package/build/tools/pipeline.js +6 -5
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -593,6 +593,14 @@ read-only only with the options and operands its grammar lists; `--foo` is not a
|
|
|
593
593
|
there. ``), so the caller can see which spelling of the same command still works. See
|
|
594
594
|
[SECURITY.md](./SECURITY.md) for the residual risks this leaves open.
|
|
595
595
|
|
|
596
|
+
A reader also falls to `safe` when its command carries a shell metacharacter — `;` `&` `|`
|
|
597
|
+
`<` `>` `` ` `` `$` `(` `)` `{` `}`, a newline or a carriage return — quoted or not, because
|
|
598
|
+
the allowlist vouches for the binary, never for what a shell might do with the rest of the
|
|
599
|
+
line. The refusal names the first such character. For `sftp-list` and `sftp-download` it is
|
|
600
|
+
in the remote path, so `/srv/backup(old)` is refused for a `readOnly` profile with
|
|
601
|
+
`` The remote path contains `(`, which counts as a shell metacharacter here, so `sftp:list`
|
|
602
|
+
is read-only only for a path without one. ``
|
|
603
|
+
|
|
596
604
|
A separate **forbidden** list is never allowed, whatever the role or approval
|
|
597
605
|
policy: `rm -rf /`, `mkfs`, `dd of=/dev/`, `shutdown`, `curl|sh`, fork bombs,
|
|
598
606
|
writes naming `/etc/cron`, `/etc/systemd` or any user's `authorized_keys`,
|
|
@@ -2428,9 +2428,10 @@ function classifyCommandInDialect(command, depth, dialect) {
|
|
|
2428
2428
|
if (CLASS_RANK[parsed.class] > CLASS_RANK[highest.class])
|
|
2429
2429
|
highest = parsed;
|
|
2430
2430
|
}
|
|
2431
|
-
// `readOnlyRejection` only ever describes the outer command's
|
|
2432
|
-
//
|
|
2433
|
-
//
|
|
2431
|
+
// `readOnlyRejection` only ever describes why the outer command's reader was
|
|
2432
|
+
// refused read-only — a grammar rejection or a shell metacharacter, see
|
|
2433
|
+
// `ReadOnlyRejection` — and only means anything when that is also what decided
|
|
2434
|
+
// the final class. `highest === outer` (reference equality) is exactly that
|
|
2434
2435
|
// condition: the synthetic floor and the nested-command loop above only
|
|
2435
2436
|
// ever replace `highest` with something of strictly higher rank than
|
|
2436
2437
|
// `outer`'s, so if either one fired, `highest` is no longer `outer` and the
|
|
@@ -2472,8 +2473,20 @@ function classifyOuter(trimmed, dialect) {
|
|
|
2472
2473
|
const twoWordPrefix = words.slice(0, 2).join(' ');
|
|
2473
2474
|
if (READ_ONLY_ALLOWLIST.has(binary) || READ_ONLY_ALLOWLIST.has(twoWordPrefix)
|
|
2474
2475
|
|| READ_ONLY_SYNTHETIC.has(binary)) {
|
|
2475
|
-
|
|
2476
|
-
|
|
2476
|
+
// Whichever key matched decides both which entry answers and how many
|
|
2477
|
+
// leading words are the command name rather than an argument: a two-word
|
|
2478
|
+
// entry's grammar reads words[2..], a one-word entry's reads words[1..]. An
|
|
2479
|
+
// SFTP verb's two-word prefix is never a key, so it is named by itself.
|
|
2480
|
+
const matchedTwoWord = READ_ONLY_ALLOWLIST.has(twoWordPrefix);
|
|
2481
|
+
const readerName = matchedTwoWord ? twoWordPrefix : binary;
|
|
2482
|
+
const metacharacter = SHELL_CONTROL_CHARS.exec(trimmed);
|
|
2483
|
+
if (metacharacter) {
|
|
2484
|
+
return {
|
|
2485
|
+
binary,
|
|
2486
|
+
fullCommand,
|
|
2487
|
+
class: 'safe',
|
|
2488
|
+
readOnlyRejection: { binary: readerName, word: metacharacter[0], cause: 'shell-metacharacter' },
|
|
2489
|
+
};
|
|
2477
2490
|
}
|
|
2478
2491
|
// The SFTP verbs are synthesised by the tool layer, never typed by a caller,
|
|
2479
2492
|
// so there is no argv for a grammar to read — READ_ONLY_SYNTHETIC stays
|
|
@@ -2481,11 +2494,6 @@ function classifyOuter(trimmed, dialect) {
|
|
|
2481
2494
|
if (READ_ONLY_SYNTHETIC.has(binary)) {
|
|
2482
2495
|
return { binary, fullCommand, class: 'read-only' };
|
|
2483
2496
|
}
|
|
2484
|
-
// Whichever key matched decides both which entry answers and how many
|
|
2485
|
-
// leading words are the command name rather than an argument: a two-word
|
|
2486
|
-
// entry's grammar reads words[2..], a one-word entry's reads words[1..].
|
|
2487
|
-
const matchedTwoWord = READ_ONLY_ALLOWLIST.has(twoWordPrefix);
|
|
2488
|
-
const readerName = matchedTwoWord ? twoWordPrefix : binary;
|
|
2489
2497
|
const entry = READERS[readerName];
|
|
2490
2498
|
const argWords = words.slice(matchedTwoWord ? 2 : 1);
|
|
2491
2499
|
// An unquoted glob word is not a word this grammar can vouch for: the
|
|
@@ -2501,7 +2509,7 @@ function classifyOuter(trimmed, dialect) {
|
|
|
2501
2509
|
binary,
|
|
2502
2510
|
fullCommand,
|
|
2503
2511
|
class: 'safe',
|
|
2504
|
-
readOnlyRejection: { binary: readerName, word: globWord },
|
|
2512
|
+
readOnlyRejection: { binary: readerName, word: globWord, cause: 'grammar' },
|
|
2505
2513
|
};
|
|
2506
2514
|
}
|
|
2507
2515
|
}
|
|
@@ -2511,7 +2519,7 @@ function classifyOuter(trimmed, dialect) {
|
|
|
2511
2519
|
binary,
|
|
2512
2520
|
fullCommand,
|
|
2513
2521
|
class: 'safe',
|
|
2514
|
-
readOnlyRejection: { binary: readerName, word: match.word },
|
|
2522
|
+
readOnlyRejection: { binary: readerName, word: match.word, cause: 'grammar' },
|
|
2515
2523
|
};
|
|
2516
2524
|
}
|
|
2517
2525
|
return { binary, fullCommand, class: 'read-only' };
|
|
@@ -2519,17 +2527,40 @@ function classifyOuter(trimmed, dialect) {
|
|
|
2519
2527
|
return { binary, fullCommand, class: 'safe' };
|
|
2520
2528
|
}
|
|
2521
2529
|
/**
|
|
2522
|
-
* The sentence a refusal appends when it carries a `readOnlyRejection`,
|
|
2523
|
-
*
|
|
2524
|
-
*
|
|
2525
|
-
*
|
|
2526
|
-
*
|
|
2527
|
-
*
|
|
2528
|
-
*
|
|
2529
|
-
*
|
|
2530
|
-
*
|
|
2530
|
+
* The sentence a refusal appends when it carries a `readOnlyRejection`, in one of
|
|
2531
|
+
* two shapes chosen by its `cause`. Both name the reader that would have qualified
|
|
2532
|
+
* and what cost it the class.
|
|
2533
|
+
*
|
|
2534
|
+
* - `grammar`: names the argv word, as "is not accepted there" rather than "is not
|
|
2535
|
+
* one of them", because most rejections are operand rules (`hostname NAME`,
|
|
2536
|
+
* `sort /data`) or a listed value flag missing its value, where "is not one of
|
|
2537
|
+
* them" would be false — it may be a perfectly good flag name, just not one bare
|
|
2538
|
+
* or in this position.
|
|
2539
|
+
* - `shell-metacharacter`: names the character, and for the SFTP verbs says it is
|
|
2540
|
+
* in the remote path (#224).
|
|
2541
|
+
*
|
|
2542
|
+
* One function so the engine's role-binding denial and `read-command`'s
|
|
2543
|
+
* `enforceClass` refusal never drift into two spellings of the same sentence.
|
|
2531
2544
|
*/
|
|
2532
2545
|
function formatReadOnlyRejection(rejection) {
|
|
2546
|
+
if (rejection.cause === 'shell-metacharacter') {
|
|
2547
|
+
// `\n` and `\r` are in SHELL_CONTROL_CHARS, and quoted raw they would break the
|
|
2548
|
+
// sentence across lines or render as nothing. A backtick is in it too, and inside
|
|
2549
|
+
// single backticks it would print as three in a row; double backticks with
|
|
2550
|
+
// padding is Markdown's own way to quote one.
|
|
2551
|
+
const char = rejection.word.replace(/\n/g, '\\n').replace(/\r/g, '\\r');
|
|
2552
|
+
const quoted = char === '`' ? '`` ` ``' : `\`${char}\``;
|
|
2553
|
+
// The SFTP verbs' operand is a path the tool layer built the command from, so the
|
|
2554
|
+
// path is what the caller can change — not "the command", which they never typed.
|
|
2555
|
+
// The verb is named as the audit record spells it (`sftp:list`), not as the tool
|
|
2556
|
+
// (`sftp-list`), so the refusal and the record it is filed under read the same.
|
|
2557
|
+
if (READ_ONLY_SYNTHETIC.has(rejection.binary)) {
|
|
2558
|
+
return `The remote path contains ${quoted}, which counts as a shell metacharacter here, ` +
|
|
2559
|
+
`so \`${rejection.binary}\` is read-only only for a path without one.`;
|
|
2560
|
+
}
|
|
2561
|
+
return `\`${rejection.binary}\` is read-only only when the command contains no shell metacharacter; ` +
|
|
2562
|
+
`${quoted} is one.`;
|
|
2563
|
+
}
|
|
2533
2564
|
// The rejected word can be the empty string — a quoted empty operand
|
|
2534
2565
|
// (`hostname ""`) — and two bare backticks would render as a blank where
|
|
2535
2566
|
// the word that cost the class should be. `""` is the shell's own spelling
|
package/build/policy/engine.js
CHANGED
|
@@ -453,9 +453,9 @@ export class PolicyEngine {
|
|
|
453
453
|
}
|
|
454
454
|
}
|
|
455
455
|
// A `safe` command refused here is often not a hard "no" — it is one
|
|
456
|
-
//
|
|
457
|
-
//
|
|
458
|
-
// have worked.
|
|
456
|
+
// spelling away from `read-only` (an argv word the grammar refused, or a
|
|
457
|
+
// shell metacharacter), and without this the reader concludes the binary is
|
|
458
|
+
// forbidden outright and never learns which spelling would have worked.
|
|
459
459
|
if (parsed.readOnlyRejection) {
|
|
460
460
|
reason += ` ${formatReadOnlyRejection(parsed.readOnlyRejection)}`;
|
|
461
461
|
}
|
package/build/tools/pipeline.js
CHANGED
|
@@ -170,11 +170,12 @@ export function createPipeline({ server, registry, policy, audit, approvalGrantT
|
|
|
170
170
|
const { conn, evaluation, approver } = await checkPolicyAndApprove(effective, profileName, opts.toolName, opts.resource);
|
|
171
171
|
state.evaluation = evaluation;
|
|
172
172
|
if (opts.enforceClass && evaluation.commandClass !== opts.enforceClass) {
|
|
173
|
-
// A command that fell to `safe` because a reader
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
173
|
+
// A command that fell to `safe` because a reader was refused read-only —
|
|
174
|
+
// its grammar rejected an argv word, or the command carries a shell
|
|
175
|
+
// metacharacter — is one spelling away from the class this tool demands.
|
|
176
|
+
// Say which word or character, or the caller only learns that the tool
|
|
177
|
+
// refused it, not why the reader that ran the exact same binary would
|
|
178
|
+
// not have.
|
|
178
179
|
const rejectionSuffix = evaluation.readOnlyRejection
|
|
179
180
|
? ` ${formatReadOnlyRejection(evaluation.readOnlyRejection)}`
|
|
180
181
|
: '';
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ssh-mcp",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "2.19.
|
|
4
|
+
"version": "2.19.1",
|
|
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",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"typecheck": "tsc --noEmit -p tsconfig.test.json",
|
|
15
15
|
"inspect": "npx @modelcontextprotocol/inspector node build/index.js",
|
|
16
16
|
"changeset": "changeset",
|
|
17
|
-
"version": "changeset version && node scripts/sync-server-json.mjs",
|
|
17
|
+
"version": "changeset version && node scripts/sync-server-json.mjs && node scripts/sync-lockfile-version.mjs",
|
|
18
18
|
"test": "cross-env SSH_MCP_DISABLE_MAIN=1 vitest --run",
|
|
19
19
|
"test:unit": "cross-env SSH_MCP_DISABLE_MAIN=1 vitest --run test/unit/ test/property/",
|
|
20
20
|
"test:integration": "cross-env SSH_MCP_DISABLE_MAIN=1 vitest --run test/integration/",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"build"
|
|
28
28
|
],
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
30
|
+
"@modelcontextprotocol/sdk": "^1.31.0",
|
|
31
31
|
"@opentelemetry/api": "^1.9.1",
|
|
32
32
|
"@opentelemetry/exporter-trace-otlp-http": "^0.222.0",
|
|
33
33
|
"@opentelemetry/resources": "^2.9.0",
|