@klhapp/skillmux 1.9.1 → 1.9.3

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,31 @@ 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.3](https://github.com/klhq/skillmux/compare/v1.9.2...v1.9.3) (2026-08-31)
9
+
10
+
11
+ ### Added
12
+
13
+ * **security:** add egress allowlist for install/update ([#160](https://github.com/klhq/skillmux/issues/160)) ([08779fd](https://github.com/klhq/skillmux/commit/08779fd67aacb941aa56025aecc05cf52e93e0c9))
14
+ * **security:** centralized secret redaction + tamper-evident admin audit trail ([#162](https://github.com/klhq/skillmux/issues/162)) ([f5335ec](https://github.com/klhq/skillmux/commit/f5335ecacf877d4e75821fe9d4be615718055814))
15
+ * **server:** remote report reporting via a stats-only port + authenticated --context ([#158](https://github.com/klhq/skillmux/issues/158)) ([aa19986](https://github.com/klhq/skillmux/commit/aa19986d43d5557d1ef5c6dd4ff53fca7fc9002a))
16
+ * **server:** runtime resource hardening — body/concurrency bounds and inference egress allowlist ([#161](https://github.com/klhq/skillmux/issues/161)) ([5e98765](https://github.com/klhq/skillmux/commit/5e9876581190aead64434c62f70e8d5e4010a9ad))
17
+
18
+
19
+ ### Chores
20
+
21
+ * force release version to 1.9.3 ([97887fe](https://github.com/klhq/skillmux/commit/97887fe4931b183951dec6859333fb1497f6df91))
22
+
23
+ ## [1.9.2](https://github.com/klhq/skillmux/compare/v1.9.1...v1.9.2) (2026-08-30)
24
+
25
+
26
+ ### Fixed
27
+
28
+ * **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))
29
+ * **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))
30
+ * **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))
31
+ * **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))
32
+
8
33
  ## [1.9.1](https://github.com/klhq/skillmux/compare/v1.9.0...v1.9.1) (2026-08-30)
9
34
 
10
35
 
@@ -192,6 +192,8 @@ hostname = "127.0.0.1"
192
192
  auth_enabled = false
193
193
  auth_token_env = "SKILLMUX_AUTH_TOKEN"
194
194
  allowed_origins = []
195
+ max_body_bytes = 1048576
196
+ max_concurrent_requests = 100
195
197
 
196
198
  [server.rate_limit]
197
199
  enabled = false
@@ -205,6 +207,8 @@ token_env = "SKILLMUX_ADMIN_TOKEN"
205
207
 
206
208
  Defaults are loopback-only (`hostname = "127.0.0.1"`) with CORS deny-by-default (`allowed_origins = []`), so a zero-config `skillmux serve --transport http` is not reachable from the network or from a browser tab on another origin. Docker sets `hostname` to `0.0.0.0` automatically (`RUNNING_IN_DOCKER=true`) since port-mapping needs the container to accept connections on all interfaces.
207
209
 
210
+ `max_body_bytes` (default 1 MiB) and `max_concurrent_requests` (default 100) are positive resource bounds on the `--transport http` listener: unlike `rate_limit`, which is opt-in and off by default, these apply out of the box so the transport is never unbounded by omission. A request whose body exceeds `max_body_bytes` is rejected with `413 Payload Too Large` before it's fully read; once `max_concurrent_requests` requests are in flight, an additional request is rejected with `503 Service Unavailable`. Both are unrelated to `rate_limit`, which bounds request *count* per client over time rather than body size or concurrency.
211
+
208
212
  Before exposing HTTP beyond localhost, set `hostname` to a reachable interface, `auth_enabled = true` with a token, and populate `allowed_origins` with the specific origins that need browser access. `rate_limit.trust_proxy` should stay `false` unless a trusted reverse proxy sets `X-Forwarded-For`: it's otherwise a client-controlled, spoofable header, and trusting it defeats per-client rate limiting.
209
213
 
210
214
  `server.auth_token_env` names the MCP token for AI clients calling `/mcp`.
@@ -219,6 +223,53 @@ Inside the server image, only read-only `config show`, `get`, `validate`,
219
223
  host CLI; the image returns `CONTAINER_COMMAND_UNSUPPORTED` with the exact host
220
224
  command to run. See [Deployment](deployment.md#container-command-contract).
221
225
 
226
+ ## Secret redaction and the admin audit trail
227
+
228
+ Skillmux redacts resolved credential values before they can reach CLI
229
+ output or server logs. Every `*_env`-suffixed config key (`api_key_env`,
230
+ `token_env`, `auth_token_env`) names an environment variable rather than
231
+ storing the secret itself; when an error message would otherwise embed that
232
+ variable's current value — or a credential typed directly into a URL, e.g.
233
+ `https://user:TOKEN@host/repo.git` for private-repo git auth — it is
234
+ replaced with `[REDACTED]` before the CLI's error handler or the server's
235
+ exception logging writes it out. This applies in both human and `--json`
236
+ output and requires no configuration; with no `*_env` keys set, it is a
237
+ no-op.
238
+
239
+ Every successful `PATCH /admin/v1/config` mutation appends one row to an
240
+ `admin_audit` table in the same `audit.sqlite3` used for fetch/resolve
241
+ telemetry, recording the timestamp, changed keys with their old and new
242
+ values, and the resulting config revision hash. A rejected request (stale
243
+ `If-Match`, read-only config) writes no row. Each row's hash is chained to
244
+ the previous row's hash, so any row deleted or edited outside the running
245
+ server breaks the chain — detectable by walking the table and recomputing
246
+ the chain, without a dedicated query endpoint. `admin_audit` rows are
247
+ pruned by the same `[audit] retention_days` setting as the rest of
248
+ `audit.sqlite3`; there is no separate retention config for admin history.
249
+
250
+ ## Egress allowlist
251
+
252
+ ```toml
253
+ [egress]
254
+ allowed_hosts = ["github.com"]
255
+ ```
256
+
257
+ Unset by default, matching Skillmux's opt-in security posture elsewhere.
258
+ When set, `skillmux install` and `skillmux update` refuse to fetch from any
259
+ git host not on the list, checked before the network call — see
260
+ [Managing skills](skill-management.md#restrict-which-hosts-install-and-update-can-reach).
261
+ `file://` sources are exempt (no network egress occurs; they're already
262
+ gated by `--allow-local-source`), and host matching is exact and
263
+ case-insensitive, with no glob support.
264
+
265
+ The same `allowed_hosts` list also gates remote-inference calls: when set,
266
+ a `[inference.embedding]` or `[inference.reranker]` `endpoint` host not on
267
+ the list is rejected before the HTTP request, surfaced the same way as any
268
+ other embedding/reranker configuration error (`resolve_skill` degrades to
269
+ the strongest available retrieval lane rather than failing outright). This
270
+ does not apply to `inference.mode = "local"`, which never makes a network
271
+ call.
272
+
222
273
  ## Tiers and the manifest
223
274
 
224
275
  `skillmux init` and `skillmux sync` manage native delivery by pinning selected
@@ -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,33 @@ 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
+
56
+ ### Restrict which hosts install and update can reach
57
+
58
+ By default `install` and `update` will fetch from any git host. Set
59
+ `[egress] allowed_hosts` in your config to restrict both to an explicit list:
60
+
61
+ ```toml
62
+ [egress]
63
+ allowed_hosts = ["github.com", "git.example.com"]
64
+ ```
65
+
66
+ A host not on the list is rejected before any network fetch, for both a new
67
+ `skillmux install <source>` and a `skillmux update` re-fetching a skill's
68
+ recorded origin. This doesn't apply to `file://` sources, which never leave
69
+ the local filesystem and are already gated by `--allow-local-source` above.
70
+ Leaving `allowed_hosts` unset (the default) leaves both commands unrestricted.
71
+
45
72
  ## Scan a vault or candidate
46
73
 
47
74
  ```sh
@@ -200,6 +227,7 @@ skillmux doctor
200
227
  ```sh
201
228
  skillmux report --since 7d
202
229
  skillmux report --server http://host:3000 --since 7d
230
+ skillmux report --context prod --since 7d
203
231
  ```
204
232
 
205
233
  `skillmux report` aggregates total requests, empty shortlist count and rate,
@@ -213,6 +241,32 @@ Top empty shortlist queries point to missing skills or weak skill descriptions.
213
241
  `--since` accepts windows such as `1h`, `7d`, and `1m`, plus absolute dates and
214
242
  timestamps.
215
243
 
244
+ Register a remote deployment once with `skillmux context add`, then reuse it by
245
+ name instead of retyping `--server` (and, if the deployment requires
246
+ authentication, its token) on every call:
247
+
248
+ ```sh
249
+ skillmux context add prod --server http://host:3000 --token-env SKILLMUX_AUTH_TOKEN
250
+ skillmux report --context prod --since 7d
251
+ ```
252
+
253
+ `--context` and bare `--server` both hit `GET /stats` on the target and require
254
+ an HTTP transport listening there. A stdio-only deployment (the common case
255
+ for an MCP server spawned by a host over stdin/stdout) has no such listener by
256
+ default. Give it a narrow, read-only one — just `/health` and `/stats`, none
257
+ of the MCP tool surface — without switching its primary transport:
258
+
259
+ ```sh
260
+ skillmux serve --transport stdio --stats-port 4317
261
+ ```
262
+
263
+ The stats port inherits the same `[server]` bind-posture rule as the `http`
264
+ transport (see [Configuration](configuration.md#http-server)): binding it
265
+ to anything other than a loopback address requires `server.auth_enabled =
266
+ true` with a token, or it refuses to start. `--stats-port` is rejected
267
+ alongside `--transport http`, since that transport already serves `/stats` on
268
+ `--port`.
269
+
216
270
  ## Target ownership and recovery
217
271
 
218
272
  `skillmux target remove <name> --yes` removes the manifest record and preserves
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@klhapp/skillmux",
3
- "version": "1.9.1",
3
+ "version": "1.9.3",
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
@@ -15,6 +15,7 @@ import {
15
15
  import { openAudit } from "./db";
16
16
  import { diagnose } from "./doctor";
17
17
  import { getEffectiveConfig } from "./config-service";
18
+ import { buildRedactor } from "./redact";
18
19
  import { evalVault } from "./eval";
19
20
  import {
20
21
  assessClientReadiness,
@@ -39,9 +40,11 @@ import {
39
40
  surfaceCandidates,
40
41
  } from "./init";
41
42
  import {
43
+ assertHostAllowed,
42
44
  cloneToTemp,
43
45
  deriveRepoName,
44
46
  installIntoVault,
47
+ isLocalFileUrl,
45
48
  resolveCloneCommit,
46
49
  resolveRepoSource,
47
50
  resolveSkillDir,
@@ -100,7 +103,7 @@ import {
100
103
  useContext,
101
104
  type ResolvedTarget,
102
105
  } from "./context";
103
- import { createTargetAdapter, type TargetAdapter } from "./adapters";
106
+ import { createTargetAdapter, isLoopbackHost, type TargetAdapter } from "./adapters";
104
107
  import {
105
108
  emitSuccess,
106
109
  CliError,
@@ -240,7 +243,7 @@ async function main() {
240
243
  process.env.RUNNING_IN_DOCKER === "true" &&
241
244
  isDockerHostManagementCommand(command, subCommand)
242
245
  ) {
243
- handleError(containerCommandUnsupported(command, subCommand), {
246
+ await handleError(containerCommandUnsupported(command, subCommand), {
244
247
  target: resolvedTarget,
245
248
  isJson,
246
249
  isVerbose,
@@ -262,7 +265,7 @@ async function main() {
262
265
  server: flagServer,
263
266
  });
264
267
  } catch (err: any) {
265
- handleError(err, { target: resolvedTarget, isJson, isVerbose });
268
+ await handleError(err, { target: resolvedTarget, isJson, isVerbose });
266
269
  return;
267
270
  }
268
271
  }
@@ -293,8 +296,8 @@ async function main() {
293
296
  break;
294
297
  case "serve": {
295
298
  const { startServer } = await import("./server");
296
- const { transport, port } = parseServeArgs(rawArgv.slice(1));
297
- const handle = await startServer({ transport, port });
299
+ const { transport, port, statsPort } = parseServeArgs(rawArgv.slice(1));
300
+ const handle = await startServer({ transport, port, statsPort });
298
301
  let stopping = false;
299
302
  const shutdown = async () => {
300
303
  if (stopping) return;
@@ -336,7 +339,11 @@ async function main() {
336
339
  await runCore(subCommand, commandArgs, { isJson, dryRun: isDryRun });
337
340
  break;
338
341
  case "report":
339
- await runReport(rawArgv.slice(1), { isJson });
342
+ await runReport(rawArgv.slice(1), {
343
+ isJson,
344
+ target: resolvedTarget,
345
+ allowInsecure,
346
+ });
340
347
  break;
341
348
  case "audit":
342
349
  await runAudit(subCommand, commandArgs, { isJson, dryRun: isDryRun });
@@ -395,7 +402,7 @@ async function main() {
395
402
  }
396
403
  }
397
404
  } catch (err: any) {
398
- handleError(err, { target: resolvedTarget, isJson, isVerbose });
405
+ await handleError(err, { target: resolvedTarget, isJson, isVerbose });
399
406
  }
400
407
  }
401
408
 
@@ -496,14 +503,25 @@ async function handleCompletionsCommand(shell: string) {
496
503
  console.log(generateCompletions(shell as ShellType));
497
504
  }
498
505
 
499
- function handleError(
506
+ async function handleError(
500
507
  err: any,
501
508
  opts: { target: ResolvedTarget; isJson: boolean; isVerbose: boolean },
502
509
  ) {
503
510
  const code = mapExitCode(err);
504
511
  process.exitCode = code;
505
512
 
506
- const msg = err instanceof Error ? err.message : String(err);
513
+ const rawMsg = err instanceof Error ? err.message : String(err);
514
+ // Best-effort: a broken config must not suppress the original error report,
515
+ // so fall back to the URL-credential-only layer of buildRedactor(undefined)
516
+ // rather than let a config-load failure mask the real failure.
517
+ let redact: (text: string) => string;
518
+ try {
519
+ const { effective } = await getEffectiveConfig();
520
+ redact = buildRedactor(effective);
521
+ } catch {
522
+ redact = buildRedactor(undefined);
523
+ }
524
+ const msg = redact(rawMsg);
507
525
 
508
526
  if (opts.isJson) {
509
527
  const env = formatJsonEnvelope({
@@ -525,7 +543,7 @@ function handleError(
525
543
  : `error: ${msg}`,
526
544
  );
527
545
  if (opts.isVerbose && err instanceof Error && err.stack) {
528
- console.error(err.stack);
546
+ console.error(redact(err.stack));
529
547
  }
530
548
  }
531
549
  }
@@ -572,7 +590,7 @@ Init targets:
572
590
  agent-skills, claude-code, codex, custom
573
591
 
574
592
  Operations:
575
- skillmux report [--server <url> | --db <path>] --since <window> [--json]
593
+ skillmux report [--context <name> | --server <url> | --db <path>] --since <window> [--json]
576
594
  skillmux audit prune [--older-than <window>] [--dry-run] [--yes] [--json]
577
595
  skillmux eval promote --since <window> [--target <path>] [--dry-run] [--yes] [--json]
578
596
  skillmux outdated [--allow-local-source] [--json]
@@ -592,9 +610,11 @@ type Transport = "stdio" | "http";
592
610
  function parseServeArgs(args: string[]): {
593
611
  transport: Transport;
594
612
  port?: number;
613
+ statsPort?: number;
595
614
  } {
596
615
  let transport: Transport = "stdio";
597
616
  let port: number | undefined;
617
+ let statsPort: number | undefined;
598
618
  for (let i = 0; i < args.length; i++) {
599
619
  const option = args[i];
600
620
  const value = args[i + 1];
@@ -611,11 +631,18 @@ function parseServeArgs(args: string[]): {
611
631
  }
612
632
  port = parsed;
613
633
  i++;
634
+ } else if (option === "--stats-port") {
635
+ const parsed = Number(value);
636
+ if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65_535) {
637
+ throw new Error("--stats-port must be an integer between 0 and 65535");
638
+ }
639
+ statsPort = parsed;
640
+ i++;
614
641
  } else {
615
642
  throw new Error(`unknown serve option: ${option}`);
616
643
  }
617
644
  }
618
- return { transport, port };
645
+ return { transport, port, statsPort };
619
646
  }
620
647
 
621
648
  async function runIndex(): Promise<void> {
@@ -1497,21 +1524,15 @@ async function runInit(
1497
1524
  }
1498
1525
 
1499
1526
  function parseReportArgs(args: string[]): {
1500
- server?: string;
1501
1527
  db?: string;
1502
1528
  since?: string;
1503
1529
  } {
1504
- let server: string | undefined;
1505
1530
  let db: string | undefined;
1506
1531
  let since: string | undefined;
1507
1532
  for (let i = 0; i < args.length; i++) {
1508
1533
  const option = args[i];
1509
1534
  const value = args[i + 1];
1510
- if (option === "--server") {
1511
- if (!value) throw new Error("--server requires a URL");
1512
- server = value;
1513
- i++;
1514
- } else if (option === "--db") {
1535
+ if (option === "--db") {
1515
1536
  if (!value) throw new Error("--db requires a path");
1516
1537
  db = value;
1517
1538
  i++;
@@ -1521,27 +1542,49 @@ function parseReportArgs(args: string[]): {
1521
1542
  i++;
1522
1543
  } else if (option === "--json") {
1523
1544
  // handled globally by main()'s isJson flag; recognized here so it isn't rejected
1545
+ } else if (option === "--server" || option === "--context") {
1546
+ // handled globally by main()'s resolveTarget(); recognized here so it isn't rejected
1547
+ i++;
1548
+ } else if (option === "--allow-insecure") {
1549
+ // handled globally by main()'s allowInsecure flag; recognized here so it isn't rejected
1524
1550
  } else {
1525
1551
  throw new Error(`unknown report option: ${option}`);
1526
1552
  }
1527
1553
  }
1528
- if (server && db) throw new Error("--server and --db are mutually exclusive");
1529
- return { server, db, since };
1554
+ return { db, since };
1530
1555
  }
1531
1556
 
1532
1557
  async function runReport(
1533
1558
  args: string[],
1534
- options: { isJson: boolean },
1559
+ options: { isJson: boolean; target: ResolvedTarget; allowInsecure: boolean },
1535
1560
  ): Promise<void> {
1536
- const { server, db: dbPath, since } = parseReportArgs(args);
1561
+ const { db: dbPath, since } = parseReportArgs(args);
1537
1562
  if (!since)
1538
1563
  throw new Error(
1539
- "usage: skillmux report [--server <url> | --db <path>] --since <window> [--json]",
1564
+ "usage: skillmux report [--context <name> | --server <url> | --db <path>] --since <window> [--json]",
1540
1565
  );
1541
-
1542
- if (server) {
1543
- const url = `${server.replace(/\/$/, "")}/stats?since=${encodeURIComponent(since)}`;
1544
- const res = await fetch(url);
1566
+ if (dbPath && options.target.type === "remote")
1567
+ throw new Error("--db and --context/--server are mutually exclusive");
1568
+
1569
+ if (options.target.type === "remote") {
1570
+ const { server, token_env } = options.target;
1571
+ const url = new URL(`${server.replace(/\/$/, "")}/stats`);
1572
+ url.searchParams.set("since", since);
1573
+ if (
1574
+ url.protocol === "http:" &&
1575
+ !isLoopbackHost(url.hostname) &&
1576
+ !options.allowInsecure
1577
+ ) {
1578
+ throw new Error(
1579
+ `Plaintext HTTP report target not allowed for non-loopback server "${server}". Pass --allow-insecure to bypass.`,
1580
+ );
1581
+ }
1582
+ const headers: Record<string, string> = {};
1583
+ if (token_env) {
1584
+ const token = process.env[token_env];
1585
+ if (token) headers.Authorization = `Bearer ${token}`;
1586
+ }
1587
+ const res = await fetch(url, { headers });
1545
1588
  if (!res.ok)
1546
1589
  throw new Error(
1547
1590
  `skillmux report --server failed: ${res.status} ${await res.text()}`,
@@ -1619,23 +1662,26 @@ function parseInstallArgs(args: string[]): {
1619
1662
  force: boolean;
1620
1663
  dryRun: boolean;
1621
1664
  failOn?: ScanSeverity;
1665
+ allowLocalSource: boolean;
1622
1666
  } {
1623
1667
  let repo: string | undefined;
1624
1668
  let force = false;
1625
1669
  let dryRun = false;
1626
1670
  let failOn: ScanSeverity | undefined;
1671
+ let allowLocalSource = false;
1627
1672
  for (let i = 0; i < args.length; i++) {
1628
1673
  const option = args[i];
1629
1674
  if (option === "--force") force = true;
1630
1675
  else if (option === "--dry-run") dryRun = true;
1676
+ else if (option === "--allow-local-source") allowLocalSource = true;
1631
1677
  else if (option === "--fail-on") {
1632
1678
  const value = args[++i];
1633
1679
  if (value !== "low" && value !== "medium" && value !== "high") {
1634
1680
  throw new Error("--fail-on must be low, medium, or high");
1635
1681
  }
1636
1682
  failOn = value;
1637
- } else if (option === "--json") {
1638
- // handled globally by main()'s isJson flag; recognized here so it isn't rejected
1683
+ } else if (option === "--json" || option === "--verbose") {
1684
+ // handled globally by main()'s isJson/isVerbose flags; recognized here so they aren't rejected
1639
1685
  } else if (option?.startsWith("--")) {
1640
1686
  throw new Error(`unknown install option: ${option}`);
1641
1687
  } else if (repo !== undefined) {
@@ -1644,21 +1690,28 @@ function parseInstallArgs(args: string[]): {
1644
1690
  repo = option;
1645
1691
  }
1646
1692
  }
1647
- return { repo, force, dryRun, failOn };
1693
+ return { repo, force, dryRun, failOn, allowLocalSource };
1648
1694
  }
1649
1695
 
1650
1696
  async function runInstall(
1651
1697
  args: string[],
1652
1698
  options: { isJson: boolean },
1653
1699
  ): Promise<void> {
1654
- const { repo, force, dryRun, failOn } = parseInstallArgs(args);
1700
+ const { repo, force, dryRun, failOn, allowLocalSource } = parseInstallArgs(args);
1655
1701
  if (!repo) {
1656
1702
  throw new Error(
1657
- "usage: skillmux install <repo>[/path] [--force] [--fail-on low|medium|high] [--dry-run] [--json]",
1703
+ "usage: skillmux install <repo>[/path] [--force] [--fail-on low|medium|high] [--dry-run] [--allow-local-source] [--json]",
1658
1704
  );
1659
1705
  }
1660
1706
 
1661
1707
  const source = resolveRepoSource(repo);
1708
+ if (!allowLocalSource && isLocalFileUrl(source.url)) {
1709
+ throw new Error(
1710
+ `"${repo}" is a local (file://) source — pass --allow-local-source to install from it`,
1711
+ );
1712
+ }
1713
+ const config = await loadConfig();
1714
+ assertHostAllowed(source.url, config.egress?.allowed_hosts);
1662
1715
  const cloneDir = await cloneToTemp(source.url);
1663
1716
  try {
1664
1717
  const resolved = resolveSkillDir(
@@ -1680,7 +1733,7 @@ async function runInstall(
1680
1733
  return;
1681
1734
  }
1682
1735
 
1683
- const vaultPath = expandHome((await loadConfig()).vault_path);
1736
+ const vaultPath = expandHome(config.vault_path);
1684
1737
  if (dryRun) {
1685
1738
  const plannedPath = join(vaultPath, resolved.skillId);
1686
1739
  emitSuccess(
package/src/clients.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Clients, Config, RemoteRerankerConfig } from "./types";
2
2
  import { expandHome } from "./config";
3
+ import { assertHostAllowed } from "./install";
3
4
  import type { pipeline as createPipeline } from "@huggingface/transformers";
4
5
 
5
6
  export type RemoteErrorKind = "configuration" | "availability" | "protocol";
@@ -34,6 +35,17 @@ function authorizationHeaders(
34
35
  return { authorization: `Bearer ${apiKey}` };
35
36
  }
36
37
 
38
+ function assertInferenceHostAllowed(url: string, allowedHosts: string[] | undefined): void {
39
+ try {
40
+ assertHostAllowed(url, allowedHosts);
41
+ } catch (error) {
42
+ throw new RemoteInferenceError(
43
+ "configuration",
44
+ error instanceof Error ? error.message : String(error),
45
+ );
46
+ }
47
+ }
48
+
37
49
  function httpFailure(surface: string, status: number): RemoteInferenceError {
38
50
  const kind: RemoteErrorKind =
39
51
  status === 401 || status === 403
@@ -196,9 +208,12 @@ async function fetchRerankerScores(
196
208
  timeoutMs: number,
197
209
  query: string,
198
210
  docs: { skill_id: string; text: string }[],
211
+ allowedHosts: string[] | undefined,
199
212
  ): Promise<number[]> {
200
213
  if (docs.length === 0) return [];
201
214
 
215
+ assertInferenceHostAllowed(reranker.endpoint, allowedHosts);
216
+
202
217
  let response: Response;
203
218
  try {
204
219
  response = await fetch(reranker.endpoint, {
@@ -300,6 +315,7 @@ export function createClients(config: Config): Clients {
300
315
  }
301
316
 
302
317
  const embedding = config.inference.embedding;
318
+ assertInferenceHostAllowed(embedding.endpoint, config.egress?.allowed_hosts);
303
319
  let response: Response;
304
320
  try {
305
321
  response = await fetch(embedding.endpoint, {
@@ -344,6 +360,7 @@ export function createClients(config: Config): Clients {
344
360
  inference.timeout_ms,
345
361
  query,
346
362
  docs,
363
+ config.egress?.allowed_hosts,
347
364
  );
348
365
  };
349
366
  }
@@ -39,7 +39,7 @@ export async function runAudit(
39
39
  if (retentionDays <= 0) {
40
40
  emitSuccess(
41
41
  { isJson: options.isJson },
42
- { audit_deleted: 0, fetch_deleted: 0, dry_run: dryRun, cutoff: null },
42
+ { audit_deleted: 0, fetch_deleted: 0, admin_audit_deleted: 0, dry_run: dryRun, cutoff: null },
43
43
  () => console.log("prune: audit.retention_days is 0 (pruning disabled); nothing to do"),
44
44
  );
45
45
  return;
@@ -55,7 +55,10 @@ export async function runAudit(
55
55
  emitSuccess(
56
56
  { isJson: options.isJson },
57
57
  { ...counts, dry_run: true, cutoff: cutoffIso },
58
- () => console.log(`prune: audit=${counts.audit_deleted} fetch=${counts.fetch_deleted} (dry-run)`),
58
+ () =>
59
+ console.log(
60
+ `prune: audit=${counts.audit_deleted} fetch=${counts.fetch_deleted} admin_audit=${counts.admin_audit_deleted} (dry-run)`,
61
+ ),
59
62
  );
60
63
  return;
61
64
  }
@@ -74,7 +77,10 @@ export async function runAudit(
74
77
  emitSuccess(
75
78
  { isJson: options.isJson },
76
79
  { ...counts, dry_run: false, cutoff: cutoffIso },
77
- () => console.log(`prune: audit=${counts.audit_deleted} fetch=${counts.fetch_deleted}`),
80
+ () =>
81
+ console.log(
82
+ `prune: audit=${counts.audit_deleted} fetch=${counts.fetch_deleted} admin_audit=${counts.admin_audit_deleted}`,
83
+ ),
78
84
  );
79
85
  } finally {
80
86
  db.close();
@@ -1,7 +1,7 @@
1
1
  import { readdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { expandHome, loadConfig } from "../config";
4
- import { isLocalFileUrl, remoteHeadCommit } from "../install";
4
+ import { assertHostAllowed, isLocalFileUrl, remoteHeadCommit } from "../install";
5
5
  import { emitSuccess } from "../output";
6
6
  import { readSkillOrigin } from "../provenance";
7
7
  import { SKILL_ID_PATTERN } from "../vault";
@@ -24,7 +24,7 @@ function vaultSkillIds(vaultPath: string): string[] {
24
24
 
25
25
  export async function checkOutdated(
26
26
  vaultPath: string,
27
- options: { allowLocalSource?: boolean } = {},
27
+ options: { allowLocalSource?: boolean; allowedHosts?: string[] } = {},
28
28
  ): Promise<OutdatedCheckResult[]> {
29
29
  const results: OutdatedCheckResult[] = [];
30
30
  for (const skillId of vaultSkillIds(vaultPath)) {
@@ -64,6 +64,7 @@ export async function checkOutdated(
64
64
  let status: OutdatedCheckResult["status"];
65
65
  let reason: string | null = null;
66
66
  try {
67
+ assertHostAllowed(origin.source_url, options.allowedHosts);
67
68
  remoteCommit = await remoteHeadCommit(origin.source_url);
68
69
  status = remoteCommit === origin.commit ? "up_to_date" : "outdated";
69
70
  } catch (error) {
@@ -94,8 +95,9 @@ export async function runOutdated(args: string[], options: { isJson: boolean }):
94
95
  throw new Error(`unknown outdated option: ${arg}`);
95
96
  }
96
97
 
97
- const vaultPath = expandHome((await loadConfig()).vault_path);
98
- const skills = await checkOutdated(vaultPath, { allowLocalSource });
98
+ const config = await loadConfig();
99
+ const vaultPath = expandHome(config.vault_path);
100
+ const skills = await checkOutdated(vaultPath, { allowLocalSource, allowedHosts: config.egress?.allowed_hosts });
99
101
  const checksFailed = skills.filter((s) => s.status === "check_failed").length;
100
102
  process.exitCode = checksFailed > 0 ? 1 : 0;
101
103