@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/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
- db.run(`CREATE TABLE IF NOT EXISTS audit (
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
+ }