@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 +25 -0
- package/docs/configuration.md +51 -0
- package/docs/deployment.md +10 -0
- package/docs/skill-management.md +54 -0
- package/package.json +1 -1
- package/src/cli.ts +87 -34
- 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 +17 -0
- package/src/db.ts +114 -6
- package/src/doctor.ts +25 -1
- package/src/install.ts +15 -0
- package/src/rate-limiter.ts +29 -2
- package/src/redact.ts +52 -0
- package/src/server.ts +488 -275
- package/src/types.ts +7 -0
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
|
|
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/deployment.md
CHANGED
|
@@ -232,6 +232,16 @@ Keep `trust_proxy = false` unless a trusted reverse proxy overwrites
|
|
|
232
232
|
`X-Forwarded-For`. A client can spoof that header when it reaches Skillmux
|
|
233
233
|
directly.
|
|
234
234
|
|
|
235
|
+
`skillmux serve --transport http` enforces this itself: it refuses to bind a
|
|
236
|
+
non-loopback hostname (including Docker's default `0.0.0.0`) while
|
|
237
|
+
`auth_enabled` is `false`, since that combination leaves `/mcp` and `/stats`
|
|
238
|
+
open to anyone who can reach the port. `skillmux doctor` flags the same
|
|
239
|
+
combination as `server_bind_posture`. If you're relying on network-level
|
|
240
|
+
isolation instead of application auth — e.g. a container with no published
|
|
241
|
+
port, reachable only inside a private Docker network — set
|
|
242
|
+
`SKILLMUX_ALLOW_INSECURE_BIND=true` to start anyway; the server logs a loud
|
|
243
|
+
warning each time it does.
|
|
244
|
+
|
|
235
245
|
## Health and metrics
|
|
236
246
|
|
|
237
247
|
The HTTP server provides:
|
package/docs/skill-management.md
CHANGED
|
@@ -42,6 +42,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
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), {
|
|
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
|
|
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 === "--
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
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
|
|
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(
|
|
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
|
}
|
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
|
|