@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 +15 -0
- package/docs/configuration.md +51 -0
- package/docs/skill-management.md +43 -0
- package/package.json +1 -1
- package/src/cli.ts +75 -31
- package/src/clients.ts +17 -0
- package/src/commands/audit.ts +9 -3
- package/src/commands/outdated.ts +6 -4
- package/src/commands/update.ts +9 -4
- package/src/concurrency-limiter.ts +61 -0
- package/src/config.ts +5 -0
- package/src/db.ts +114 -6
- package/src/install.ts +15 -0
- package/src/redact.ts +52 -0
- package/src/server.ts +448 -268
- package/src/types.ts +7 -0
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
|
|
package/docs/configuration.md
CHANGED
|
@@ -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
|
package/docs/skill-management.md
CHANGED
|
@@ -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
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), {
|
|
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
|
|
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 === "--
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
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
|
|
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(
|
|
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
|
}
|
package/src/commands/audit.ts
CHANGED
|
@@ -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
|
-
() =>
|
|
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
|
-
() =>
|
|
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();
|
package/src/commands/outdated.ts
CHANGED
|
@@ -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
|
|
98
|
-
const
|
|
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
|
|
package/src/commands/update.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|