@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/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) {
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/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();
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
+ }