@klhapp/skillmux 1.9.0 → 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,26 @@ 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
+
18
+ ## [1.9.1](https://github.com/klhq/skillmux/compare/v1.9.0...v1.9.1) (2026-08-30)
19
+
20
+
21
+ ### Fixed
22
+
23
+ * **install:** reject '.' / '..' skill ids derived during install (path traversal) ([#151](https://github.com/klhq/skillmux/issues/151)) ([2426613](https://github.com/klhq/skillmux/commit/2426613b79c67e2afa267de946492b6b020011bb))
24
+ * **install:** reject scp-like git URLs starting with '-' (argument injection RCE) ([#148](https://github.com/klhq/skillmux/issues/148)) ([6a9db28](https://github.com/klhq/skillmux/commit/6a9db28670201237dd17455592fa30cbce43b58f))
25
+ * **sync:** require approval before creating a new target directory ([#152](https://github.com/klhq/skillmux/issues/152)) ([6fcfd1a](https://github.com/klhq/skillmux/commit/6fcfd1a8beed87e5f88e2bd24b84a94a3adb3d89))
26
+ * **update:** validate skill-id against SKILL_ID_PATTERN before path-joining it ([#150](https://github.com/klhq/skillmux/issues/150)) ([0bfa807](https://github.com/klhq/skillmux/commit/0bfa8071a80ea4f70b2e6974cb2cb439666f8357))
27
+
8
28
  ## [1.9.0](https://github.com/klhq/skillmux/compare/v1.8.0...v1.9.0) (2026-08-30)
9
29
 
10
30
 
@@ -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.0",
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,
@@ -82,10 +83,12 @@ import {
82
83
  import { getStats, renderStatsText, type StatsResponse } from "./stats";
83
84
  import {
84
85
  installPostMergeHook,
86
+ resolveProjectPinDir,
85
87
  restoreMonolith as restoreMonolithTarget,
86
88
  syncProjectTargets,
87
89
  syncTarget,
88
90
  writeLocalVaultMarker,
91
+ type ProjectGroupInput,
89
92
  } from "./sync";
90
93
  import { scanVault, vaultResolutionOrder } from "./vault";
91
94
 
@@ -771,21 +774,45 @@ function parseSyncArgs(args: string[]): {
771
774
  dryRun: boolean;
772
775
  restoreMonolith: boolean;
773
776
  installHook: boolean;
777
+ yes: boolean;
774
778
  } {
775
779
  let dryRun = false;
776
780
  let restoreMonolith = false;
777
781
  let installHook = false;
782
+ let yes = false;
778
783
  for (const arg of args) {
779
784
  if (arg === "--dry-run") dryRun = true;
780
785
  else if (arg === "--restore-monolith") restoreMonolith = true;
781
786
  else if (arg === "--install-hook") installHook = true;
787
+ else if (arg === "--yes") yes = true;
782
788
  else throw new Error(`unknown sync option: ${arg}`);
783
789
  }
784
- return { dryRun, restoreMonolith, installHook };
790
+ return { dryRun, restoreMonolith, installHook, yes };
791
+ }
792
+
793
+ /**
794
+ * A target directory that doesn't exist yet is about to be created by `sync`.
795
+ * `manifest.targets[*].dir` is vault content — readable and writable by whatever
796
+ * populated the vault (a shared git-backed vault pulled in, or a hand-edit) — and
797
+ * `sync` can run unattended via the `--install-hook` post-merge hook. Without this
798
+ * gate, a tampered manifest naming a brand-new path gets that directory silently
799
+ * created (and populated with symlinks) the next time anyone pulls. Creation for
800
+ * an as-yet-unseen directory therefore requires either `--yes` or an interactive
801
+ * confirmation; once the directory exists, later syncs never hit this path again.
802
+ */
803
+ async function confirmNewSyncTarget(label: string, dir: string, yes: boolean): Promise<boolean> {
804
+ if (yes) return true;
805
+ if (!isInteractive()) {
806
+ console.log(
807
+ `${label}: skipped — ${dir} does not exist yet; creating it requires approval. Re-run "skillmux sync --yes", or run "skillmux sync" interactively, once you've confirmed this target is expected.`,
808
+ );
809
+ return false;
810
+ }
811
+ return confirmAction(`${label}: create new target directory ${dir}?`);
785
812
  }
786
813
 
787
814
  async function runSync(args: string[]): Promise<void> {
788
- const { dryRun, restoreMonolith, installHook } = parseSyncArgs(args);
815
+ const { dryRun, restoreMonolith, installHook, yes } = parseSyncArgs(args);
789
816
  const config = await loadConfig();
790
817
  const vaultPath = expandHome(config.vault_path);
791
818
 
@@ -829,6 +856,16 @@ async function runSync(args: string[]): Promise<void> {
829
856
  continue;
830
857
  }
831
858
 
859
+ if (!dryRun && !existsSync(targetDir)) {
860
+ const approved = await confirmNewSyncTarget(targetName, targetDir, yes);
861
+ if (!approved) {
862
+ if (isInteractive()) {
863
+ console.log(`${targetName}: skipped — creating ${targetDir} was not approved`);
864
+ }
865
+ continue;
866
+ }
867
+ }
868
+
832
869
  const suffix = dryRun ? " (dry-run)" : "";
833
870
  const result = syncTarget(
834
871
  {
@@ -851,9 +888,24 @@ async function runSync(args: string[]): Promise<void> {
851
888
 
852
889
  if (target.project_groups.length > 0) {
853
890
  const allGroups = manifest.project ?? {};
854
- const projectGroups = Object.fromEntries(
855
- target.project_groups.map((name) => [name, allGroups[name]!]),
856
- );
891
+ const projectGroups: Record<string, ProjectGroupInput> = {};
892
+ for (const groupName of target.project_groups) {
893
+ const group = allGroups[groupName]!;
894
+ const approvedPaths: string[] = [];
895
+ for (const path of group.paths) {
896
+ // Mirror syncProjectTargets' own `if (!existsSync(path)) continue` so we
897
+ // never prompt for a project path it would silently skip anyway.
898
+ if (!existsSync(path)) continue;
899
+ const pinDir = resolveProjectPinDir(targetDir, path);
900
+ if (dryRun || existsSync(pinDir)) {
901
+ approvedPaths.push(path);
902
+ continue;
903
+ }
904
+ const approved = await confirmNewSyncTarget(`${targetName}/${groupName}`, pinDir, yes);
905
+ if (approved) approvedPaths.push(path);
906
+ }
907
+ projectGroups[groupName] = { ...group, paths: approvedPaths };
908
+ }
857
909
  const projectResults = syncProjectTargets(
858
910
  { vaultPath, targetDir, targetName, projectGroups, localVaultPaths },
859
911
  { dryRun },
@@ -1437,7 +1489,12 @@ async function runInit(
1437
1489
  ) {
1438
1490
  console.log(`\n${printLastMile()}`);
1439
1491
  }
1440
- if (guided && sync && confirmedTargets.length > 0) await runSync([]);
1492
+ // Reaching this point already required approval above (--yes, or an accepted
1493
+ // confirmAction naming these exact targets/dirs) — that approval covers whatever
1494
+ // new target directories this init just adopted, so runSync's own new-target
1495
+ // confirmation gate would just be a redundant (and non-interactively,
1496
+ // silently-skipping) re-ask.
1497
+ if (guided && sync && confirmedTargets.length > 0) await runSync(["--yes"]);
1441
1498
  }
1442
1499
 
1443
1500
  function parseReportArgs(args: string[]): {
@@ -1563,15 +1620,18 @@ function parseInstallArgs(args: string[]): {
1563
1620
  force: boolean;
1564
1621
  dryRun: boolean;
1565
1622
  failOn?: ScanSeverity;
1623
+ allowLocalSource: boolean;
1566
1624
  } {
1567
1625
  let repo: string | undefined;
1568
1626
  let force = false;
1569
1627
  let dryRun = false;
1570
1628
  let failOn: ScanSeverity | undefined;
1629
+ let allowLocalSource = false;
1571
1630
  for (let i = 0; i < args.length; i++) {
1572
1631
  const option = args[i];
1573
1632
  if (option === "--force") force = true;
1574
1633
  else if (option === "--dry-run") dryRun = true;
1634
+ else if (option === "--allow-local-source") allowLocalSource = true;
1575
1635
  else if (option === "--fail-on") {
1576
1636
  const value = args[++i];
1577
1637
  if (value !== "low" && value !== "medium" && value !== "high") {
@@ -1588,21 +1648,26 @@ function parseInstallArgs(args: string[]): {
1588
1648
  repo = option;
1589
1649
  }
1590
1650
  }
1591
- return { repo, force, dryRun, failOn };
1651
+ return { repo, force, dryRun, failOn, allowLocalSource };
1592
1652
  }
1593
1653
 
1594
1654
  async function runInstall(
1595
1655
  args: string[],
1596
1656
  options: { isJson: boolean },
1597
1657
  ): Promise<void> {
1598
- const { repo, force, dryRun, failOn } = parseInstallArgs(args);
1658
+ const { repo, force, dryRun, failOn, allowLocalSource } = parseInstallArgs(args);
1599
1659
  if (!repo) {
1600
1660
  throw new Error(
1601
- "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]",
1602
1662
  );
1603
1663
  }
1604
1664
 
1605
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
+ }
1606
1671
  const cloneDir = await cloneToTemp(source.url);
1607
1672
  try {
1608
1673
  const resolved = resolveSkillDir(
@@ -397,7 +397,12 @@ export async function runProject(
397
397
  writeManifestAtomic(manifestPath, updated);
398
398
  if (request.sync) {
399
399
  try {
400
- await options.sync([]);
400
+ // Reaching here already required approval above (request.yes, or an
401
+ // accepted interactive confirmAction) — that approval covers whatever
402
+ // new target/pin directories this project setup implies, so the
403
+ // downstream sync's own new-target confirmation gate would just be a
404
+ // redundant (and, non-interactively, silently-skipping) re-ask.
405
+ await options.sync(["--yes"]);
401
406
  } catch (error) {
402
407
  throw new Error(
403
408
  `project configuration was saved, but sync failed; fix the reported issue and run "skillmux sync": ${
@@ -14,6 +14,7 @@ import { emitSuccess } from "../output";
14
14
  import { hashSkillContent, readSkillOrigin, writeSkillOrigin } from "../provenance";
15
15
  import type { SkillOrigin } from "../provenance";
16
16
  import { type ScanFinding, type ScanSeverity, scanExitCode } from "../scan";
17
+ import { SKILL_ID_PATTERN } from "../vault";
17
18
  import { confirmIfNeeded } from "./shared";
18
19
  import { checkOutdated } from "./outdated";
19
20
 
@@ -38,6 +39,16 @@ async function resolveCandidateOrigins(
38
39
  allowLocalSource: boolean,
39
40
  ): Promise<{ skillId: string; origin: SkillOrigin }[]> {
40
41
  if (skillId) {
42
+ // skillId (the CLI's positional <skill-id>) is joined straight into vaultPath
43
+ // below, and that join ultimately reaches installIntoVault's rmSync(recursive)
44
+ // + cpSync on the write path — a "../"-shaped value escapes the vault and lets
45
+ // `skillmux update` delete and overwrite an arbitrary directory on disk. Batch
46
+ // mode never hits this because checkOutdated only enumerates real vault entries
47
+ // (already SKILL_ID_PATTERN-filtered); the explicit single-skill path is the
48
+ // only one that takes this string directly from argv, so validate it here.
49
+ if (!SKILL_ID_PATTERN.test(skillId)) {
50
+ throw new Error(`invalid skill id "${skillId}": expected lowercase letters, digits, and hyphens only`);
51
+ }
41
52
  let origin: SkillOrigin | null;
42
53
  try {
43
54
  origin = readSkillOrigin(join(vaultPath, skillId));
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/install.ts CHANGED
@@ -13,7 +13,14 @@ const GIT_URL_PREFIXES = ["http://", "https://", "git://", "ssh://", "file://"];
13
13
  const SCP_LIKE_URL_PATTERN = /^[^/\s]+@[^/\s]+:/;
14
14
 
15
15
  export function isGitUrl(repo: string): boolean {
16
- return GIT_URL_PREFIXES.some((prefix) => repo.startsWith(prefix)) || SCP_LIKE_URL_PATTERN.test(repo);
16
+ if (GIT_URL_PREFIXES.some((prefix) => repo.startsWith(prefix))) return true;
17
+ // scp-like syntax (user@host:path) has no URL scheme, so whatever accepts it here
18
+ // hands the raw string to `git clone`/`git ls-remote` as a bare positional argument.
19
+ // If that string starts with `-`, git's own option parser reads it as a flag, not a
20
+ // repository — verified against git 2.55: `--upload-pack=<cmd>@host:path` makes git
21
+ // run `<cmd>` as a real local shell command instead of contacting a remote. Reject
22
+ // it outright rather than let a crafted string reach that argv slot.
23
+ return SCP_LIKE_URL_PATTERN.test(repo) && !repo.startsWith("-");
17
24
  }
18
25
 
19
26
  /** A `file://` source_url reaches the local filesystem directly, not just a network
@@ -170,14 +177,32 @@ export interface ResolvedSkillDir {
170
177
  dir: string;
171
178
  }
172
179
 
180
+ /** The returned skillId is joined straight into vaultPath by installIntoVault's callers
181
+ * and fed to a real rmSync(recursive)+cpSync overwrite. Both branches below can produce
182
+ * "." or ".." for a crafted-but-plausible input: `skill_path` of "." (e.g. `skillmux
183
+ * install owner/repo/.`) survives the ".." segment check since "." isn't "..", and its
184
+ * basename is "." too; `fallbackName` comes from deriveRepoName(url), which can return
185
+ * ".." for a url whose last "/"- or ":"-delimited segment is literally "..". Verified
186
+ * end-to-end against the real CLI binary: the former makes `install --force` wipe the
187
+ * entire vault, the latter makes it wipe the vault's parent directory. Neither can ever
188
+ * legitimately be a skill id, so reject both outright rather than let them reach a join. */
189
+ function rejectTraversalSkillId(skillId: string): void {
190
+ if (skillId === "." || skillId === "..") {
191
+ throw new Error(`invalid skill id "${skillId}"`);
192
+ }
193
+ }
194
+
173
195
  export function resolveSkillDir(cloneDir: string, fallbackName: string, skillPath?: string): ResolvedSkillDir {
174
196
  if (skillPath) {
175
197
  if (skillPath.startsWith("/") || skillPath.split("/").includes("..")) {
176
198
  throw new Error(`invalid skill_path "${skillPath}": must be a relative path within the repo`);
177
199
  }
178
- return { skillId: basename(skillPath), dir: join(cloneDir, skillPath) };
200
+ const skillId = basename(skillPath);
201
+ rejectTraversalSkillId(skillId);
202
+ return { skillId, dir: join(cloneDir, skillPath) };
179
203
  }
180
204
  if (existsSync(join(cloneDir, "SKILL.md"))) {
205
+ rejectTraversalSkillId(fallbackName);
181
206
  return { skillId: fallbackName, dir: cloneDir };
182
207
  }
183
208
  const discovered = readdirSync(cloneDir, { withFileTypes: true })
@@ -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,