ssh-mcp 2.4.1 → 2.5.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 CHANGED
@@ -3,7 +3,7 @@
3
3
  [![NPM Version](https://img.shields.io/npm/v/ssh-mcp)](https://www.npmjs.com/package/ssh-mcp)
4
4
  [![Downloads](https://img.shields.io/npm/dm/ssh-mcp)](https://www.npmjs.com/package/ssh-mcp)
5
5
  [![CI](https://github.com/tufantunc/ssh-mcp/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/tufantunc/ssh-mcp/actions/workflows/ci.yml)
6
- [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/tufantunc/ssh-mcp/badge)](https://scorecard.dev/viewer/?uri=github.com/tufantunc/ssh-mcp)
6
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/tufantunc/ssh-mcp/badge)](https://scorecard.dev/viewer/?uri=github.com/tufantunc/ssh-mcp)
7
7
  [![codecov](https://codecov.io/gh/tufantunc/ssh-mcp/graph/badge.svg?branch=main)](https://codecov.io/gh/tufantunc/ssh-mcp)
8
8
  [![License](https://img.shields.io/github/license/tufantunc/ssh-mcp)](./LICENSE)
9
9
  [![GitHub issues](https://img.shields.io/github/issues/tufantunc/ssh-mcp)](https://github.com/tufantunc/ssh-mcp/issues)
@@ -11,7 +11,78 @@ const commandClassSchema = z.enum(['read-only', 'safe', 'destructive', 'privileg
11
11
  * excluded alongside it rather than reasoned about case by case.
12
12
  */
13
13
  const RESERVED_BINDING_KEYS = ['__proto__', 'constructor', 'prototype'];
14
- const bindingKeySchema = z.string().min(1).refine((key) => !RESERVED_BINDING_KEYS.includes(key), { message: `Reserved name (${RESERVED_BINDING_KEYS.join(', ')}). Rename the role or tier.` });
14
+ const RESERVED_KEY_MESSAGE = `Reserved name (${RESERVED_BINDING_KEYS.join(', ')}). Rename the role or tier.`;
15
+ /** Only the length rule. The reserved-name rule lives in `checkReservedKeys` below. */
16
+ const bindingKeySchema = z.string().min(1);
17
+ /**
18
+ * How deep the walk goes: role -> tier -> classes.
19
+ *
20
+ * The schema descends exactly two levels whatever the input looks like, but this
21
+ * function follows the *data*, so without a bound it recurses as deep as the TOML
22
+ * nests. `smol-toml` parses ten thousand levels happily, and the resulting
23
+ * `RangeError` escapes `loadConfig` — `safeParse` throws rather than returning
24
+ * `{ success: false }`, and that call sits outside the loader's try/catch — so the
25
+ * operator got a stack trace where zod 3 printed a validation error.
26
+ */
27
+ const MAX_BINDING_DEPTH = 2;
28
+ /**
29
+ * The reserved-key check, run against the raw object before `z.record` sees it.
30
+ *
31
+ * `bindingKeySchema` alone stopped being enough at zod 4. Its record
32
+ * implementation never hands `__proto__` to the key schema — it drops the key and
33
+ * returns an object without it, so a config naming that role parsed *cleanly* and
34
+ * came out empty. Measured: `Object.keys` on the parsed TOML shows `__proto__`,
35
+ * `z.record` accepts, and the result has zero keys.
36
+ *
37
+ * zod 4 broke it in two separate ways, both measured. `z.record` never hands
38
+ * `__proto__` to the key schema — it drops the key and returns an object without it.
39
+ * And for the keys it does check, zod 4 replaces the key schema's message with its
40
+ * own `Invalid key in record`, so neither the reserved-name text nor `min(1)`'s
41
+ * reached the operator.
42
+ *
43
+ * The dropped key is not prototype pollution — zod 4 declines to write it at all,
44
+ * which is the safe half. What was lost is the refusal, and downstream catches it
45
+ * only when a profile uses the role: `resolvePolicyRules` throws for a role with no
46
+ * bindings, but a binding block no profile references was dropped in silence.
47
+ *
48
+ * So this owns both rules outright rather than layering over the key schema. The
49
+ * reserved-name `refine` came off `bindingKeySchema` at the same time — the pipe
50
+ * short-circuits, so it could never run, and calling it a second layer would have
51
+ * described something that was not there.
52
+ *
53
+ * `getOwnPropertyNames` and a property descriptor rather than `Object.keys` and a
54
+ * property read. Measured, neither changes the outcome for anything this can
55
+ * receive: `smol-toml` yields enumerable string keys, and an own `__proto__` data
56
+ * property shadows the prototype accessor, so even a direct read returns the key.
57
+ * They are kept because a descriptor cannot run a getter, and because whether a key
58
+ * is enumerable is the TOML parser's decision rather than ours.
59
+ */
60
+ function checkReservedKeys(value, ctx, prefix = []) {
61
+ if (typeof value !== 'object' || value === null)
62
+ return;
63
+ if (prefix.length >= MAX_BINDING_DEPTH)
64
+ return;
65
+ for (const key of Object.getOwnPropertyNames(value)) {
66
+ // The path carries the ancestry, so a reserved tier names the role it sits under.
67
+ // Reporting only the leaf pointed the operator at a top-level role that was not in
68
+ // their file, which is the opposite of a refusal they can act on.
69
+ const path = [...prefix, key];
70
+ if (RESERVED_BINDING_KEYS.includes(key)) {
71
+ ctx.addIssue({ code: 'custom', message: RESERVED_KEY_MESSAGE, path });
72
+ // `continue` rather than `return`: a config with several bad names should hear
73
+ // about all of them, the way the policy engine's coherence check does.
74
+ continue;
75
+ }
76
+ if (key.length === 0) {
77
+ ctx.addIssue({ code: 'custom', message: 'Role and tier names cannot be empty.', path });
78
+ continue;
79
+ }
80
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
81
+ if (descriptor && typeof descriptor.value === 'object' && descriptor.value !== null) {
82
+ checkReservedKeys(descriptor.value, ctx, path);
83
+ }
84
+ }
85
+ }
15
86
  export const defaultsSchema = z.object({
16
87
  defaultProfile: z.string().optional(),
17
88
  sessionMaxPerConnection: z.number().int().positive().default(5),
@@ -78,14 +149,17 @@ export const profileSchema = z.object({
78
149
  * code reads it, so accepting it here would document a key that does nothing.
79
150
  */
80
151
  export const policySchema = z.object({
81
- roleBindings: z.record(bindingKeySchema, z.record(bindingKeySchema, z.array(commandClassSchema))).optional(),
152
+ roleBindings: z.unknown()
153
+ .superRefine(checkReservedKeys)
154
+ .pipe(z.record(bindingKeySchema, z.record(bindingKeySchema, z.array(commandClassSchema))))
155
+ .optional(),
82
156
  denylist: z.array(z.string()).optional(),
83
157
  }).strict();
84
158
  // .strict() on the root as well: without it an unknown section (a typo, or a
85
159
  // key from a newer version) parses cleanly and is dropped with no warning, so
86
160
  // the operator gets a clean startup and none of the behaviour they configured.
87
161
  export const configSchema = z.object({
88
- defaults: defaultsSchema.default({}),
162
+ defaults: defaultsSchema.prefault({}),
89
163
  profiles: z.array(profileSchema).min(1),
90
164
  policy: policySchema.optional(),
91
165
  }).strict();
@@ -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.1",
4
+ "version": "2.5.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",
@@ -35,10 +35,7 @@
35
35
  "@opentelemetry/semantic-conventions": "^1.43.0",
36
36
  "smol-toml": "^1.7.0",
37
37
  "ssh2": "^1.17.0",
38
- "zod": "^3.25.28"
39
- },
40
- "overrides": {
41
- "zod-to-json-schema": "3.24.6"
38
+ "zod": "^4.4.3"
42
39
  },
43
40
  "devDependencies": {
44
41
  "@changesets/changelog-github": "^0.7.0",