@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 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
 
@@ -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:
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@klhapp/skillmux",
3
- "version": "1.9.1",
3
+ "version": "1.9.2",
4
4
  "description": "Skill management and retrieval for AI agents: sync native skills across clients and route the long tail over MCP",
5
5
  "type": "module",
6
6
  "private": false,
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 });
@@ -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: { enabled: boolean; requests_per_minute: number; trust_proxy?: boolean }) {
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 (!bucket) {
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
- function safeTokenEquals(a: string, b: string): boolean {
63
- const bufA = Buffer.from(a);
64
- const bufB = Buffer.from(b);
65
- if (bufA.length !== bufB.length) return false;
66
- return timingSafeEqual(bufA, bufB);
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,