@klhapp/skillmux 1.9.2 → 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,21 @@ 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
+
8
23
  ## [1.9.2](https://github.com/klhq/skillmux/compare/v1.9.1...v1.9.2) (2026-08-30)
9
24
 
10
25
 
@@ -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
@@ -53,6 +53,22 @@ from a local repo is a deliberate, interactive choice:
53
53
  skillmux install file:///path/to/local/repo --allow-local-source
54
54
  ```
55
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
+
56
72
  ## Scan a vault or candidate
57
73
 
58
74
  ```sh
@@ -211,6 +227,7 @@ skillmux doctor
211
227
  ```sh
212
228
  skillmux report --since 7d
213
229
  skillmux report --server http://host:3000 --since 7d
230
+ skillmux report --context prod --since 7d
214
231
  ```
215
232
 
216
233
  `skillmux report` aggregates total requests, empty shortlist count and rate,
@@ -224,6 +241,32 @@ Top empty shortlist queries point to missing skills or weak skill descriptions.
224
241
  `--since` accepts windows such as `1h`, `7d`, and `1m`, plus absolute dates and
225
242
  timestamps.
226
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
+
227
270
  ## Target ownership and recovery
228
271
 
229
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.2",
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,6 +40,7 @@ import {
39
40
  surfaceCandidates,
40
41
  } from "./init";
41
42
  import {
43
+ assertHostAllowed,
42
44
  cloneToTemp,
43
45
  deriveRepoName,
44
46
  installIntoVault,
@@ -101,7 +103,7 @@ import {
101
103
  useContext,
102
104
  type ResolvedTarget,
103
105
  } from "./context";
104
- import { createTargetAdapter, type TargetAdapter } from "./adapters";
106
+ import { createTargetAdapter, isLoopbackHost, type TargetAdapter } from "./adapters";
105
107
  import {
106
108
  emitSuccess,
107
109
  CliError,
@@ -241,7 +243,7 @@ async function main() {
241
243
  process.env.RUNNING_IN_DOCKER === "true" &&
242
244
  isDockerHostManagementCommand(command, subCommand)
243
245
  ) {
244
- handleError(containerCommandUnsupported(command, subCommand), {
246
+ await handleError(containerCommandUnsupported(command, subCommand), {
245
247
  target: resolvedTarget,
246
248
  isJson,
247
249
  isVerbose,
@@ -263,7 +265,7 @@ async function main() {
263
265
  server: flagServer,
264
266
  });
265
267
  } catch (err: any) {
266
- handleError(err, { target: resolvedTarget, isJson, isVerbose });
268
+ await handleError(err, { target: resolvedTarget, isJson, isVerbose });
267
269
  return;
268
270
  }
269
271
  }
@@ -294,8 +296,8 @@ async function main() {
294
296
  break;
295
297
  case "serve": {
296
298
  const { startServer } = await import("./server");
297
- const { transport, port } = parseServeArgs(rawArgv.slice(1));
298
- const handle = await startServer({ transport, port });
299
+ const { transport, port, statsPort } = parseServeArgs(rawArgv.slice(1));
300
+ const handle = await startServer({ transport, port, statsPort });
299
301
  let stopping = false;
300
302
  const shutdown = async () => {
301
303
  if (stopping) return;
@@ -337,7 +339,11 @@ async function main() {
337
339
  await runCore(subCommand, commandArgs, { isJson, dryRun: isDryRun });
338
340
  break;
339
341
  case "report":
340
- await runReport(rawArgv.slice(1), { isJson });
342
+ await runReport(rawArgv.slice(1), {
343
+ isJson,
344
+ target: resolvedTarget,
345
+ allowInsecure,
346
+ });
341
347
  break;
342
348
  case "audit":
343
349
  await runAudit(subCommand, commandArgs, { isJson, dryRun: isDryRun });
@@ -396,7 +402,7 @@ async function main() {
396
402
  }
397
403
  }
398
404
  } catch (err: any) {
399
- handleError(err, { target: resolvedTarget, isJson, isVerbose });
405
+ await handleError(err, { target: resolvedTarget, isJson, isVerbose });
400
406
  }
401
407
  }
402
408
 
@@ -497,14 +503,25 @@ async function handleCompletionsCommand(shell: string) {
497
503
  console.log(generateCompletions(shell as ShellType));
498
504
  }
499
505
 
500
- function handleError(
506
+ async function handleError(
501
507
  err: any,
502
508
  opts: { target: ResolvedTarget; isJson: boolean; isVerbose: boolean },
503
509
  ) {
504
510
  const code = mapExitCode(err);
505
511
  process.exitCode = code;
506
512
 
507
- 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);
508
525
 
509
526
  if (opts.isJson) {
510
527
  const env = formatJsonEnvelope({
@@ -526,7 +543,7 @@ function handleError(
526
543
  : `error: ${msg}`,
527
544
  );
528
545
  if (opts.isVerbose && err instanceof Error && err.stack) {
529
- console.error(err.stack);
546
+ console.error(redact(err.stack));
530
547
  }
531
548
  }
532
549
  }
@@ -573,7 +590,7 @@ Init targets:
573
590
  agent-skills, claude-code, codex, custom
574
591
 
575
592
  Operations:
576
- skillmux report [--server <url> | --db <path>] --since <window> [--json]
593
+ skillmux report [--context <name> | --server <url> | --db <path>] --since <window> [--json]
577
594
  skillmux audit prune [--older-than <window>] [--dry-run] [--yes] [--json]
578
595
  skillmux eval promote --since <window> [--target <path>] [--dry-run] [--yes] [--json]
579
596
  skillmux outdated [--allow-local-source] [--json]
@@ -593,9 +610,11 @@ type Transport = "stdio" | "http";
593
610
  function parseServeArgs(args: string[]): {
594
611
  transport: Transport;
595
612
  port?: number;
613
+ statsPort?: number;
596
614
  } {
597
615
  let transport: Transport = "stdio";
598
616
  let port: number | undefined;
617
+ let statsPort: number | undefined;
599
618
  for (let i = 0; i < args.length; i++) {
600
619
  const option = args[i];
601
620
  const value = args[i + 1];
@@ -612,11 +631,18 @@ function parseServeArgs(args: string[]): {
612
631
  }
613
632
  port = parsed;
614
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++;
615
641
  } else {
616
642
  throw new Error(`unknown serve option: ${option}`);
617
643
  }
618
644
  }
619
- return { transport, port };
645
+ return { transport, port, statsPort };
620
646
  }
621
647
 
622
648
  async function runIndex(): Promise<void> {
@@ -1498,21 +1524,15 @@ async function runInit(
1498
1524
  }
1499
1525
 
1500
1526
  function parseReportArgs(args: string[]): {
1501
- server?: string;
1502
1527
  db?: string;
1503
1528
  since?: string;
1504
1529
  } {
1505
- let server: string | undefined;
1506
1530
  let db: string | undefined;
1507
1531
  let since: string | undefined;
1508
1532
  for (let i = 0; i < args.length; i++) {
1509
1533
  const option = args[i];
1510
1534
  const value = args[i + 1];
1511
- if (option === "--server") {
1512
- if (!value) throw new Error("--server requires a URL");
1513
- server = value;
1514
- i++;
1515
- } else if (option === "--db") {
1535
+ if (option === "--db") {
1516
1536
  if (!value) throw new Error("--db requires a path");
1517
1537
  db = value;
1518
1538
  i++;
@@ -1522,27 +1542,49 @@ function parseReportArgs(args: string[]): {
1522
1542
  i++;
1523
1543
  } else if (option === "--json") {
1524
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
1525
1550
  } else {
1526
1551
  throw new Error(`unknown report option: ${option}`);
1527
1552
  }
1528
1553
  }
1529
- if (server && db) throw new Error("--server and --db are mutually exclusive");
1530
- return { server, db, since };
1554
+ return { db, since };
1531
1555
  }
1532
1556
 
1533
1557
  async function runReport(
1534
1558
  args: string[],
1535
- options: { isJson: boolean },
1559
+ options: { isJson: boolean; target: ResolvedTarget; allowInsecure: boolean },
1536
1560
  ): Promise<void> {
1537
- const { server, db: dbPath, since } = parseReportArgs(args);
1561
+ const { db: dbPath, since } = parseReportArgs(args);
1538
1562
  if (!since)
1539
1563
  throw new Error(
1540
- "usage: skillmux report [--server <url> | --db <path>] --since <window> [--json]",
1564
+ "usage: skillmux report [--context <name> | --server <url> | --db <path>] --since <window> [--json]",
1541
1565
  );
1542
-
1543
- if (server) {
1544
- const url = `${server.replace(/\/$/, "")}/stats?since=${encodeURIComponent(since)}`;
1545
- 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 });
1546
1588
  if (!res.ok)
1547
1589
  throw new Error(
1548
1590
  `skillmux report --server failed: ${res.status} ${await res.text()}`,
@@ -1638,8 +1680,8 @@ function parseInstallArgs(args: string[]): {
1638
1680
  throw new Error("--fail-on must be low, medium, or high");
1639
1681
  }
1640
1682
  failOn = value;
1641
- } else if (option === "--json") {
1642
- // 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
1643
1685
  } else if (option?.startsWith("--")) {
1644
1686
  throw new Error(`unknown install option: ${option}`);
1645
1687
  } else if (repo !== undefined) {
@@ -1668,6 +1710,8 @@ async function runInstall(
1668
1710
  `"${repo}" is a local (file://) source — pass --allow-local-source to install from it`,
1669
1711
  );
1670
1712
  }
1713
+ const config = await loadConfig();
1714
+ assertHostAllowed(source.url, config.egress?.allowed_hosts);
1671
1715
  const cloneDir = await cloneToTemp(source.url);
1672
1716
  try {
1673
1717
  const resolved = resolveSkillDir(
@@ -1689,7 +1733,7 @@ async function runInstall(
1689
1733
  return;
1690
1734
  }
1691
1735
 
1692
- const vaultPath = expandHome((await loadConfig()).vault_path);
1736
+ const vaultPath = expandHome(config.vault_path);
1693
1737
  if (dryRun) {
1694
1738
  const plannedPath = join(vaultPath, resolved.skillId);
1695
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
 
@@ -2,6 +2,7 @@ import { rmSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { expandHome, loadConfig } from "../config";
4
4
  import {
5
+ assertHostAllowed,
5
6
  cloneToTemp,
6
7
  installIntoVault,
7
8
  isLocalFileUrl,
@@ -37,6 +38,7 @@ async function resolveCandidateOrigins(
37
38
  vaultPath: string,
38
39
  skillId: string | undefined,
39
40
  allowLocalSource: boolean,
41
+ allowedHosts: string[] | undefined,
40
42
  ): Promise<{ skillId: string; origin: SkillOrigin }[]> {
41
43
  if (skillId) {
42
44
  // skillId (the CLI's positional <skill-id>) is joined straight into vaultPath
@@ -65,7 +67,7 @@ async function resolveCandidateOrigins(
65
67
  }
66
68
  return [{ skillId, origin }];
67
69
  }
68
- const outdated = await checkOutdated(vaultPath, { allowLocalSource });
70
+ const outdated = await checkOutdated(vaultPath, { allowLocalSource, allowedHosts });
69
71
  return outdated
70
72
  .filter((result) => result.status === "outdated")
71
73
  .map((result) => ({ skillId: result.skill_id, origin: readSkillOrigin(join(vaultPath, result.skill_id))! }));
@@ -76,6 +78,7 @@ async function buildPlan(
76
78
  candidates: { skillId: string; origin: SkillOrigin }[],
77
79
  failOn: ScanSeverity | undefined,
78
80
  force: boolean,
81
+ allowedHosts: string[] | undefined,
79
82
  ): Promise<UpdatePlanItem[]> {
80
83
  const plan: UpdatePlanItem[] = [];
81
84
  for (const { skillId, origin } of candidates) {
@@ -118,6 +121,7 @@ async function buildPlan(
118
121
  continue;
119
122
  }
120
123
 
124
+ assertHostAllowed(origin.source_url, allowedHosts);
121
125
  const cloneDir = await cloneToTemp(origin.source_url);
122
126
  const resolved = resolveSkillDir(cloneDir, skillId, origin.skill_path);
123
127
  const base = {
@@ -206,10 +210,11 @@ function parseUpdateArgs(args: string[]): {
206
210
 
207
211
  export async function runUpdate(args: string[], options: { isJson: boolean }): Promise<void> {
208
212
  const { skillId, yes, dryRun, force, failOn, allowLocalSource } = parseUpdateArgs(args);
209
- const vaultPath = expandHome((await loadConfig()).vault_path);
213
+ const config = await loadConfig();
214
+ const vaultPath = expandHome(config.vault_path);
210
215
 
211
- const candidates = await resolveCandidateOrigins(vaultPath, skillId, allowLocalSource);
212
- const plan = await buildPlan(vaultPath, candidates, failOn, force);
216
+ const candidates = await resolveCandidateOrigins(vaultPath, skillId, allowLocalSource, config.egress?.allowed_hosts);
217
+ const plan = await buildPlan(vaultPath, candidates, failOn, force, config.egress?.allowed_hosts);
213
218
  try {
214
219
  const toWrite = plan.filter((item) => item.kind === "update");
215
220
 
@@ -0,0 +1,61 @@
1
+ export class ConcurrencyLimiter {
2
+ private inFlight = 0;
3
+
4
+ constructor(private readonly max: number) {}
5
+
6
+ tryAcquire(): boolean {
7
+ if (this.inFlight >= this.max) return false;
8
+ this.inFlight++;
9
+ return true;
10
+ }
11
+
12
+ release(): void {
13
+ this.inFlight = Math.max(0, this.inFlight - 1);
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Wraps a response body so `release` fires when the stream actually finishes
19
+ * (fully drained or cancelled by a client disconnect) instead of as soon as
20
+ * the Response object is constructed. For a buffered body this happens almost
21
+ * immediately; for an open SSE stream it defers release until the connection
22
+ * really closes, so a concurrency limiter reflects true connection lifetime.
23
+ */
24
+ export function releaseOnStreamClose(
25
+ body: ReadableStream<Uint8Array> | null,
26
+ release: () => void,
27
+ ): ReadableStream<Uint8Array> | null {
28
+ if (!body) {
29
+ release();
30
+ return null;
31
+ }
32
+
33
+ const reader = body.getReader();
34
+ let released = false;
35
+ const releaseOnce = () => {
36
+ if (released) return;
37
+ released = true;
38
+ release();
39
+ };
40
+
41
+ return new ReadableStream<Uint8Array>({
42
+ async pull(controller) {
43
+ try {
44
+ const { done, value } = await reader.read();
45
+ if (done) {
46
+ controller.close();
47
+ releaseOnce();
48
+ return;
49
+ }
50
+ controller.enqueue(value);
51
+ } catch (error) {
52
+ controller.error(error);
53
+ releaseOnce();
54
+ }
55
+ },
56
+ cancel(reason) {
57
+ releaseOnce();
58
+ return reader.cancel(reason);
59
+ },
60
+ });
61
+ }