ssh-mcp 2.4.2 → 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 +1 -1
- package/build/config/schema.js +77 -3
- package/package.json +2 -5
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/ssh-mcp)
|
|
4
4
|
[](https://www.npmjs.com/package/ssh-mcp)
|
|
5
5
|
[](https://github.com/tufantunc/ssh-mcp/actions/workflows/ci.yml)
|
|
6
|
-
[](https://scorecard.dev/viewer/?uri=github.com/tufantunc/ssh-mcp)
|
|
7
7
|
[](https://codecov.io/gh/tufantunc/ssh-mcp)
|
|
8
8
|
[](./LICENSE)
|
|
9
9
|
[](https://github.com/tufantunc/ssh-mcp/issues)
|
package/build/config/schema.js
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
162
|
+
defaults: defaultsSchema.prefault({}),
|
|
89
163
|
profiles: z.array(profileSchema).min(1),
|
|
90
164
|
policy: policySchema.optional(),
|
|
91
165
|
}).strict();
|
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.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": "^
|
|
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",
|