@klhapp/skillmux 1.9.1 → 1.9.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/CHANGELOG.md +10 -0
- package/docs/deployment.md +10 -0
- package/docs/skill-management.md +11 -0
- package/package.json +1 -1
- package/src/cli.ts +12 -3
- package/src/config.ts +12 -0
- package/src/doctor.ts +25 -1
- package/src/rate-limiter.ts +29 -2
- package/src/server.ts +40 -7
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,16 @@ All notable changes to this project are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.9.2](https://github.com/klhq/skillmux/compare/v1.9.1...v1.9.2) (2026-08-30)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
* **install:** refuse a file:// source without --allow-local-source (SMX-92) ([#156](https://github.com/klhq/skillmux/issues/156)) ([b4ffb4a](https://github.com/klhq/skillmux/commit/b4ffb4abec90f900113bcd5806274b5d5692d23c))
|
|
14
|
+
* **server:** bound rate-limiter bucket map with LRU eviction (SMX-93) ([#155](https://github.com/klhq/skillmux/issues/155)) ([4dd1eb5](https://github.com/klhq/skillmux/commit/4dd1eb5a6d673f11f4ccd556fb4f189cf5fa828f))
|
|
15
|
+
* **server:** compare fixed-length hashes in safeTokenEquals (SMX-94) ([#157](https://github.com/klhq/skillmux/issues/157)) ([ae225c0](https://github.com/klhq/skillmux/commit/ae225c0db88952fedd4e66d4f373455774635900))
|
|
16
|
+
* **server:** refuse to bind a non-loopback host with auth disabled (SMX-91) ([#153](https://github.com/klhq/skillmux/issues/153)) ([2520408](https://github.com/klhq/skillmux/commit/252040867ed73ef1b82c6f97010842a9b399b498))
|
|
17
|
+
|
|
8
18
|
## [1.9.1](https://github.com/klhq/skillmux/compare/v1.9.0...v1.9.1) (2026-08-30)
|
|
9
19
|
|
|
10
20
|
|
package/docs/deployment.md
CHANGED
|
@@ -232,6 +232,16 @@ Keep `trust_proxy = false` unless a trusted reverse proxy overwrites
|
|
|
232
232
|
`X-Forwarded-For`. A client can spoof that header when it reaches Skillmux
|
|
233
233
|
directly.
|
|
234
234
|
|
|
235
|
+
`skillmux serve --transport http` enforces this itself: it refuses to bind a
|
|
236
|
+
non-loopback hostname (including Docker's default `0.0.0.0`) while
|
|
237
|
+
`auth_enabled` is `false`, since that combination leaves `/mcp` and `/stats`
|
|
238
|
+
open to anyone who can reach the port. `skillmux doctor` flags the same
|
|
239
|
+
combination as `server_bind_posture`. If you're relying on network-level
|
|
240
|
+
isolation instead of application auth — e.g. a container with no published
|
|
241
|
+
port, reachable only inside a private Docker network — set
|
|
242
|
+
`SKILLMUX_ALLOW_INSECURE_BIND=true` to start anyway; the server logs a loud
|
|
243
|
+
warning each time it does.
|
|
244
|
+
|
|
235
245
|
## Health and metrics
|
|
236
246
|
|
|
237
247
|
The HTTP server provides:
|
package/docs/skill-management.md
CHANGED
|
@@ -42,6 +42,17 @@ skillmux install owner/repo --fail-on high
|
|
|
42
42
|
The scanner detects suspicious prompt-injection patterns, secrets, and risky
|
|
43
43
|
instructions. Findings remain advisory unless you pass `--fail-on`.
|
|
44
44
|
|
|
45
|
+
`install` refuses a `file://` source by default — a `file://` URL reaches the
|
|
46
|
+
local filesystem directly, so honoring one unconditionally would let anything
|
|
47
|
+
that can hand `skillmux install` a string (a webpage, another tool's output,
|
|
48
|
+
an instruction an agent was told to follow) pull an arbitrary local
|
|
49
|
+
repository into the shared vault. Pass `--allow-local-source` when installing
|
|
50
|
+
from a local repo is a deliberate, interactive choice:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
skillmux install file:///path/to/local/repo --allow-local-source
|
|
54
|
+
```
|
|
55
|
+
|
|
45
56
|
## Scan a vault or candidate
|
|
46
57
|
|
|
47
58
|
```sh
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
cloneToTemp,
|
|
43
43
|
deriveRepoName,
|
|
44
44
|
installIntoVault,
|
|
45
|
+
isLocalFileUrl,
|
|
45
46
|
resolveCloneCommit,
|
|
46
47
|
resolveRepoSource,
|
|
47
48
|
resolveSkillDir,
|
|
@@ -1619,15 +1620,18 @@ function parseInstallArgs(args: string[]): {
|
|
|
1619
1620
|
force: boolean;
|
|
1620
1621
|
dryRun: boolean;
|
|
1621
1622
|
failOn?: ScanSeverity;
|
|
1623
|
+
allowLocalSource: boolean;
|
|
1622
1624
|
} {
|
|
1623
1625
|
let repo: string | undefined;
|
|
1624
1626
|
let force = false;
|
|
1625
1627
|
let dryRun = false;
|
|
1626
1628
|
let failOn: ScanSeverity | undefined;
|
|
1629
|
+
let allowLocalSource = false;
|
|
1627
1630
|
for (let i = 0; i < args.length; i++) {
|
|
1628
1631
|
const option = args[i];
|
|
1629
1632
|
if (option === "--force") force = true;
|
|
1630
1633
|
else if (option === "--dry-run") dryRun = true;
|
|
1634
|
+
else if (option === "--allow-local-source") allowLocalSource = true;
|
|
1631
1635
|
else if (option === "--fail-on") {
|
|
1632
1636
|
const value = args[++i];
|
|
1633
1637
|
if (value !== "low" && value !== "medium" && value !== "high") {
|
|
@@ -1644,21 +1648,26 @@ function parseInstallArgs(args: string[]): {
|
|
|
1644
1648
|
repo = option;
|
|
1645
1649
|
}
|
|
1646
1650
|
}
|
|
1647
|
-
return { repo, force, dryRun, failOn };
|
|
1651
|
+
return { repo, force, dryRun, failOn, allowLocalSource };
|
|
1648
1652
|
}
|
|
1649
1653
|
|
|
1650
1654
|
async function runInstall(
|
|
1651
1655
|
args: string[],
|
|
1652
1656
|
options: { isJson: boolean },
|
|
1653
1657
|
): Promise<void> {
|
|
1654
|
-
const { repo, force, dryRun, failOn } = parseInstallArgs(args);
|
|
1658
|
+
const { repo, force, dryRun, failOn, allowLocalSource } = parseInstallArgs(args);
|
|
1655
1659
|
if (!repo) {
|
|
1656
1660
|
throw new Error(
|
|
1657
|
-
"usage: skillmux install <repo>[/path] [--force] [--fail-on low|medium|high] [--dry-run] [--json]",
|
|
1661
|
+
"usage: skillmux install <repo>[/path] [--force] [--fail-on low|medium|high] [--dry-run] [--allow-local-source] [--json]",
|
|
1658
1662
|
);
|
|
1659
1663
|
}
|
|
1660
1664
|
|
|
1661
1665
|
const source = resolveRepoSource(repo);
|
|
1666
|
+
if (!allowLocalSource && isLocalFileUrl(source.url)) {
|
|
1667
|
+
throw new Error(
|
|
1668
|
+
`"${repo}" is a local (file://) source — pass --allow-local-source to install from it`,
|
|
1669
|
+
);
|
|
1670
|
+
}
|
|
1662
1671
|
const cloneDir = await cloneToTemp(source.url);
|
|
1663
1672
|
try {
|
|
1664
1673
|
const resolved = resolveSkillDir(
|
package/src/config.ts
CHANGED
|
@@ -170,6 +170,18 @@ export function expandHome(path: string): string {
|
|
|
170
170
|
return path.startsWith("~") ? join(homedir(), path.slice(1)) : path;
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
/**
|
|
174
|
+
* True only for hostnames the HTTP server can bind while staying unreachable
|
|
175
|
+
* from outside this machine. Deliberately narrower than adapters.ts's
|
|
176
|
+
* isLoopbackHost, which treats "0.0.0.0" as loopback for a different question
|
|
177
|
+
* (whether an admin *client* is talking to the local machine) — here "0.0.0.0"
|
|
178
|
+
* (and any other wildcard/public address) must read as non-loopback, since
|
|
179
|
+
* binding it is exactly what makes the server reachable from outside (SMX-91).
|
|
180
|
+
*/
|
|
181
|
+
export function isLoopbackBindHost(hostname: string): boolean {
|
|
182
|
+
return hostname === "localhost" || hostname === "::1" || hostname === "127.0.0.1" || hostname.startsWith("127.");
|
|
183
|
+
}
|
|
184
|
+
|
|
173
185
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
174
186
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
175
187
|
}
|
package/src/doctor.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync } from "node:fs";
|
|
2
2
|
import { createClients, RemoteInferenceError } from "./clients";
|
|
3
|
-
import { embeddingDimension, expandHome } from "./config";
|
|
3
|
+
import { embeddingDimension, expandHome, isLoopbackBindHost } from "./config";
|
|
4
4
|
import { describeDeployment, type DeploymentIdentity } from "./deployment";
|
|
5
5
|
import { parseManifest, resolveManifestPath, validateManifest } from "./manifest";
|
|
6
6
|
import { readSkillmuxMarker } from "./sync";
|
|
@@ -51,6 +51,30 @@ export async function diagnose(
|
|
|
51
51
|
}
|
|
52
52
|
checks.push({ name: "vault", ok: existsSync(expandHome(config.vault_path)), detail: expandHome(config.vault_path) });
|
|
53
53
|
|
|
54
|
+
// SMX-91: `serve --transport http` itself refuses to start over this combination
|
|
55
|
+
// (assertSafeBindPosture in server.ts) unless SKILLMUX_ALLOW_INSECURE_BIND is set —
|
|
56
|
+
// surface it here too so it's visible without having to start the HTTP server first.
|
|
57
|
+
// An operator who has already set that env var has made an informed choice, so
|
|
58
|
+
// doctor treats it the same way the server does (ok, not a standing failure) —
|
|
59
|
+
// it doesn't re-litigate a decision the server itself already accepted.
|
|
60
|
+
if (config.server) {
|
|
61
|
+
const hostname = config.server.hostname ?? "127.0.0.1";
|
|
62
|
+
const insecureBindAcknowledged = environment.SKILLMUX_ALLOW_INSECURE_BIND === "true";
|
|
63
|
+
const bindIsSafe = isLoopbackBindHost(hostname) || config.server.auth_enabled || insecureBindAcknowledged;
|
|
64
|
+
checks.push({
|
|
65
|
+
name: "server_bind_posture",
|
|
66
|
+
ok: bindIsSafe,
|
|
67
|
+
detail: !bindIsSafe
|
|
68
|
+
? `${hostname} is reachable beyond this machine with auth_enabled=false — ` +
|
|
69
|
+
"MCP tools and /stats would be open to anyone who can reach this port; " +
|
|
70
|
+
"set server.auth_enabled=true, bind a loopback hostname, or set SKILLMUX_ALLOW_INSECURE_BIND=true"
|
|
71
|
+
: insecureBindAcknowledged && !isLoopbackBindHost(hostname) && !config.server.auth_enabled
|
|
72
|
+
? `${hostname}, auth_enabled=false, acknowledged via SKILLMUX_ALLOW_INSECURE_BIND`
|
|
73
|
+
: `${hostname}, auth_enabled=${config.server.auth_enabled}`,
|
|
74
|
+
failure_kind: bindIsSafe ? undefined : "configuration",
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
54
78
|
for (const localPath of config.local_vault_paths) {
|
|
55
79
|
const expanded = expandHome(localPath);
|
|
56
80
|
checks.push({ name: `local_vault:${localPath}`, ok: existsSync(expanded), detail: expanded });
|
package/src/rate-limiter.ts
CHANGED
|
@@ -3,6 +3,14 @@ interface Bucket {
|
|
|
3
3
|
lastRefillMs: number;
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
+
// SMX-93: bounds memory even when an attacker (behind a trust_proxy-honored
|
|
7
|
+
// reverse proxy) mints unbounded distinct X-Forwarded-For values, or a
|
|
8
|
+
// long-running deployment simply accumulates many distinct legitimate
|
|
9
|
+
// clients over time. Past this many entries, the least-recently-used
|
|
10
|
+
// bucket is evicted to make room — active clients are never evicted ahead
|
|
11
|
+
// of idle ones.
|
|
12
|
+
const DEFAULT_MAX_BUCKETS = 10_000;
|
|
13
|
+
|
|
6
14
|
export interface RateLimitCheckInput {
|
|
7
15
|
nowMs: number;
|
|
8
16
|
auth_enabled: boolean;
|
|
@@ -22,12 +30,19 @@ export class RateLimiter {
|
|
|
22
30
|
private enabled: boolean;
|
|
23
31
|
private requests_per_minute: number;
|
|
24
32
|
private trust_proxy: boolean;
|
|
33
|
+
private max_buckets: number;
|
|
25
34
|
private buckets = new Map<string, Bucket>();
|
|
26
35
|
|
|
27
|
-
constructor(config: {
|
|
36
|
+
constructor(config: {
|
|
37
|
+
enabled: boolean;
|
|
38
|
+
requests_per_minute: number;
|
|
39
|
+
trust_proxy?: boolean;
|
|
40
|
+
max_buckets?: number;
|
|
41
|
+
}) {
|
|
28
42
|
this.enabled = config.enabled;
|
|
29
43
|
this.requests_per_minute = config.requests_per_minute;
|
|
30
44
|
this.trust_proxy = config.trust_proxy ?? false;
|
|
45
|
+
this.max_buckets = config.max_buckets ?? DEFAULT_MAX_BUCKETS;
|
|
31
46
|
}
|
|
32
47
|
|
|
33
48
|
check(input: RateLimitCheckInput): RateLimitCheckResult {
|
|
@@ -59,7 +74,19 @@ export class RateLimiter {
|
|
|
59
74
|
|
|
60
75
|
// 2. Retrieve or initialize bucket
|
|
61
76
|
let bucket = this.buckets.get(id);
|
|
62
|
-
if (
|
|
77
|
+
if (bucket) {
|
|
78
|
+
// Map iteration order is insertion order, so re-inserting on touch
|
|
79
|
+
// marks this entry as most-recently-used and moves it out of the
|
|
80
|
+
// eviction path below.
|
|
81
|
+
this.buckets.delete(id);
|
|
82
|
+
this.buckets.set(id, bucket);
|
|
83
|
+
} else {
|
|
84
|
+
if (this.buckets.size >= this.max_buckets) {
|
|
85
|
+
const oldestId = this.buckets.keys().next().value;
|
|
86
|
+
if (oldestId !== undefined) {
|
|
87
|
+
this.buckets.delete(oldestId);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
63
90
|
bucket = {
|
|
64
91
|
tokens: this.requests_per_minute,
|
|
65
92
|
lastRefillMs: input.nowMs,
|
package/src/server.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
3
3
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { createClients } from "./clients";
|
|
7
|
-
import { loadConfig, resolveConfigPath } from "./config";
|
|
7
|
+
import { isLoopbackBindHost, loadConfig, resolveConfigPath } from "./config";
|
|
8
8
|
import { describeDeployment } from "./deployment";
|
|
9
9
|
import { ConfigWatcher, type ReloadStatus } from "./config-watcher";
|
|
10
10
|
import { RuntimeSnapshotManager } from "./snapshot";
|
|
@@ -40,6 +40,35 @@ export interface ServerHandle {
|
|
|
40
40
|
stop(): Promise<void>;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Docker's documented deployment mode auto-switches hostname to "0.0.0.0"
|
|
45
|
+
* (config.ts's RUNNING_IN_DOCKER override) while server.auth_enabled still
|
|
46
|
+
* defaults to false and allowed_origins defaults to [] — which only blocks
|
|
47
|
+
* requests that carry an Origin header, so a plain server-to-server/curl
|
|
48
|
+
* request sails through unauthenticated. That default combination leaves MCP
|
|
49
|
+
* tools (resolve_skill/fetch_skill) and /stats (raw historical query text)
|
|
50
|
+
* open to anyone who can reach the port — the documented common case, not an
|
|
51
|
+
* edge case (SMX-91). Refuse to start rather than silently exposing it;
|
|
52
|
+
* SKILLMUX_ALLOW_INSECURE_BIND is the explicit, logged escape hatch for
|
|
53
|
+
* operators who rely on network-level isolation instead of application auth.
|
|
54
|
+
*/
|
|
55
|
+
export function assertSafeBindPosture(
|
|
56
|
+
hostname: string,
|
|
57
|
+
authEnabled: boolean,
|
|
58
|
+
env: Record<string, string | undefined> = process.env,
|
|
59
|
+
): void {
|
|
60
|
+
if (isLoopbackBindHost(hostname) || authEnabled) return;
|
|
61
|
+
const message =
|
|
62
|
+
`refusing to bind "${hostname}" (reachable beyond this machine) with server.auth_enabled=false — ` +
|
|
63
|
+
"MCP tools (resolve_skill/fetch_skill) and /stats would be open to anyone who can reach this port. " +
|
|
64
|
+
"Set server.auth_enabled=true (with SKILLMUX_AUTH_TOKEN) or bind a loopback hostname instead. " +
|
|
65
|
+
"To start anyway — e.g. when network isolation is the intended boundary — set SKILLMUX_ALLOW_INSECURE_BIND=true.";
|
|
66
|
+
if (env.SKILLMUX_ALLOW_INSECURE_BIND !== "true") {
|
|
67
|
+
throw new Error(`skillmux: ${message}`);
|
|
68
|
+
}
|
|
69
|
+
console.error(`skillmux: WARNING — ${message}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
43
72
|
let warnedAuthToken = false;
|
|
44
73
|
function resolveAuthToken(envName: string): string {
|
|
45
74
|
const value = process.env[envName];
|
|
@@ -59,11 +88,14 @@ function resolveAuthToken(envName: string): string {
|
|
|
59
88
|
return "";
|
|
60
89
|
}
|
|
61
90
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
91
|
+
// SMX-94: comparing raw buffers made length itself observable — a
|
|
92
|
+
// mismatched-length pair returns before ever reaching timingSafeEqual.
|
|
93
|
+
// Hashing both sides to a fixed 32-byte digest first means every
|
|
94
|
+
// comparison takes the same constant-time path regardless of input length.
|
|
95
|
+
export function safeTokenEquals(a: string, b: string): boolean {
|
|
96
|
+
const hashA = createHash("sha256").update(a).digest();
|
|
97
|
+
const hashB = createHash("sha256").update(b).digest();
|
|
98
|
+
return timingSafeEqual(hashA, hashB);
|
|
67
99
|
}
|
|
68
100
|
|
|
69
101
|
export function createMcpServer(): McpServer {
|
|
@@ -206,6 +238,7 @@ export async function startServer(opts?: {
|
|
|
206
238
|
|
|
207
239
|
const port = opts?.port ?? Number(process.env.PORT || 3000);
|
|
208
240
|
const hostname = config.server?.hostname ?? "127.0.0.1";
|
|
241
|
+
assertSafeBindPosture(hostname, config.server?.auth_enabled ?? false);
|
|
209
242
|
const bunServer = Bun.serve({
|
|
210
243
|
port,
|
|
211
244
|
hostname,
|