@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.
@@ -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
+ }
package/src/config.ts CHANGED
@@ -87,10 +87,15 @@ const configSchema = z.object({
87
87
  enabled: z.boolean(),
88
88
  token_env: z.string().min(1),
89
89
  }).strict().optional(),
90
+ max_body_bytes: z.number().int().positive().optional(),
91
+ max_concurrent_requests: z.number().int().min(0).optional(),
90
92
  }).strict().optional(),
91
93
  audit: z.object({
92
94
  retention_days: z.number().int().min(0).default(90),
93
95
  }).strict().default({ retention_days: 90 }),
96
+ egress: z.object({
97
+ allowed_hosts: z.array(z.string().min(1)).optional(),
98
+ }).strict().optional(),
94
99
  }).strict().refine((cfg) => {
95
100
  const hasReranker = cfg.inference.mode === "remote" && !!cfg.inference.reranker;
96
101
  if (hasReranker && cfg.output.max_top_k > cfg.recall.k_rerank) {
@@ -170,6 +175,18 @@ export function expandHome(path: string): string {
170
175
  return path.startsWith("~") ? join(homedir(), path.slice(1)) : path;
171
176
  }
172
177
 
178
+ /**
179
+ * True only for hostnames the HTTP server can bind while staying unreachable
180
+ * from outside this machine. Deliberately narrower than adapters.ts's
181
+ * isLoopbackHost, which treats "0.0.0.0" as loopback for a different question
182
+ * (whether an admin *client* is talking to the local machine) — here "0.0.0.0"
183
+ * (and any other wildcard/public address) must read as non-loopback, since
184
+ * binding it is exactly what makes the server reachable from outside (SMX-91).
185
+ */
186
+ export function isLoopbackBindHost(hostname: string): boolean {
187
+ return hostname === "localhost" || hostname === "::1" || hostname === "127.0.0.1" || hostname.startsWith("127.");
188
+ }
189
+
173
190
  function isPlainObject(value: unknown): value is Record<string, unknown> {
174
191
  return typeof value === "object" && value !== null && !Array.isArray(value);
175
192
  }
package/src/db.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Database } from "bun:sqlite";
2
+ import { createHash } from "node:crypto";
2
3
  import { existsSync, mkdirSync } from "node:fs";
3
4
  import { join } from "node:path";
4
5
  import type { AuditCandidate, AuditRow } from "./types";
@@ -48,6 +49,14 @@ export function openAudit(stateDir: string): Database {
48
49
  resolve_audit_id INTEGER,
49
50
  rank_at_resolve INTEGER
50
51
  )`);
52
+ db.run(`CREATE TABLE IF NOT EXISTS admin_audit (
53
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
54
+ ts TEXT NOT NULL,
55
+ changes TEXT NOT NULL,
56
+ resulting_revision TEXT NOT NULL,
57
+ row_hash TEXT NOT NULL,
58
+ prev_row_hash TEXT
59
+ )`);
51
60
  adoptAuditFromIndex(db, stateDir);
52
61
  return db;
53
62
  }
@@ -382,32 +391,131 @@ export function insertFetch(db: Database, row: FetchInsert): void {
382
391
  export interface PruneResult {
383
392
  audit_deleted: number;
384
393
  fetch_deleted: number;
394
+ admin_audit_deleted: number;
385
395
  }
386
396
 
387
397
  /**
388
- * Deletes resolve and fetch rows with ts before `cutoffIso`, each by its own
389
- * timestamp; no FK ties them, so a fetch outliving its resolve row simply
390
- * reads back uncorrelated (AC7's existing null path). Reclaims the freed
398
+ * Deletes resolve, fetch, and admin_audit rows with ts before `cutoffIso`,
399
+ * each by its own timestamp; no FK ties them, so a fetch outliving its
400
+ * resolve row simply reads back uncorrelated (AC7's existing null path).
401
+ * admin_audit shares this cutoff rather than a separate retention config
402
+ * (AC10) — its hash chain is unaffected since pruning only ever removes the
403
+ * oldest rows, never rows in the middle of the chain. Reclaims the freed
391
404
  * pages with an incremental vacuum, which only touches audit.sqlite3 (AC16).
392
405
  */
393
406
  export function pruneAuditBefore(db: Database, cutoffIso: string): PruneResult {
394
407
  const auditResult = db.run("DELETE FROM audit WHERE ts < ?", [cutoffIso]);
395
408
  const fetchResult = db.run("DELETE FROM fetch WHERE ts < ?", [cutoffIso]);
409
+ const adminAuditResult = db.run("DELETE FROM admin_audit WHERE ts < ?", [cutoffIso]);
396
410
  db.run("PRAGMA incremental_vacuum");
397
411
 
398
- return { audit_deleted: auditResult.changes, fetch_deleted: fetchResult.changes };
412
+ return {
413
+ audit_deleted: auditResult.changes,
414
+ fetch_deleted: fetchResult.changes,
415
+ admin_audit_deleted: adminAuditResult.changes,
416
+ };
399
417
  }
400
418
 
401
419
  /** AC12: retentionDays <= 0 disables pruning entirely. */
402
420
  export function pruneAudit(db: Database, retentionDays: number, now: Date = new Date()): PruneResult {
403
- if (retentionDays <= 0) return { audit_deleted: 0, fetch_deleted: 0 };
421
+ if (retentionDays <= 0) return { audit_deleted: 0, fetch_deleted: 0, admin_audit_deleted: 0 };
404
422
  const cutoff = new Date(now.getTime() - retentionDays * 86_400_000).toISOString();
405
423
  return pruneAuditBefore(db, cutoff);
406
424
  }
407
425
 
426
+ export interface AdminAuditChange {
427
+ key: string;
428
+ old_value: unknown;
429
+ new_value: unknown;
430
+ }
431
+
432
+ export interface AdminAuditInsert {
433
+ ts: string;
434
+ changes: AdminAuditChange[];
435
+ resulting_revision: string;
436
+ }
437
+
438
+ export interface AdminAuditRow {
439
+ id: number;
440
+ ts: string;
441
+ changes: AdminAuditChange[];
442
+ resulting_revision: string;
443
+ row_hash: string;
444
+ prev_row_hash: string | null;
445
+ }
446
+
447
+ function computeAdminAuditRowHash(
448
+ prevRowHash: string | null,
449
+ fields: { ts: string; changes: AdminAuditChange[]; resulting_revision: string },
450
+ ): string {
451
+ const payload = JSON.stringify({ prev_row_hash: prevRowHash, ...fields });
452
+ return createHash("sha256").update(payload).digest("hex");
453
+ }
454
+
455
+ /**
456
+ * Appends one tamper-evident admin_audit row, chaining its hash to the
457
+ * previous row's hash (or null for the first row) so any out-of-band
458
+ * edit/delete breaks the chain — see verifyAdminAuditChain.
459
+ */
460
+ export function insertAdminAuditRow(db: Database, row: AdminAuditInsert): AdminAuditRow {
461
+ const prevRow = db
462
+ .query("SELECT row_hash FROM admin_audit ORDER BY id DESC LIMIT 1")
463
+ .get() as { row_hash: string } | null;
464
+ const prevRowHash = prevRow?.row_hash ?? null;
465
+ const rowHash = computeAdminAuditRowHash(prevRowHash, row);
466
+
467
+ db.run(
468
+ `INSERT INTO admin_audit (ts, changes, resulting_revision, row_hash, prev_row_hash)
469
+ VALUES (?, ?, ?, ?, ?)`,
470
+ [row.ts, JSON.stringify(row.changes), row.resulting_revision, rowHash, prevRowHash],
471
+ );
472
+
473
+ const inserted = db.query("SELECT last_insert_rowid() AS id").get() as { id: number };
474
+ return {
475
+ id: inserted.id,
476
+ ts: row.ts,
477
+ changes: row.changes,
478
+ resulting_revision: row.resulting_revision,
479
+ row_hash: rowHash,
480
+ prev_row_hash: prevRowHash,
481
+ };
482
+ }
483
+
484
+ export interface AdminAuditChainResult {
485
+ valid: boolean;
486
+ broken_at_id: number | null;
487
+ }
488
+
489
+ /** Walks admin_audit in insertion order and reports whether the hash chain is unbroken. */
490
+ export function verifyAdminAuditChain(db: Database): AdminAuditChainResult {
491
+ const rows = db
492
+ .query("SELECT id, ts, changes, resulting_revision, row_hash, prev_row_hash FROM admin_audit ORDER BY id ASC")
493
+ .all() as { id: number; ts: string; changes: string; resulting_revision: string; row_hash: string; prev_row_hash: string | null }[];
494
+
495
+ let expectedPrevHash: string | null = null;
496
+ for (const row of rows) {
497
+ if (row.prev_row_hash !== expectedPrevHash) {
498
+ return { valid: false, broken_at_id: row.id };
499
+ }
500
+ const recomputed = computeAdminAuditRowHash(expectedPrevHash, {
501
+ ts: row.ts,
502
+ changes: JSON.parse(row.changes),
503
+ resulting_revision: row.resulting_revision,
504
+ });
505
+ if (recomputed !== row.row_hash) {
506
+ return { valid: false, broken_at_id: row.id };
507
+ }
508
+ expectedPrevHash = row.row_hash;
509
+ }
510
+ return { valid: true, broken_at_id: null };
511
+ }
512
+
408
513
  /** Dry-run counterpart of pruneAuditBefore: counts without deleting (AC15). */
409
514
  export function countPrunable(db: Database, cutoffIso: string): PruneResult {
410
515
  const auditRow = db.query("SELECT count(*) AS n FROM audit WHERE ts < ?").get(cutoffIso) as { n: number };
411
516
  const fetchRow = db.query("SELECT count(*) AS n FROM fetch WHERE ts < ?").get(cutoffIso) as { n: number };
412
- return { audit_deleted: auditRow.n, fetch_deleted: fetchRow.n };
517
+ const adminAuditRow = db
518
+ .query("SELECT count(*) AS n FROM admin_audit WHERE ts < ?")
519
+ .get(cutoffIso) as { n: number };
520
+ return { audit_deleted: auditRow.n, fetch_deleted: fetchRow.n, admin_audit_deleted: adminAuditRow.n };
413
521
  }
package/src/doctor.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync } from "node:fs";
2
2
  import { createClients, RemoteInferenceError } from "./clients";
3
- import { embeddingDimension, expandHome } from "./config";
3
+ import { embeddingDimension, expandHome, isLoopbackBindHost } from "./config";
4
4
  import { describeDeployment, type DeploymentIdentity } from "./deployment";
5
5
  import { parseManifest, resolveManifestPath, validateManifest } from "./manifest";
6
6
  import { readSkillmuxMarker } from "./sync";
@@ -51,6 +51,30 @@ export async function diagnose(
51
51
  }
52
52
  checks.push({ name: "vault", ok: existsSync(expandHome(config.vault_path)), detail: expandHome(config.vault_path) });
53
53
 
54
+ // SMX-91: `serve --transport http` itself refuses to start over this combination
55
+ // (assertSafeBindPosture in server.ts) unless SKILLMUX_ALLOW_INSECURE_BIND is set —
56
+ // surface it here too so it's visible without having to start the HTTP server first.
57
+ // An operator who has already set that env var has made an informed choice, so
58
+ // doctor treats it the same way the server does (ok, not a standing failure) —
59
+ // it doesn't re-litigate a decision the server itself already accepted.
60
+ if (config.server) {
61
+ const hostname = config.server.hostname ?? "127.0.0.1";
62
+ const insecureBindAcknowledged = environment.SKILLMUX_ALLOW_INSECURE_BIND === "true";
63
+ const bindIsSafe = isLoopbackBindHost(hostname) || config.server.auth_enabled || insecureBindAcknowledged;
64
+ checks.push({
65
+ name: "server_bind_posture",
66
+ ok: bindIsSafe,
67
+ detail: !bindIsSafe
68
+ ? `${hostname} is reachable beyond this machine with auth_enabled=false — ` +
69
+ "MCP tools and /stats would be open to anyone who can reach this port; " +
70
+ "set server.auth_enabled=true, bind a loopback hostname, or set SKILLMUX_ALLOW_INSECURE_BIND=true"
71
+ : insecureBindAcknowledged && !isLoopbackBindHost(hostname) && !config.server.auth_enabled
72
+ ? `${hostname}, auth_enabled=false, acknowledged via SKILLMUX_ALLOW_INSECURE_BIND`
73
+ : `${hostname}, auth_enabled=${config.server.auth_enabled}`,
74
+ failure_kind: bindIsSafe ? undefined : "configuration",
75
+ });
76
+ }
77
+
54
78
  for (const localPath of config.local_vault_paths) {
55
79
  const expanded = expandHome(localPath);
56
80
  checks.push({ name: `local_vault:${localPath}`, ok: existsSync(expanded), detail: expanded });
package/src/install.ts CHANGED
@@ -45,6 +45,21 @@ export function resolveRepoSource(repo: string): RepoSource {
45
45
  return rest.length > 0 ? { url, skillPath: rest.join("/") } : { url };
46
46
  }
47
47
 
48
+ function extractHost(url: string): string {
49
+ const scpMatch = url.match(/^[^/\s]+@([^/\s]+):/);
50
+ if (scpMatch) return scpMatch[1]!;
51
+ return new URL(url).hostname;
52
+ }
53
+
54
+ export function assertHostAllowed(url: string, allowedHosts: string[] | undefined): void {
55
+ if (!allowedHosts || allowedHosts.length === 0) return;
56
+ if (isLocalFileUrl(url)) return;
57
+ const host = extractHost(url);
58
+ if (!allowedHosts.map((h) => h.toLowerCase()).includes(host.toLowerCase())) {
59
+ throw new Error(`refusing to fetch from host "${host}" — not in [egress] allowed_hosts`);
60
+ }
61
+ }
62
+
48
63
  export function deriveRepoName(url: string): string {
49
64
  const cleaned = url.replace(/\.git$/, "");
50
65
  const segment = cleaned.split(/[/:]/).filter(Boolean).pop();
@@ -3,6 +3,14 @@ interface Bucket {
3
3
  lastRefillMs: number;
4
4
  }
5
5
 
6
+ // SMX-93: bounds memory even when an attacker (behind a trust_proxy-honored
7
+ // reverse proxy) mints unbounded distinct X-Forwarded-For values, or a
8
+ // long-running deployment simply accumulates many distinct legitimate
9
+ // clients over time. Past this many entries, the least-recently-used
10
+ // bucket is evicted to make room — active clients are never evicted ahead
11
+ // of idle ones.
12
+ const DEFAULT_MAX_BUCKETS = 10_000;
13
+
6
14
  export interface RateLimitCheckInput {
7
15
  nowMs: number;
8
16
  auth_enabled: boolean;
@@ -22,12 +30,19 @@ export class RateLimiter {
22
30
  private enabled: boolean;
23
31
  private requests_per_minute: number;
24
32
  private trust_proxy: boolean;
33
+ private max_buckets: number;
25
34
  private buckets = new Map<string, Bucket>();
26
35
 
27
- constructor(config: { enabled: boolean; requests_per_minute: number; trust_proxy?: boolean }) {
36
+ constructor(config: {
37
+ enabled: boolean;
38
+ requests_per_minute: number;
39
+ trust_proxy?: boolean;
40
+ max_buckets?: number;
41
+ }) {
28
42
  this.enabled = config.enabled;
29
43
  this.requests_per_minute = config.requests_per_minute;
30
44
  this.trust_proxy = config.trust_proxy ?? false;
45
+ this.max_buckets = config.max_buckets ?? DEFAULT_MAX_BUCKETS;
31
46
  }
32
47
 
33
48
  check(input: RateLimitCheckInput): RateLimitCheckResult {
@@ -59,7 +74,19 @@ export class RateLimiter {
59
74
 
60
75
  // 2. Retrieve or initialize bucket
61
76
  let bucket = this.buckets.get(id);
62
- if (!bucket) {
77
+ if (bucket) {
78
+ // Map iteration order is insertion order, so re-inserting on touch
79
+ // marks this entry as most-recently-used and moves it out of the
80
+ // eviction path below.
81
+ this.buckets.delete(id);
82
+ this.buckets.set(id, bucket);
83
+ } else {
84
+ if (this.buckets.size >= this.max_buckets) {
85
+ const oldestId = this.buckets.keys().next().value;
86
+ if (oldestId !== undefined) {
87
+ this.buckets.delete(oldestId);
88
+ }
89
+ }
63
90
  bucket = {
64
91
  tokens: this.requests_per_minute,
65
92
  lastRefillMs: input.nowMs,
package/src/redact.ts ADDED
@@ -0,0 +1,52 @@
1
+ const PLACEHOLDER = "[REDACTED]";
2
+
3
+ // Strips userinfo (user:pass@) from URLs unconditionally, since a
4
+ // credential-bearing git URL (private-repo PAT auth) is typed by the user
5
+ // directly and never resolved from a config `*_env` key, so it can't be
6
+ // caught by the config-driven scrub below.
7
+ const URL_USERINFO = /:\/\/[^/\s@]*@/g;
8
+
9
+ function redactUrlCredentials(text: string): string {
10
+ return text.replace(URL_USERINFO, `://${PLACEHOLDER}@`);
11
+ }
12
+
13
+ /**
14
+ * Walks the config tree for every string-valued key ending in `_env`
15
+ * (api_key_env, token_env, auth_token_env, and any future one) and resolves
16
+ * each to its current environment value.
17
+ */
18
+ function collectSecretValues(value: unknown): string[] {
19
+ const secrets: string[] = [];
20
+ const visit = (node: unknown): void => {
21
+ if (Array.isArray(node)) {
22
+ for (const item of node) visit(item);
23
+ return;
24
+ }
25
+ if (node === null || typeof node !== "object") return;
26
+ for (const [key, val] of Object.entries(node as Record<string, unknown>)) {
27
+ if (key.endsWith("_env") && typeof val === "string") {
28
+ const resolved = process.env[val];
29
+ if (resolved) secrets.push(resolved);
30
+ } else {
31
+ visit(val);
32
+ }
33
+ }
34
+ };
35
+ visit(value);
36
+ return secrets;
37
+ }
38
+
39
+ /**
40
+ * Builds a redactor bound to the currently effective config: a pure
41
+ * `(text) => text` closure that scrubs any resolved `*_env` secret value.
42
+ */
43
+ export function buildRedactor(config: unknown): (text: string) => string {
44
+ const secrets = collectSecretValues(config);
45
+ return (text: string): string => {
46
+ let result = redactUrlCredentials(text);
47
+ for (const secret of secrets) {
48
+ result = result.split(secret).join(PLACEHOLDER);
49
+ }
50
+ return result;
51
+ };
52
+ }