@klhapp/skillmux 1.7.1 → 1.9.0
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 +35 -0
- package/README.md +15 -5
- package/docs/assets/architecture-dark.svg +160 -0
- package/docs/assets/{architecture.svg → architecture-light.svg} +40 -34
- package/docs/assets/logo-dark.png +0 -0
- package/docs/assets/logo-light.png +0 -0
- package/docs/cli.md +63 -5
- package/docs/concepts.md +10 -0
- package/docs/configuration.md +20 -20
- package/docs/deployment.md +20 -7
- package/docs/getting-started.md +11 -0
- package/docs/mcp-routing.md +41 -7
- package/docs/schema.json +31 -4
- package/docs/skill-management.md +32 -0
- package/package.json +1 -1
- package/src/audit.ts +1 -0
- package/src/cli.ts +62 -8
- package/src/commands/audit.ts +82 -0
- package/src/commands/eval.ts +81 -0
- package/src/commands/outdated.ts +112 -0
- package/src/commands/update.ts +253 -0
- package/src/config.ts +6 -0
- package/src/db.ts +152 -45
- package/src/eval.ts +69 -0
- package/src/install.ts +82 -3
- package/src/provenance.ts +99 -0
- package/src/router-core.ts +109 -26
- package/src/scan.ts +7 -1
- package/src/server.ts +21 -6
- package/src/stats.ts +119 -13
- package/src/sync.ts +38 -8
- package/src/types.ts +22 -0
- package/src/vault.ts +44 -4
- package/docs/assets/logo.png +0 -0
package/src/db.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
-
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import type { AuditCandidate, AuditRow } from "./types";
|
|
5
5
|
import type { VaultSkill } from "./vault";
|
|
@@ -12,6 +12,81 @@ export interface SkillRow {
|
|
|
12
12
|
content_sha256: string;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
export function openAudit(stateDir: string): Database {
|
|
16
|
+
mkdirSync(stateDir, { recursive: true });
|
|
17
|
+
const db = new Database(join(stateDir, "audit.sqlite3"), { create: true });
|
|
18
|
+
// auto_vacuum only takes on an empty database, so it must precede both the
|
|
19
|
+
// journal-mode switch and any CREATE TABLE. It is what lets a retention
|
|
20
|
+
// prune reclaim space without a full VACUUM.
|
|
21
|
+
db.run("PRAGMA auto_vacuum = INCREMENTAL");
|
|
22
|
+
db.run("PRAGMA journal_mode = WAL");
|
|
23
|
+
db.run("PRAGMA busy_timeout = 2000");
|
|
24
|
+
db.run(`CREATE TABLE IF NOT EXISTS audit (
|
|
25
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
26
|
+
ts TEXT NOT NULL,
|
|
27
|
+
request_id TEXT,
|
|
28
|
+
query TEXT NOT NULL,
|
|
29
|
+
retrieval TEXT NOT NULL DEFAULT 'lexical',
|
|
30
|
+
degraded_from TEXT,
|
|
31
|
+
degradation_reason TEXT,
|
|
32
|
+
candidates TEXT NOT NULL,
|
|
33
|
+
latency_ms INTEGER NOT NULL
|
|
34
|
+
)`);
|
|
35
|
+
// CREATE TABLE IF NOT EXISTS no-ops on a table opened from before request_id
|
|
36
|
+
// existed (AC4), so add it explicitly when missing.
|
|
37
|
+
const auditColumns = new Set(
|
|
38
|
+
(db.query("PRAGMA table_info(audit)").all() as { name: string }[]).map((c) => c.name),
|
|
39
|
+
);
|
|
40
|
+
if (!auditColumns.has("request_id")) {
|
|
41
|
+
db.run("ALTER TABLE audit ADD COLUMN request_id TEXT");
|
|
42
|
+
}
|
|
43
|
+
db.run(`CREATE TABLE IF NOT EXISTS fetch (
|
|
44
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
45
|
+
ts TEXT NOT NULL,
|
|
46
|
+
skill_id TEXT NOT NULL,
|
|
47
|
+
request_id TEXT,
|
|
48
|
+
resolve_audit_id INTEGER,
|
|
49
|
+
rank_at_resolve INTEGER
|
|
50
|
+
)`);
|
|
51
|
+
adoptAuditFromIndex(db, stateDir);
|
|
52
|
+
return db;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Audit rows used to live in index.sqlite3. Move any that remain there into the
|
|
56
|
+
// audit store, then drop the old table so the index carries no user queries.
|
|
57
|
+
function adoptAuditFromIndex(db: Database, stateDir: string): void {
|
|
58
|
+
const indexPath = join(stateDir, "index.sqlite3");
|
|
59
|
+
if (!existsSync(indexPath)) return;
|
|
60
|
+
|
|
61
|
+
db.run("ATTACH DATABASE ? AS legacy", [indexPath]);
|
|
62
|
+
try {
|
|
63
|
+
const legacyAudit = db
|
|
64
|
+
.query("SELECT name FROM legacy.sqlite_master WHERE type = 'table' AND name = 'audit'")
|
|
65
|
+
.get();
|
|
66
|
+
if (!legacyAudit) return;
|
|
67
|
+
|
|
68
|
+
// Older audit tables predate the retrieval columns and carry outcome /
|
|
69
|
+
// degraded / selected_skill_id instead. Select what is actually there and
|
|
70
|
+
// let the canonical defaults stand in for the rest.
|
|
71
|
+
const legacyColumns = new Set(
|
|
72
|
+
(db.query("PRAGMA legacy.table_info(audit)").all() as { name: string }[]).map((c) => c.name),
|
|
73
|
+
);
|
|
74
|
+
const retrieval = legacyColumns.has("retrieval") ? "COALESCE(retrieval, 'lexical')" : "'lexical'";
|
|
75
|
+
const degradedFrom = legacyColumns.has("degraded_from") ? "degraded_from" : "NULL";
|
|
76
|
+
const degradationReason = legacyColumns.has("degradation_reason") ? "degradation_reason" : "NULL";
|
|
77
|
+
|
|
78
|
+
// SQLite commits atomically across attached databases, so the copy and the
|
|
79
|
+
// drop either both land or neither does.
|
|
80
|
+
db.transaction(() => {
|
|
81
|
+
db.run(`INSERT INTO audit (ts, query, retrieval, degraded_from, degradation_reason, candidates, latency_ms)
|
|
82
|
+
SELECT ts, query, ${retrieval}, ${degradedFrom}, ${degradationReason}, candidates, latency_ms FROM legacy.audit`);
|
|
83
|
+
db.run("DROP TABLE legacy.audit");
|
|
84
|
+
})();
|
|
85
|
+
} finally {
|
|
86
|
+
db.run("DETACH DATABASE legacy");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
15
90
|
export function openIndex(stateDir: string): Database {
|
|
16
91
|
mkdirSync(stateDir, { recursive: true });
|
|
17
92
|
const db = new Database(join(stateDir, "index.sqlite3"), { create: true });
|
|
@@ -39,48 +114,7 @@ export function openIndex(stateDir: string): Database {
|
|
|
39
114
|
if (!vectorColumns.some((column) => column.name === "embedding_fingerprint")) {
|
|
40
115
|
db.run("ALTER TABLE vectors ADD COLUMN embedding_fingerprint TEXT NOT NULL DEFAULT ''");
|
|
41
116
|
}
|
|
42
|
-
|
|
43
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
44
|
-
ts TEXT NOT NULL,
|
|
45
|
-
query TEXT NOT NULL,
|
|
46
|
-
retrieval TEXT NOT NULL DEFAULT 'lexical',
|
|
47
|
-
degraded_from TEXT,
|
|
48
|
-
degradation_reason TEXT,
|
|
49
|
-
candidates TEXT NOT NULL,
|
|
50
|
-
latency_ms INTEGER NOT NULL
|
|
51
|
-
)`);
|
|
52
|
-
const auditColumns = db.query("PRAGMA table_info(audit)").all() as { name: string }[];
|
|
53
|
-
const columnNames = new Set(auditColumns.map((column) => column.name));
|
|
54
|
-
const hasLegacyColumns =
|
|
55
|
-
columnNames.has("outcome") ||
|
|
56
|
-
columnNames.has("selected_skill_id") ||
|
|
57
|
-
columnNames.has("degraded");
|
|
58
|
-
const missingCanonicalColumns =
|
|
59
|
-
!columnNames.has("retrieval") ||
|
|
60
|
-
!columnNames.has("degraded_from") ||
|
|
61
|
-
!columnNames.has("degradation_reason");
|
|
62
|
-
|
|
63
|
-
if (hasLegacyColumns || missingCanonicalColumns) {
|
|
64
|
-
db.transaction(() => {
|
|
65
|
-
db.run(`CREATE TABLE audit_new (
|
|
66
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
67
|
-
ts TEXT NOT NULL,
|
|
68
|
-
query TEXT NOT NULL,
|
|
69
|
-
retrieval TEXT NOT NULL DEFAULT 'lexical',
|
|
70
|
-
degraded_from TEXT,
|
|
71
|
-
degradation_reason TEXT,
|
|
72
|
-
candidates TEXT NOT NULL,
|
|
73
|
-
latency_ms INTEGER NOT NULL
|
|
74
|
-
)`);
|
|
75
|
-
const retrievalExpr = columnNames.has("retrieval") ? "COALESCE(retrieval, 'lexical')" : "'lexical'";
|
|
76
|
-
const degradedFromExpr = columnNames.has("degraded_from") ? "degraded_from" : "NULL";
|
|
77
|
-
const degradationReasonExpr = columnNames.has("degradation_reason") ? "degradation_reason" : "NULL";
|
|
78
|
-
db.run(`INSERT INTO audit_new (id, ts, query, retrieval, degraded_from, degradation_reason, candidates, latency_ms)
|
|
79
|
-
SELECT id, ts, query, ${retrievalExpr}, ${degradedFromExpr}, ${degradationReasonExpr}, candidates, latency_ms FROM audit`);
|
|
80
|
-
db.run("DROP TABLE audit");
|
|
81
|
-
db.run("ALTER TABLE audit_new RENAME TO audit");
|
|
82
|
-
})();
|
|
83
|
-
}
|
|
117
|
+
// Audit rows live in audit.sqlite3; openAudit adopts any left here.
|
|
84
118
|
db.run(`CREATE TABLE IF NOT EXISTS index_meta (
|
|
85
119
|
key TEXT PRIMARY KEY,
|
|
86
120
|
value TEXT NOT NULL
|
|
@@ -281,6 +315,7 @@ export function vectorTopK(db: Database, query: Float32Array, k: number): SkillR
|
|
|
281
315
|
|
|
282
316
|
export interface AuditInsert {
|
|
283
317
|
ts: string;
|
|
318
|
+
request_id?: string | null;
|
|
284
319
|
query: string;
|
|
285
320
|
retrieval: AuditRow["retrieval"];
|
|
286
321
|
degraded_from?: string | null;
|
|
@@ -291,10 +326,11 @@ export interface AuditInsert {
|
|
|
291
326
|
|
|
292
327
|
export function insertAudit(db: Database, row: AuditInsert): void {
|
|
293
328
|
db.run(
|
|
294
|
-
`INSERT INTO audit (ts, query, retrieval, degraded_from, degradation_reason, candidates, latency_ms)
|
|
295
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
329
|
+
`INSERT INTO audit (ts, request_id, query, retrieval, degraded_from, degradation_reason, candidates, latency_ms)
|
|
330
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
296
331
|
[
|
|
297
332
|
row.ts,
|
|
333
|
+
row.request_id ?? null,
|
|
298
334
|
row.query,
|
|
299
335
|
row.retrieval,
|
|
300
336
|
row.degraded_from ?? null,
|
|
@@ -304,3 +340,74 @@ export function insertAudit(db: Database, row: AuditInsert): void {
|
|
|
304
340
|
],
|
|
305
341
|
);
|
|
306
342
|
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Correlation lookup for AC5/AC7: looks up the resolve that produced
|
|
346
|
+
* `requestId`, or null when it names no known resolve (including malformed
|
|
347
|
+
* input, which is never validated at the boundary per AC7).
|
|
348
|
+
*/
|
|
349
|
+
export function getAuditRowByRequestId(
|
|
350
|
+
db: Database,
|
|
351
|
+
requestId: string,
|
|
352
|
+
): { id: number; candidates: AuditCandidate[] } | null {
|
|
353
|
+
const row = db
|
|
354
|
+
.query("SELECT id, candidates FROM audit WHERE request_id = ?")
|
|
355
|
+
.get(requestId) as { id: number; candidates: string } | null;
|
|
356
|
+
if (!row) return null;
|
|
357
|
+
return { id: row.id, candidates: JSON.parse(row.candidates) as AuditCandidate[] };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export interface FetchInsert {
|
|
361
|
+
ts: string;
|
|
362
|
+
skill_id: string;
|
|
363
|
+
request_id?: string | null;
|
|
364
|
+
resolve_audit_id?: number | null;
|
|
365
|
+
rank_at_resolve?: number | null;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function insertFetch(db: Database, row: FetchInsert): void {
|
|
369
|
+
db.run(
|
|
370
|
+
`INSERT INTO fetch (ts, skill_id, request_id, resolve_audit_id, rank_at_resolve)
|
|
371
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
372
|
+
[
|
|
373
|
+
row.ts,
|
|
374
|
+
row.skill_id,
|
|
375
|
+
row.request_id ?? null,
|
|
376
|
+
row.resolve_audit_id ?? null,
|
|
377
|
+
row.rank_at_resolve ?? null,
|
|
378
|
+
],
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export interface PruneResult {
|
|
383
|
+
audit_deleted: number;
|
|
384
|
+
fetch_deleted: number;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
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
|
|
391
|
+
* pages with an incremental vacuum, which only touches audit.sqlite3 (AC16).
|
|
392
|
+
*/
|
|
393
|
+
export function pruneAuditBefore(db: Database, cutoffIso: string): PruneResult {
|
|
394
|
+
const auditResult = db.run("DELETE FROM audit WHERE ts < ?", [cutoffIso]);
|
|
395
|
+
const fetchResult = db.run("DELETE FROM fetch WHERE ts < ?", [cutoffIso]);
|
|
396
|
+
db.run("PRAGMA incremental_vacuum");
|
|
397
|
+
|
|
398
|
+
return { audit_deleted: auditResult.changes, fetch_deleted: fetchResult.changes };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** AC12: retentionDays <= 0 disables pruning entirely. */
|
|
402
|
+
export function pruneAudit(db: Database, retentionDays: number, now: Date = new Date()): PruneResult {
|
|
403
|
+
if (retentionDays <= 0) return { audit_deleted: 0, fetch_deleted: 0 };
|
|
404
|
+
const cutoff = new Date(now.getTime() - retentionDays * 86_400_000).toISOString();
|
|
405
|
+
return pruneAuditBefore(db, cutoff);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Dry-run counterpart of pruneAuditBefore: counts without deleting (AC15). */
|
|
409
|
+
export function countPrunable(db: Database, cutoffIso: string): PruneResult {
|
|
410
|
+
const auditRow = db.query("SELECT count(*) AS n FROM audit WHERE ts < ?").get(cutoffIso) as { n: number };
|
|
411
|
+
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 };
|
|
413
|
+
}
|
package/src/eval.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
1
2
|
import { readFileSync } from "node:fs";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import {
|
|
@@ -58,6 +59,74 @@ export function parseEvalCases(raw: unknown): EvalCase[] {
|
|
|
58
59
|
return result;
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
/** AC17: dedup key for promoted cases. Collapses whitespace and case differences that are not meaningfully distinct queries. */
|
|
63
|
+
export function normalizeQuery(query: string): string {
|
|
64
|
+
return query.trim().replace(/\s+/g, " ").toLowerCase();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ObservedFetch {
|
|
68
|
+
query: string;
|
|
69
|
+
skill_id: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** AC17: groups fetched skill ids by normalized query into observed-split eval cases. */
|
|
73
|
+
export function buildPromotedCases(fetches: ObservedFetch[]): EvalCase[] {
|
|
74
|
+
const byQuery = new Map<string, { query: string; skillIds: string[]; seen: Set<string> }>();
|
|
75
|
+
for (const fetch of fetches) {
|
|
76
|
+
const key = normalizeQuery(fetch.query);
|
|
77
|
+
if (!key) continue;
|
|
78
|
+
let entry = byQuery.get(key);
|
|
79
|
+
if (!entry) {
|
|
80
|
+
entry = { query: fetch.query, skillIds: [], seen: new Set() };
|
|
81
|
+
byQuery.set(key, entry);
|
|
82
|
+
}
|
|
83
|
+
if (!entry.seen.has(fetch.skill_id)) {
|
|
84
|
+
entry.seen.add(fetch.skill_id);
|
|
85
|
+
entry.skillIds.push(fetch.skill_id);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return [...byQuery.values()].map((entry) => ({
|
|
89
|
+
query: entry.query,
|
|
90
|
+
split: "observed",
|
|
91
|
+
relevant_skill_ids: entry.skillIds,
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** AC18: never rewrites a case whose normalized query already exists in the target file. */
|
|
96
|
+
export function excludeExistingCases(
|
|
97
|
+
cases: EvalCase[],
|
|
98
|
+
existing: EvalCase[],
|
|
99
|
+
): { cases: EvalCase[]; skipped: number } {
|
|
100
|
+
const existingKeys = new Set(existing.map((c) => normalizeQuery(c.query)));
|
|
101
|
+
const kept: EvalCase[] = [];
|
|
102
|
+
let skipped = 0;
|
|
103
|
+
for (const c of cases) {
|
|
104
|
+
if (existingKeys.has(normalizeQuery(c.query))) {
|
|
105
|
+
skipped++;
|
|
106
|
+
} else {
|
|
107
|
+
kept.push(c);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { cases: kept, skipped };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* AC17: joins fetches to the resolve that produced them for promotion. Only
|
|
115
|
+
* correlated fetches (a known resolve_audit_id) carry a query to promote;
|
|
116
|
+
* uncorrelated fetches have no resolve to join against and are excluded.
|
|
117
|
+
*/
|
|
118
|
+
export function queryPromotableFetches(db: Database, sinceIso: string): ObservedFetch[] {
|
|
119
|
+
return db
|
|
120
|
+
.query(
|
|
121
|
+
`SELECT audit.query AS query, fetch.skill_id AS skill_id
|
|
122
|
+
FROM fetch
|
|
123
|
+
JOIN audit ON fetch.resolve_audit_id = audit.id
|
|
124
|
+
WHERE fetch.ts >= ?
|
|
125
|
+
ORDER BY fetch.ts ASC`,
|
|
126
|
+
)
|
|
127
|
+
.all(sinceIso) as ObservedFetch[];
|
|
128
|
+
}
|
|
129
|
+
|
|
61
130
|
export interface EvalMetrics {
|
|
62
131
|
recall_at_5: number;
|
|
63
132
|
recall_at_10: number;
|
package/src/install.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { cpSync, existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
|
|
1
|
+
import { cpSync, existsSync, lstatSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
|
-
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
4
4
|
import { type ScanFinding, readTextFileOrNull, scanContent } from "./scan";
|
|
5
5
|
import { decodeUtf8Strict, listSupportingFiles, parseSkillMd } from "./vault";
|
|
6
6
|
|
|
@@ -12,10 +12,21 @@ export interface RepoSource {
|
|
|
12
12
|
const GIT_URL_PREFIXES = ["http://", "https://", "git://", "ssh://", "file://"];
|
|
13
13
|
const SCP_LIKE_URL_PATTERN = /^[^/\s]+@[^/\s]+:/;
|
|
14
14
|
|
|
15
|
-
function isGitUrl(repo: string): boolean {
|
|
15
|
+
export function isGitUrl(repo: string): boolean {
|
|
16
16
|
return GIT_URL_PREFIXES.some((prefix) => repo.startsWith(prefix)) || SCP_LIKE_URL_PATTERN.test(repo);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/** A `file://` source_url reaches the local filesystem directly, not just a network
|
|
20
|
+
* remote. That's fine when the user typed it themselves at `skillmux install` time,
|
|
21
|
+
* but a `.skillmux-origin` sidecar is vault content — readable and writable by
|
|
22
|
+
* whatever populated the vault (a shared git-backed vault pulled in, or a hand-edit),
|
|
23
|
+
* same threat model as every other vault-content read this codebase guards. `skillmux
|
|
24
|
+
* outdated`/`update` must not blindly git-clone/ls-remote whatever local path a
|
|
25
|
+
* forged sidecar names. */
|
|
26
|
+
export function isLocalFileUrl(url: string): boolean {
|
|
27
|
+
return url.startsWith("file://");
|
|
28
|
+
}
|
|
29
|
+
|
|
19
30
|
export function resolveRepoSource(repo: string): RepoSource {
|
|
20
31
|
if (isGitUrl(repo)) return { url: repo };
|
|
21
32
|
|
|
@@ -49,11 +60,70 @@ export async function cloneToTemp(url: string): Promise<string> {
|
|
|
49
60
|
return dir;
|
|
50
61
|
}
|
|
51
62
|
|
|
63
|
+
export function resolveCloneCommit(cloneDir: string): string {
|
|
64
|
+
const proc = Bun.spawnSync(["git", "-C", cloneDir, "rev-parse", "HEAD"], { stdout: "pipe", stderr: "pipe" });
|
|
65
|
+
if (proc.exitCode !== 0) {
|
|
66
|
+
throw new Error(`git rev-parse HEAD failed in ${cloneDir}: ${proc.stderr.toString().trim()}`);
|
|
67
|
+
}
|
|
68
|
+
return proc.stdout.toString().trim();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function remoteHeadCommit(url: string, ref = "HEAD"): Promise<string> {
|
|
72
|
+
const proc = Bun.spawn(["git", "ls-remote", url, ref], { stdout: "pipe", stderr: "pipe" });
|
|
73
|
+
const exitCode = await proc.exited;
|
|
74
|
+
const stdout = await new Response(proc.stdout).text();
|
|
75
|
+
if (exitCode !== 0) {
|
|
76
|
+
const stderr = await new Response(proc.stderr).text();
|
|
77
|
+
throw new Error(`git ls-remote failed for ${url}: ${stderr.trim()}`);
|
|
78
|
+
}
|
|
79
|
+
const line = stdout.split("\n").find((l) => l.trim().length > 0);
|
|
80
|
+
if (!line) throw new Error(`git ls-remote returned no ref "${ref}" for ${url}`);
|
|
81
|
+
const sha = line.split("\t")[0]?.trim();
|
|
82
|
+
if (!sha || !/^[0-9a-f]{40}$/.test(sha)) {
|
|
83
|
+
throw new Error(`git ls-remote returned an unparseable SHA for ${url}: ${line}`);
|
|
84
|
+
}
|
|
85
|
+
return sha;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Recursively finds symlinks under `dir` (skipping `.git`), without following them.
|
|
89
|
+
* A skill's content must be regular files only — a symlink here is how a malicious
|
|
90
|
+
* skill smuggles an escape out of the vault once `skillmux sync` exposes it inside
|
|
91
|
+
* an agent's native skill directory. `dir` itself is checked too: a `skill_path`
|
|
92
|
+
* can point straight at a directory that git committed *as a symlink* (git supports
|
|
93
|
+
* storing symlink blobs) — walking its descendants alone would silently resolve
|
|
94
|
+
* through it and report the target's real files as clean. */
|
|
95
|
+
export function findSymlinks(dir: string): string[] {
|
|
96
|
+
const found: string[] = [];
|
|
97
|
+
if (lstatSync(dir).isSymbolicLink()) {
|
|
98
|
+
return ["(the skill directory itself is a symlink)"];
|
|
99
|
+
}
|
|
100
|
+
const walk = (current: string) => {
|
|
101
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
102
|
+
if (entry.name === ".git") continue;
|
|
103
|
+
const abs = join(current, entry.name);
|
|
104
|
+
if (entry.isSymbolicLink()) {
|
|
105
|
+
found.push(relative(dir, abs));
|
|
106
|
+
} else if (entry.isDirectory()) {
|
|
107
|
+
walk(abs);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
walk(dir);
|
|
112
|
+
return found.sort();
|
|
113
|
+
}
|
|
114
|
+
|
|
52
115
|
export interface ValidationResult {
|
|
53
116
|
findings: ScanFinding[];
|
|
54
117
|
}
|
|
55
118
|
|
|
56
119
|
export async function validateSkillCandidate(skillId: string, dir: string): Promise<ValidationResult> {
|
|
120
|
+
const symlinks = findSymlinks(dir);
|
|
121
|
+
if (symlinks.length > 0) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`"${skillId}" contains symlink(s), which are not allowed in skill content: ${symlinks.join(", ")}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
57
127
|
const bytes = await Bun.file(join(dir, "SKILL.md")).bytes();
|
|
58
128
|
const body = decodeUtf8Strict(bytes);
|
|
59
129
|
parseSkillMd(skillId, body);
|
|
@@ -78,6 +148,12 @@ export async function validateSkillCandidate(skillId: string, dir: string): Prom
|
|
|
78
148
|
}
|
|
79
149
|
|
|
80
150
|
export function installIntoVault(vaultPath: string, skillId: string, sourceDir: string, force = false): string {
|
|
151
|
+
const symlinks = findSymlinks(sourceDir);
|
|
152
|
+
if (symlinks.length > 0) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`refusing to install "${skillId}": source contains symlink(s), which are not allowed in skill content: ${symlinks.join(", ")}`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
81
157
|
const targetDir = join(vaultPath, skillId);
|
|
82
158
|
if (existsSync(targetDir)) {
|
|
83
159
|
if (!force) {
|
|
@@ -96,6 +172,9 @@ export interface ResolvedSkillDir {
|
|
|
96
172
|
|
|
97
173
|
export function resolveSkillDir(cloneDir: string, fallbackName: string, skillPath?: string): ResolvedSkillDir {
|
|
98
174
|
if (skillPath) {
|
|
175
|
+
if (skillPath.startsWith("/") || skillPath.split("/").includes("..")) {
|
|
176
|
+
throw new Error(`invalid skill_path "${skillPath}": must be a relative path within the repo`);
|
|
177
|
+
}
|
|
99
178
|
return { skillId: basename(skillPath), dir: join(cloneDir, skillPath) };
|
|
100
179
|
}
|
|
101
180
|
if (existsSync(join(cloneDir, "SKILL.md"))) {
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { isGitUrl } from "./install";
|
|
4
|
+
import { SKILLMUX_ORIGIN_FILENAME, listSupportingFiles } from "./vault";
|
|
5
|
+
|
|
6
|
+
export { SKILLMUX_ORIGIN_FILENAME };
|
|
7
|
+
|
|
8
|
+
export interface SkillOrigin {
|
|
9
|
+
schema_version: 1;
|
|
10
|
+
source_url: string;
|
|
11
|
+
skill_path?: string;
|
|
12
|
+
commit: string;
|
|
13
|
+
installed_at: string;
|
|
14
|
+
content_hash: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function validateOrigin(origin: SkillOrigin, path: string): SkillOrigin {
|
|
18
|
+
if (origin.schema_version !== 1) throw new Error(`${path}: unsupported .skillmux-origin schema_version`);
|
|
19
|
+
if (typeof origin.source_url !== "string" || origin.source_url.length === 0) {
|
|
20
|
+
throw new Error(`${path}: .skillmux-origin is missing source_url`);
|
|
21
|
+
}
|
|
22
|
+
if (!isGitUrl(origin.source_url)) {
|
|
23
|
+
throw new Error(`${path}: .skillmux-origin has a source_url that is not a recognized git protocol`);
|
|
24
|
+
}
|
|
25
|
+
if (typeof origin.commit !== "string" || !/^[0-9a-f]{40}$/.test(origin.commit)) {
|
|
26
|
+
throw new Error(`${path}: .skillmux-origin has an invalid commit`);
|
|
27
|
+
}
|
|
28
|
+
if (typeof origin.installed_at !== "string") throw new Error(`${path}: .skillmux-origin is missing installed_at`);
|
|
29
|
+
if (typeof origin.content_hash !== "string" || !/^[a-f0-9]{64}$/.test(origin.content_hash)) {
|
|
30
|
+
throw new Error(`${path}: .skillmux-origin has an invalid content_hash`);
|
|
31
|
+
}
|
|
32
|
+
return origin;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Same defense-in-depth as readSkill/hashSkillContent: a tampered vault entry
|
|
36
|
+
* (shared git-backed vault pulled in, or a hand-edit) could symlink the sidecar
|
|
37
|
+
* itself to an arbitrary host file — refuse to follow it rather than feeding
|
|
38
|
+
* that file's bytes into JSON.parse and, on a schema-shaped coincidence, into
|
|
39
|
+
* the git commands `outdated`/`update` run against `source_url`. */
|
|
40
|
+
export function readSkillOrigin(dir: string): SkillOrigin | null {
|
|
41
|
+
// The skill directory itself must be checked separately from the sidecar's own
|
|
42
|
+
// leaf check below: `lstat` only refuses to follow the *final* path component,
|
|
43
|
+
// so a symlinked skill directory containing a real, non-symlink sidecar at its
|
|
44
|
+
// target would otherwise resolve straight through to arbitrary host content.
|
|
45
|
+
if (existsSync(dir) && lstatSync(dir).isSymbolicLink()) {
|
|
46
|
+
throw new Error(`${dir}: refusing to read .skillmux-origin — the skill directory is a symlink`);
|
|
47
|
+
}
|
|
48
|
+
const path = join(dir, SKILLMUX_ORIGIN_FILENAME);
|
|
49
|
+
if (!existsSync(path)) return null;
|
|
50
|
+
if (lstatSync(path).isSymbolicLink()) {
|
|
51
|
+
throw new Error(`${path}: refusing to read .skillmux-origin — it is a symlink`);
|
|
52
|
+
}
|
|
53
|
+
return validateOrigin(JSON.parse(readFileSync(path, "utf-8")), path);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function writeSkillOrigin(
|
|
57
|
+
dir: string,
|
|
58
|
+
params: {
|
|
59
|
+
source_url: string;
|
|
60
|
+
skill_path?: string;
|
|
61
|
+
commit: string;
|
|
62
|
+
installed_at: string;
|
|
63
|
+
content_hash: string;
|
|
64
|
+
},
|
|
65
|
+
): void {
|
|
66
|
+
const origin: SkillOrigin = { schema_version: 1, ...params };
|
|
67
|
+
writeFileSync(join(dir, SKILLMUX_ORIGIN_FILENAME), JSON.stringify(origin, null, 2));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Deterministic sha256 over every file in a skill directory (SKILL.md plus every
|
|
71
|
+
* file listSupportingFiles returns), used for local-drift detection. listSupportingFiles
|
|
72
|
+
* already excludes symlinks it finds, but SKILL.md is read separately (same reason
|
|
73
|
+
* readSkill/deliverSkill guard it independently in vault.ts/router-core.ts) — this is
|
|
74
|
+
* a local, already-installed vault dir, not the freshly cloned candidate that
|
|
75
|
+
* validateSkillCandidate has vetted, so a swapped-in symlinked SKILL.md must be
|
|
76
|
+
* refused here rather than followed. `dir` itself is checked too: every per-file
|
|
77
|
+
* lstat below only refuses to follow a symlinked *leaf*, so a symlinked `dir`
|
|
78
|
+
* containing real (non-symlink) files at its target would otherwise resolve
|
|
79
|
+
* straight through to arbitrary host content. */
|
|
80
|
+
export function hashSkillContent(dir: string): string {
|
|
81
|
+
const vaultPath = dirname(dir);
|
|
82
|
+
const skillId = basename(dir);
|
|
83
|
+
if (lstatSync(dir).isSymbolicLink()) {
|
|
84
|
+
throw new Error(`refusing to hash ${skillId}: the skill directory is a symlink`);
|
|
85
|
+
}
|
|
86
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
87
|
+
const files = ["SKILL.md", ...listSupportingFiles(vaultPath, skillId)];
|
|
88
|
+
for (const rel of files) {
|
|
89
|
+
const path = join(dir, rel);
|
|
90
|
+
if (lstatSync(path).isSymbolicLink()) {
|
|
91
|
+
throw new Error(`refusing to hash ${skillId}/${rel}: it is a symlink`);
|
|
92
|
+
}
|
|
93
|
+
hasher.update(rel);
|
|
94
|
+
hasher.update("\0");
|
|
95
|
+
hasher.update(readFileSync(path));
|
|
96
|
+
hasher.update("\0");
|
|
97
|
+
}
|
|
98
|
+
return hasher.digest("hex");
|
|
99
|
+
}
|