@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 +20 -0
- package/docs/deployment.md +10 -0
- package/docs/skill-management.md +11 -0
- package/package.json +1 -1
- package/src/cli.ts +74 -9
- package/src/commands/project.ts +6 -1
- package/src/commands/update.ts +11 -0
- package/src/config.ts +12 -0
- package/src/doctor.ts +25 -1
- package/src/install.ts +27 -2
- package/src/rate-limiter.ts +29 -2
- package/src/server.ts +40 -7
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
|
|
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,
|
|
@@ -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 =
|
|
855
|
-
|
|
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
|
-
|
|
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(
|
package/src/commands/project.ts
CHANGED
|
@@ -397,7 +397,12 @@ export async function runProject(
|
|
|
397
397
|
writeManifestAtomic(manifestPath, updated);
|
|
398
398
|
if (request.sync) {
|
|
399
399
|
try {
|
|
400
|
-
|
|
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": ${
|
package/src/commands/update.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 })
|
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,
|