@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/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
|
|
389
|
-
* timestamp; no FK ties them, so a fetch outliving its
|
|
390
|
-
* reads back uncorrelated (AC7's existing null path).
|
|
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 {
|
|
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
|
-
|
|
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
|
+
}
|