@klhapp/skillmux 1.8.0 → 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 CHANGED
@@ -5,6 +5,34 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.9.0](https://github.com/klhq/skillmux/compare/v1.8.0...v1.9.0) (2026-08-30)
9
+
10
+
11
+ ### Added
12
+
13
+ * track skill provenance and add outdated/update commands ([#133](https://github.com/klhq/skillmux/issues/133)) ([4d4f8c9](https://github.com/klhq/skillmux/commit/4d4f8c94a7e34e1ad030f9904995d02fa5fa2712))
14
+
15
+
16
+ ### Fixed
17
+
18
+ * **install:** guard findSymlinks against a symlinked skill_path directory itself ([#146](https://github.com/klhq/skillmux/issues/146)) ([a264afc](https://github.com/klhq/skillmux/commit/a264afc19710206c4f77c61e3a9dccafb54c2894))
19
+ * **install:** prevent symlink smuggling in skill content ([#136](https://github.com/klhq/skillmux/issues/136)) ([2df318c](https://github.com/klhq/skillmux/commit/2df318cfdc974b0f7b9042c3d68fa4fece9109d9))
20
+ * **provenance:** refuse to hash a symlinked SKILL.md instead of following it ([#141](https://github.com/klhq/skillmux/issues/141)) ([74d1b9c](https://github.com/klhq/skillmux/commit/74d1b9c2f8fb2891ce743ccf09b9632fea58aa84))
21
+ * **provenance:** refuse to read a symlinked .skillmux-origin sidecar ([#144](https://github.com/klhq/skillmux/issues/144)) ([c9c0b32](https://github.com/klhq/skillmux/commit/c9c0b327f8cf26e1a9cd9971263cfa4f35235de3))
22
+ * **scan:** guard readTextFileOrNull against symlinks at every call site ([#143](https://github.com/klhq/skillmux/issues/143)) ([6692060](https://github.com/klhq/skillmux/commit/66920601bb4969911063407afa3dbffcf09c00be))
23
+ * **scan:** refuse to scan a symlinked SKILL.md in single-skill-dir mode ([#142](https://github.com/klhq/skillmux/issues/142)) ([b20cd66](https://github.com/klhq/skillmux/commit/b20cd6621a30067643034dd155383afab3540cf2))
24
+ * **security:** validate provenance sidecar values before they reach git subprocess calls ([#135](https://github.com/klhq/skillmux/issues/135)) ([01d643a](https://github.com/klhq/skillmux/commit/01d643a789e353d0d944b7b467f3c9f0894718ce))
25
+ * **sync:** skip symlinking core skill dirs with internal links ([#137](https://github.com/klhq/skillmux/issues/137)) ([f992b39](https://github.com/klhq/skillmux/commit/f992b39720dcdd3e946c6628817bd11b54380f40))
26
+ * **update:** skip file:// source_url in outdated/update by default ([#147](https://github.com/klhq/skillmux/issues/147)) ([9b127d0](https://github.com/klhq/skillmux/commit/9b127d0a65f89cfd32e6ed8d75155ba388349fae))
27
+ * **vault:** guard against a symlinked skill directory, not just its leaf files ([#145](https://github.com/klhq/skillmux/issues/145)) ([63d34e3](https://github.com/klhq/skillmux/commit/63d34e32bc4d91221f03fbbfdf22d3a0fdc7a492))
28
+ * **vault:** refuse to read a symlinked SKILL.md ([#140](https://github.com/klhq/skillmux/issues/140)) ([3220fe3](https://github.com/klhq/skillmux/commit/3220fe393a81922f76729284d156644662f177f2))
29
+ * **vault:** secure listSupportingFiles against symlinks and traversal ([#138](https://github.com/klhq/skillmux/issues/138)) ([6343a30](https://github.com/klhq/skillmux/commit/6343a30747844c5f83b810cbfe3d19ed569ca7cd))
30
+
31
+
32
+ ### Changed
33
+
34
+ * **update:** skip cloning drifted skills before fetching ([1b247b6](https://github.com/klhq/skillmux/commit/1b247b6379b021692c839a77d44a81bd736f2bea))
35
+
8
36
  ## [1.8.0](https://github.com/klhq/skillmux/compare/v1.7.1...v1.8.0) (2026-08-28)
9
37
 
10
38
 
package/README.md CHANGED
@@ -213,6 +213,7 @@ Useful management commands:
213
213
 
214
214
  ```sh
215
215
  skillmux scan ~/skills
216
+ skillmux outdated
216
217
  skillmux core pin csv-formatter --yes
217
218
  skillmux project pin my-project code-context --yes
218
219
  skillmux skill which csv-formatter
@@ -55,6 +55,38 @@ With no path, `scan` checks the configured vault. `--json` wraps the result in
55
55
  the standard CLI automation envelope, while `--format json` selects the
56
56
  scanner's raw JSON rendering.
57
57
 
58
+ ## Check and pull upstream updates
59
+
60
+ Every skill `install` places in the vault gets a `.skillmux-origin` sidecar
61
+ recording the source repo, resolved commit, and a content hash. Check which
62
+ installed skills have moved upstream:
63
+
64
+ ```sh
65
+ skillmux outdated
66
+ skillmux outdated --json
67
+ ```
68
+
69
+ Skills you authored by hand, or installed before this sidecar existed, carry
70
+ no `.skillmux-origin` and are silently omitted — `outdated` only reports on
71
+ skills it can trace back to a source. A repo that has become unreachable is
72
+ reported per-skill as `check_failed` with a reason; it does not stop the rest
73
+ of the check.
74
+
75
+ Pull an update for one skill, or every outdated skill at once:
76
+
77
+ ```sh
78
+ skillmux update csv-formatter --dry-run
79
+ skillmux update csv-formatter --yes
80
+ skillmux update --yes
81
+ ```
82
+
83
+ `--dry-run` reports the old and new commit and whether content actually
84
+ changed, without writing anything. A real update re-runs the same scan
85
+ `install` does (`--fail-on` applies the same way) and refuses to overwrite a
86
+ skill whose on-disk content has drifted from what was last installed —
87
+ someone may have hand-edited it. Pass `--force` to overwrite anyway. Like
88
+ `audit prune`, a non-interactive or `--json` run needs `--yes`.
89
+
58
90
  ## Plan client delivery
59
91
 
60
92
  Use product names for common clients:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@klhapp/skillmux",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Skill management and retrieval for AI agents: sync native skills across clients and route the long tail over MCP",
5
5
  "type": "module",
6
6
  "private": false,
package/src/cli.ts CHANGED
@@ -42,10 +42,14 @@ import {
42
42
  cloneToTemp,
43
43
  deriveRepoName,
44
44
  installIntoVault,
45
+ resolveCloneCommit,
45
46
  resolveRepoSource,
46
47
  resolveSkillDir,
47
48
  validateSkillCandidate,
48
49
  } from "./install";
50
+ import { runOutdated } from "./commands/outdated";
51
+ import { runUpdate } from "./commands/update";
52
+ import { hashSkillContent, writeSkillOrigin } from "./provenance";
49
53
  import {
50
54
  parseManifest,
51
55
  resolveManifestPath,
@@ -129,6 +133,8 @@ const KNOWN_COMMANDS = [
129
133
  "audit",
130
134
  "scan",
131
135
  "install",
136
+ "outdated",
137
+ "update",
132
138
  "eval",
133
139
  "doctor",
134
140
  "models",
@@ -142,6 +148,8 @@ function isDockerHostManagementCommand(command: string, subCommand: string): boo
142
148
  "init",
143
149
  "sync",
144
150
  "install",
151
+ "outdated",
152
+ "update",
145
153
  "project",
146
154
  "target",
147
155
  "core",
@@ -337,6 +345,12 @@ async function main() {
337
345
  case "install":
338
346
  await runInstall(rawArgv.slice(1), { isJson });
339
347
  break;
348
+ case "outdated":
349
+ await runOutdated(rawArgv.slice(1), { isJson });
350
+ break;
351
+ case "update":
352
+ await runUpdate(rawArgv.slice(1), { isJson });
353
+ break;
340
354
  case "eval":
341
355
  if (subCommand === "promote") {
342
356
  await runEvalPromote(commandArgs, { isJson, dryRun: isDryRun });
@@ -374,7 +388,7 @@ async function main() {
374
388
  const suggestion = suggestCorrection(command, KNOWN_COMMANDS);
375
389
  const msg = suggestion
376
390
  ? `Unknown command "${command}". Did you mean "${suggestion}"?`
377
- : `usage: skillmux <serve|index|sync|init|project|target|core pin/unpin|report|audit prune|scan|install|eval|doctor|skill which|local-vault init|config show|models download>`;
391
+ : `usage: skillmux <serve|index|sync|init|project|target|core pin/unpin|report|audit prune|scan|install|outdated|update|eval|doctor|skill which|local-vault init|config show|models download>`;
378
392
  throw new Error(msg);
379
393
  }
380
394
  }
@@ -559,10 +573,12 @@ Operations:
559
573
  skillmux report [--server <url> | --db <path>] --since <window> [--json]
560
574
  skillmux audit prune [--older-than <window>] [--dry-run] [--yes] [--json]
561
575
  skillmux eval promote --since <window> [--target <path>] [--dry-run] [--yes] [--json]
576
+ skillmux outdated [--allow-local-source] [--json]
577
+ skillmux update [skill-id] [--yes] [--dry-run] [--force] [--allow-local-source] [--fail-on low|medium|high] [--json]
562
578
 
563
579
  Commands:
564
- serve, index, sync, init, project, target, core, report, audit, scan, install, eval, doctor,
565
- skill, local-vault, config, models, context, completions`);
580
+ serve, index, sync, init, project, target, core, report, audit, scan, install, outdated, update,
581
+ eval, doctor, skill, local-vault, config, models, context, completions`);
566
582
  }
567
583
 
568
584
  // ---------------------------------------------------------------------------
@@ -827,6 +843,11 @@ async function runSync(args: string[]): Promise<void> {
827
843
  console.log(
828
844
  `${targetName}: +${result.added.length} -${result.removed.length}${suffix}`,
829
845
  );
846
+ if (result.skipped.length > 0) {
847
+ console.log(
848
+ ` warning: refused to sync ${result.skipped.join(", ")} — skill directory contains a symlink`,
849
+ );
850
+ }
830
851
 
831
852
  if (target.project_groups.length > 0) {
832
853
  const allGroups = manifest.project ?? {};
@@ -841,6 +862,11 @@ async function runSync(args: string[]): Promise<void> {
841
862
  console.log(
842
863
  ` ${projectResult.group} -> ${projectResult.pinDir}: +${projectResult.added.length} -${projectResult.removed.length}${suffix}`,
843
864
  );
865
+ if (projectResult.skipped.length > 0) {
866
+ console.log(
867
+ ` warning: refused to sync ${projectResult.skipped.join(", ")} — skill directory contains a symlink`,
868
+ );
869
+ }
844
870
  }
845
871
  }
846
872
  }
@@ -1612,12 +1638,20 @@ async function runInstall(
1612
1638
  return;
1613
1639
  }
1614
1640
 
1641
+ const commit = resolveCloneCommit(cloneDir);
1615
1642
  const targetDir = installIntoVault(
1616
1643
  vaultPath,
1617
1644
  resolved.skillId,
1618
1645
  resolved.dir,
1619
1646
  force,
1620
1647
  );
1648
+ writeSkillOrigin(targetDir, {
1649
+ source_url: source.url,
1650
+ skill_path: source.skillPath,
1651
+ commit,
1652
+ installed_at: new Date().toISOString(),
1653
+ content_hash: hashSkillContent(targetDir),
1654
+ });
1621
1655
  emitSuccess(
1622
1656
  { isJson: options.isJson },
1623
1657
  { skill_id: resolved.skillId, installed_at: targetDir },
@@ -0,0 +1,112 @@
1
+ import { readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { expandHome, loadConfig } from "../config";
4
+ import { isLocalFileUrl, remoteHeadCommit } from "../install";
5
+ import { emitSuccess } from "../output";
6
+ import { readSkillOrigin } from "../provenance";
7
+ import { SKILL_ID_PATTERN } from "../vault";
8
+
9
+ export interface OutdatedCheckResult {
10
+ skill_id: string;
11
+ source_url: string;
12
+ recorded_commit: string;
13
+ remote_commit: string | null;
14
+ status: "up_to_date" | "outdated" | "check_failed" | "local_source_skipped";
15
+ reason: string | null;
16
+ }
17
+
18
+ function vaultSkillIds(vaultPath: string): string[] {
19
+ return readdirSync(vaultPath, { withFileTypes: true })
20
+ .filter((entry) => entry.isDirectory() && SKILL_ID_PATTERN.test(entry.name))
21
+ .map((entry) => entry.name)
22
+ .sort();
23
+ }
24
+
25
+ export async function checkOutdated(
26
+ vaultPath: string,
27
+ options: { allowLocalSource?: boolean } = {},
28
+ ): Promise<OutdatedCheckResult[]> {
29
+ const results: OutdatedCheckResult[] = [];
30
+ for (const skillId of vaultSkillIds(vaultPath)) {
31
+ let origin: ReturnType<typeof readSkillOrigin>;
32
+ try {
33
+ origin = readSkillOrigin(join(vaultPath, skillId));
34
+ } catch (error) {
35
+ // A corrupt or unreadable sidecar is this skill's problem alone — never
36
+ // let it abort the check for every other skill in the vault (AC3/AC4's
37
+ // per-skill isolation applies to local parse failures too, not just
38
+ // unreachable remotes).
39
+ results.push({
40
+ skill_id: skillId,
41
+ source_url: "",
42
+ recorded_commit: "",
43
+ remote_commit: null,
44
+ status: "check_failed",
45
+ reason: `.skillmux-origin: ${error instanceof Error ? error.message : String(error)}`,
46
+ });
47
+ continue;
48
+ }
49
+ if (!origin) continue;
50
+
51
+ if (!options.allowLocalSource && isLocalFileUrl(origin.source_url)) {
52
+ results.push({
53
+ skill_id: skillId,
54
+ source_url: origin.source_url,
55
+ recorded_commit: origin.commit,
56
+ remote_commit: null,
57
+ status: "local_source_skipped",
58
+ reason: "source_url is a local file:// path — skipped by default; pass --allow-local-source to check it",
59
+ });
60
+ continue;
61
+ }
62
+
63
+ let remoteCommit: string | null = null;
64
+ let status: OutdatedCheckResult["status"];
65
+ let reason: string | null = null;
66
+ try {
67
+ remoteCommit = await remoteHeadCommit(origin.source_url);
68
+ status = remoteCommit === origin.commit ? "up_to_date" : "outdated";
69
+ } catch (error) {
70
+ status = "check_failed";
71
+ reason = error instanceof Error ? error.message : String(error);
72
+ }
73
+
74
+ results.push({
75
+ skill_id: skillId,
76
+ source_url: origin.source_url,
77
+ recorded_commit: origin.commit,
78
+ remote_commit: remoteCommit,
79
+ status,
80
+ reason,
81
+ });
82
+ }
83
+ return results;
84
+ }
85
+
86
+ export async function runOutdated(args: string[], options: { isJson: boolean }): Promise<void> {
87
+ let allowLocalSource = false;
88
+ for (const arg of args) {
89
+ if (arg === "--json") continue;
90
+ if (arg === "--allow-local-source") {
91
+ allowLocalSource = true;
92
+ continue;
93
+ }
94
+ throw new Error(`unknown outdated option: ${arg}`);
95
+ }
96
+
97
+ const vaultPath = expandHome((await loadConfig()).vault_path);
98
+ const skills = await checkOutdated(vaultPath, { allowLocalSource });
99
+ const checksFailed = skills.filter((s) => s.status === "check_failed").length;
100
+ process.exitCode = checksFailed > 0 ? 1 : 0;
101
+
102
+ emitSuccess({ isJson: options.isJson }, { skills, checks_failed: checksFailed }, () => {
103
+ if (skills.length === 0) {
104
+ console.log("outdated: no vault skills carry provenance");
105
+ return;
106
+ }
107
+ for (const s of skills) {
108
+ const suffix = s.status === "check_failed" ? ` — ${s.reason}` : "";
109
+ console.log(`[${s.status}] ${s.skill_id}${suffix}`);
110
+ }
111
+ });
112
+ }
@@ -0,0 +1,253 @@
1
+ import { rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { expandHome, loadConfig } from "../config";
4
+ import {
5
+ cloneToTemp,
6
+ installIntoVault,
7
+ isLocalFileUrl,
8
+ remoteHeadCommit,
9
+ resolveCloneCommit,
10
+ resolveSkillDir,
11
+ validateSkillCandidate,
12
+ } from "../install";
13
+ import { emitSuccess } from "../output";
14
+ import { hashSkillContent, readSkillOrigin, writeSkillOrigin } from "../provenance";
15
+ import type { SkillOrigin } from "../provenance";
16
+ import { type ScanFinding, type ScanSeverity, scanExitCode } from "../scan";
17
+ import { confirmIfNeeded } from "./shared";
18
+ import { checkOutdated } from "./outdated";
19
+
20
+ type UpdateKind = "update" | "up_to_date" | "skip_drift" | "skip_scan_failed" | "skip_read_error";
21
+
22
+ interface UpdatePlanItem {
23
+ skillId: string;
24
+ oldCommit: string;
25
+ newCommit: string;
26
+ contentChanged: boolean;
27
+ kind: UpdateKind;
28
+ findings?: ScanFinding[];
29
+ reason?: string;
30
+ cloneDir: string | null;
31
+ fetchedDir: string | null;
32
+ origin: SkillOrigin;
33
+ }
34
+
35
+ async function resolveCandidateOrigins(
36
+ vaultPath: string,
37
+ skillId: string | undefined,
38
+ allowLocalSource: boolean,
39
+ ): Promise<{ skillId: string; origin: SkillOrigin }[]> {
40
+ if (skillId) {
41
+ let origin: SkillOrigin | null;
42
+ try {
43
+ origin = readSkillOrigin(join(vaultPath, skillId));
44
+ } catch (error) {
45
+ throw new Error(
46
+ `"${skillId}" has a corrupt .skillmux-origin sidecar: ${error instanceof Error ? error.message : String(error)}`,
47
+ );
48
+ }
49
+ if (!origin) {
50
+ throw new Error(`"${skillId}" has no origin recorded — was this skill installed via "skillmux install"?`);
51
+ }
52
+ if (!allowLocalSource && isLocalFileUrl(origin.source_url)) {
53
+ throw new Error(`"${skillId}" has a local (file://) source — pass --allow-local-source to update it`);
54
+ }
55
+ return [{ skillId, origin }];
56
+ }
57
+ const outdated = await checkOutdated(vaultPath, { allowLocalSource });
58
+ return outdated
59
+ .filter((result) => result.status === "outdated")
60
+ .map((result) => ({ skillId: result.skill_id, origin: readSkillOrigin(join(vaultPath, result.skill_id))! }));
61
+ }
62
+
63
+ async function buildPlan(
64
+ vaultPath: string,
65
+ candidates: { skillId: string; origin: SkillOrigin }[],
66
+ failOn: ScanSeverity | undefined,
67
+ force: boolean,
68
+ ): Promise<UpdatePlanItem[]> {
69
+ const plan: UpdatePlanItem[] = [];
70
+ for (const { skillId, origin } of candidates) {
71
+ const skillDir = join(vaultPath, skillId);
72
+ let currentHash: string;
73
+ try {
74
+ currentHash = hashSkillContent(skillDir);
75
+ } catch (error) {
76
+ // A skill whose on-disk content can't be safely read (e.g. SKILL.md was
77
+ // swapped for a symlink after install — same threat model as AC7's drift
78
+ // check) is this skill's problem alone, matching checkOutdated's per-skill
79
+ // isolation: never let it abort the batch for every other candidate.
80
+ plan.push({
81
+ skillId,
82
+ oldCommit: origin.commit,
83
+ newCommit: origin.commit,
84
+ contentChanged: false,
85
+ kind: "skip_read_error",
86
+ reason: error instanceof Error ? error.message : String(error),
87
+ cloneDir: null,
88
+ fetchedDir: null,
89
+ origin,
90
+ });
91
+ continue;
92
+ }
93
+
94
+ // Drift is a purely local comparison — check it before fetching anything,
95
+ // so a drifted (and therefore skipped) skill never pays for a clone.
96
+ if (currentHash !== origin.content_hash && !force) {
97
+ plan.push({
98
+ skillId,
99
+ oldCommit: origin.commit,
100
+ newCommit: await remoteHeadCommit(origin.source_url),
101
+ contentChanged: false,
102
+ kind: "skip_drift",
103
+ cloneDir: null,
104
+ fetchedDir: null,
105
+ origin,
106
+ });
107
+ continue;
108
+ }
109
+
110
+ const cloneDir = await cloneToTemp(origin.source_url);
111
+ const resolved = resolveSkillDir(cloneDir, skillId, origin.skill_path);
112
+ const base = {
113
+ skillId,
114
+ oldCommit: origin.commit,
115
+ newCommit: resolveCloneCommit(cloneDir),
116
+ cloneDir,
117
+ fetchedDir: resolved.dir,
118
+ origin,
119
+ };
120
+
121
+ const { findings } = await validateSkillCandidate(skillId, resolved.dir);
122
+ if (scanExitCode(findings, failOn) !== 0) {
123
+ plan.push({ ...base, contentChanged: false, kind: "skip_scan_failed", findings });
124
+ continue;
125
+ }
126
+
127
+ const contentChanged = hashSkillContent(resolved.dir) !== currentHash;
128
+ plan.push({
129
+ ...base,
130
+ contentChanged,
131
+ kind: base.newCommit === origin.commit && !contentChanged ? "up_to_date" : "update",
132
+ });
133
+ }
134
+ return plan;
135
+ }
136
+
137
+ export function buildConfirmPrompt(toWrite: Pick<UpdatePlanItem, "skillId" | "origin">[]): string {
138
+ const lines = toWrite.map((item) => ` ${item.skillId} <- ${item.origin.source_url}`);
139
+ return `update:\n${lines.join("\n")}\n?`;
140
+ }
141
+
142
+ function statusFor(kind: UpdateKind, dryRun: boolean): string {
143
+ switch (kind) {
144
+ case "update":
145
+ return dryRun ? "would_update" : "updated";
146
+ case "skip_drift":
147
+ return dryRun ? "would_skip_drift" : "skipped_drift";
148
+ case "skip_scan_failed":
149
+ return dryRun ? "would_skip_scan_failed" : "skipped_scan_failed";
150
+ case "skip_read_error":
151
+ return "skipped_read_error";
152
+ default:
153
+ return "up_to_date";
154
+ }
155
+ }
156
+
157
+ function parseUpdateArgs(args: string[]): {
158
+ skillId?: string;
159
+ yes: boolean;
160
+ dryRun: boolean;
161
+ force: boolean;
162
+ failOn?: ScanSeverity;
163
+ allowLocalSource: boolean;
164
+ } {
165
+ let skillId: string | undefined;
166
+ let yes = false;
167
+ let dryRun = false;
168
+ let force = false;
169
+ let failOn: ScanSeverity | undefined;
170
+ let allowLocalSource = false;
171
+ for (let i = 0; i < args.length; i++) {
172
+ const arg = args[i];
173
+ if (arg === "--yes") yes = true;
174
+ else if (arg === "--dry-run") dryRun = true;
175
+ else if (arg === "--force") force = true;
176
+ else if (arg === "--allow-local-source") allowLocalSource = true;
177
+ else if (arg === "--fail-on") {
178
+ const value = args[++i];
179
+ if (value !== "low" && value !== "medium" && value !== "high") {
180
+ throw new Error("--fail-on must be low, medium, or high");
181
+ }
182
+ failOn = value;
183
+ } else if (arg === "--json") {
184
+ // handled globally
185
+ } else if (arg?.startsWith("--")) {
186
+ throw new Error(`unknown update option: ${arg}`);
187
+ } else if (skillId !== undefined) {
188
+ throw new Error("skillmux update accepts at most one <skill-id> argument");
189
+ } else {
190
+ skillId = arg;
191
+ }
192
+ }
193
+ return { skillId, yes, dryRun, force, failOn, allowLocalSource };
194
+ }
195
+
196
+ export async function runUpdate(args: string[], options: { isJson: boolean }): Promise<void> {
197
+ const { skillId, yes, dryRun, force, failOn, allowLocalSource } = parseUpdateArgs(args);
198
+ const vaultPath = expandHome((await loadConfig()).vault_path);
199
+
200
+ const candidates = await resolveCandidateOrigins(vaultPath, skillId, allowLocalSource);
201
+ const plan = await buildPlan(vaultPath, candidates, failOn, force);
202
+ try {
203
+ const toWrite = plan.filter((item) => item.kind === "update");
204
+
205
+ if (!dryRun && toWrite.length > 0) {
206
+ const proceed = await confirmIfNeeded({
207
+ confirmed: yes,
208
+ isJson: options.isJson,
209
+ prompt: buildConfirmPrompt(toWrite),
210
+ nonInteractiveError: "skillmux update requires --yes when run non-interactively",
211
+ });
212
+ if (!proceed) return;
213
+
214
+ for (const item of toWrite) {
215
+ // toWrite is filtered to kind === "update", which is only ever set after
216
+ // a successful clone above, so fetchedDir is always populated here.
217
+ const targetDir = installIntoVault(vaultPath, item.skillId, item.fetchedDir as string, true);
218
+ writeSkillOrigin(targetDir, {
219
+ source_url: item.origin.source_url,
220
+ skill_path: item.origin.skill_path,
221
+ commit: item.newCommit,
222
+ installed_at: new Date().toISOString(),
223
+ content_hash: hashSkillContent(targetDir),
224
+ });
225
+ }
226
+ }
227
+
228
+ const skills = plan.map((item) => ({
229
+ skill_id: item.skillId,
230
+ source_url: item.origin.source_url,
231
+ old_commit: item.oldCommit,
232
+ new_commit: item.newCommit,
233
+ content_changed: item.contentChanged,
234
+ status: statusFor(item.kind, dryRun),
235
+ ...(item.findings ? { findings: item.findings } : {}),
236
+ ...(item.reason ? { reason: item.reason } : {}),
237
+ }));
238
+
239
+ emitSuccess({ isJson: options.isJson }, { dry_run: dryRun, skills }, () => {
240
+ if (skills.length === 0) {
241
+ console.log("update: nothing to do");
242
+ return;
243
+ }
244
+ for (const s of skills) {
245
+ console.log(`[${s.status}] ${s.skill_id}`);
246
+ }
247
+ });
248
+ } finally {
249
+ for (const item of plan) {
250
+ if (item.cloneDir) rmSync(item.cloneDir, { recursive: true, force: true });
251
+ }
252
+ }
253
+ }
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
+ }
@@ -1,5 +1,5 @@
1
1
  import type { Database } from "bun:sqlite";
2
- import { existsSync, watch } from "node:fs";
2
+ import { existsSync, lstatSync, watch } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { buildAuditRow } from "./audit";
5
5
  import { embeddingDimension, embeddingFingerprint, expandHome, loadConfig } from "./config";
@@ -161,8 +161,20 @@ async function deliverSkill(db: Database, config: Config, skillId: string): Prom
161
161
  let raw = "";
162
162
  for (let i = 0; i < candidates.length; i++) {
163
163
  const candidate = candidates[i]!;
164
- const file = Bun.file(join(candidate, skillId, "SKILL.md"));
164
+ const skillDir = join(candidate, skillId);
165
+ const path = join(skillDir, "SKILL.md");
166
+ const file = Bun.file(path);
165
167
  if (!(await file.exists())) continue;
168
+ // A symlinked SKILL.md must never be read here: this is the direct "zero-loss
169
+ // delivery" read path that bypasses the index and readSkill's own symlink guard
170
+ // (vault.ts), and its `body` is served straight into the agent's context.
171
+ //
172
+ // The skill directory itself must be checked too, separately from SKILL.md's
173
+ // leaf check above: `lstat` only refuses to follow the *final* path component,
174
+ // so a symlinked skill directory (e.g. a tampered local_vault_paths override)
175
+ // containing a real, non-symlink SKILL.md at its target silently passes the
176
+ // leaf check while still resolving straight through to arbitrary host content.
177
+ if (lstatSync(skillDir).isSymbolicLink() || lstatSync(path).isSymbolicLink()) continue;
166
178
  const candidateBytes = await file.bytes();
167
179
  const candidateRaw = decodeUtf8Strict(candidateBytes);
168
180
  if (i < candidates.length - 1) {
package/src/scan.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, lstatSync } from "node:fs";
2
2
  import { basename, dirname, join } from "node:path";
3
3
  import { decodeUtf8Strict, listSupportingFiles, scanVault } from "./vault";
4
4
 
@@ -162,8 +162,14 @@ interface ScanContentTarget {
162
162
  content: string;
163
163
  }
164
164
 
165
+ /** Refuses (returns null for) a symlinked path instead of following it — every
166
+ * caller feeds vault content into scan findings, so a symlink swapped in after
167
+ * an earlier symlink-filtering pass (e.g. listSupportingFiles' walk, which runs
168
+ * before this read, not at it) must still be caught right here, not trusted to
169
+ * have stayed excluded. Same defense-in-depth pattern as readSkill/hashSkillContent. */
165
170
  export async function readTextFileOrNull(path: string): Promise<string | null> {
166
171
  try {
172
+ if (lstatSync(path).isSymbolicLink()) return null;
167
173
  const bytes = await Bun.file(path).bytes();
168
174
  return decodeUtf8Strict(bytes);
169
175
  } catch {
package/src/sync.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join, relative } from "node:path";
4
+ import { findSymlinks } from "./install";
4
5
  import { resolveSkillRoot } from "./vault";
5
6
 
6
7
  export const SKILLMUX_MARKER_FILENAME = ".skillmux";
@@ -101,25 +102,47 @@ export interface SyncTargetParams {
101
102
  export interface SyncTargetResult {
102
103
  added: string[];
103
104
  removed: string[];
105
+ /** Core skills whose directory contains an internal symlink and were refused —
106
+ * syncTarget symlinks the whole skill directory into the target, so an internal
107
+ * symlink would otherwise be exposed to the agent regardless of how it got into
108
+ * the vault (install/update already reject them, but a shared git-backed vault
109
+ * can still be tampered with directly). */
110
+ skipped: string[];
104
111
  }
105
112
 
106
113
  export interface SyncTargetOptions {
107
114
  dryRun?: boolean;
108
115
  }
109
116
 
117
+ /** Splits `skillIds` into those safe to symlink into a target and those refused
118
+ * for containing an internal symlink (see SyncTargetResult.skipped). */
119
+ function partitionSyncable(
120
+ skillIds: string[],
121
+ skillSource: (skillId: string) => string,
122
+ ): { syncable: string[]; skipped: string[] } {
123
+ const syncable: string[] = [];
124
+ const skipped: string[] = [];
125
+ for (const skillId of skillIds) {
126
+ if (findSymlinks(join(skillSource(skillId), skillId)).length > 0) skipped.push(skillId);
127
+ else syncable.push(skillId);
128
+ }
129
+ return { syncable, skipped };
130
+ }
131
+
110
132
  export function syncTarget(params: SyncTargetParams, options: SyncTargetOptions = {}): SyncTargetResult {
111
133
  const { vaultPath, targetDir, targetName, coreSkillIds, localVaultPaths = [] } = params;
112
134
  const { dryRun = false } = options;
113
135
  const skillSource = (skillId: string) => resolveSkillRoot(skillId, vaultPath, localVaultPaths) ?? vaultPath;
114
136
 
115
137
  if (!existsSync(targetDir)) {
116
- if (dryRun) return { added: [...coreSkillIds], removed: [] };
138
+ const { syncable, skipped } = partitionSyncable(coreSkillIds, skillSource);
139
+ if (dryRun) return { added: syncable, removed: [], skipped };
117
140
  mkdirSync(targetDir, { recursive: true });
118
- for (const skillId of coreSkillIds) {
141
+ for (const skillId of syncable) {
119
142
  symlinkSync(join(skillSource(skillId), skillId), join(targetDir, skillId));
120
143
  }
121
- writeTargetMarker(targetDir, targetName, vaultPath, coreSkillIds);
122
- return { added: [...coreSkillIds], removed: [] };
144
+ writeTargetMarker(targetDir, targetName, vaultPath, syncable);
145
+ return { added: syncable, removed: [], skipped };
123
146
  }
124
147
 
125
148
  let marker = readSkillmuxMarker(targetDir);
@@ -167,14 +190,21 @@ export function syncTarget(params: SyncTargetParams, options: SyncTargetOptions
167
190
  }
168
191
 
169
192
  const removed = [...managedEntries].filter((name) => existing.includes(name) && !desired.has(name));
170
- const added = coreSkillIds.filter((skillId) => !existing.includes(skillId));
171
- if (dryRun) return { added, removed };
193
+ const addedCandidates = coreSkillIds.filter((skillId) => !existing.includes(skillId));
194
+ const { syncable: added, skipped } = partitionSyncable(addedCandidates, skillSource);
195
+ if (dryRun) return { added, removed, skipped };
172
196
 
173
197
  for (const name of removed) unlinkSync(join(targetDir, name));
174
198
  for (const skillId of added) symlinkSync(join(skillSource(skillId), skillId), join(targetDir, skillId));
175
- writeTargetMarker(targetDir, targetName, vaultPath, coreSkillIds, marker.created_at);
199
+ writeTargetMarker(
200
+ targetDir,
201
+ targetName,
202
+ vaultPath,
203
+ coreSkillIds.filter((skillId) => !skipped.includes(skillId)),
204
+ marker.created_at,
205
+ );
176
206
 
177
- return { added, removed };
207
+ return { added, removed, skipped };
178
208
  }
179
209
 
180
210
  export interface AdoptTargetResult {
package/src/vault.ts CHANGED
@@ -1,8 +1,12 @@
1
- import { existsSync, readdirSync, statSync } from "node:fs";
1
+ import { existsSync, lstatSync, readdirSync, statSync } from "node:fs";
2
2
  import { join, relative } from "node:path";
3
3
 
4
4
  export const SKILL_ID_PATTERN = /^[a-z0-9][a-z0-9-]{1,127}$/;
5
5
 
6
+ /** Provenance sidecar filename (see provenance.ts). Kept here, not re-imported from
7
+ * provenance.ts, so listSupportingFiles can exclude it without a circular import. */
8
+ export const SKILLMUX_ORIGIN_FILENAME = ".skillmux-origin";
9
+
6
10
  export interface VaultSkill {
7
11
  skill_id: string;
8
12
  title: string;
@@ -45,8 +49,26 @@ export function decodeUtf8Strict(bytes: Uint8Array): string {
45
49
  return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
46
50
  }
47
51
 
52
+ /** SKILL.md itself is read directly (not via listSupportingFiles, which excludes
53
+ * it by name) — so its own symlink-ness must be checked here. Without this, a
54
+ * symlinked SKILL.md reachable via a shared git-backed vault or a hand-edit would
55
+ * have its target's content indexed and delivered to agents as the skill body.
56
+ *
57
+ * The skill directory itself must be checked too, separately from SKILL.md's own
58
+ * leaf check: `lstat` only refuses to follow the *final* path component, so a
59
+ * symlinked skill directory containing a real (non-symlink) SKILL.md file at its
60
+ * target silently passes the leaf check while still resolving straight through
61
+ * to arbitrary host content. */
48
62
  export async function readSkill(vaultPath: string, skillId: string): Promise<VaultSkill> {
49
- const bytes = await Bun.file(join(vaultPath, skillId, "SKILL.md")).bytes();
63
+ const skillDir = join(vaultPath, skillId);
64
+ if (lstatSync(skillDir).isSymbolicLink()) {
65
+ throw new Error(`refusing to read ${skillId}: the skill directory is a symlink`);
66
+ }
67
+ const path = join(skillDir, "SKILL.md");
68
+ if (lstatSync(path).isSymbolicLink()) {
69
+ throw new Error(`refusing to read ${skillId}/SKILL.md: it is a symlink`);
70
+ }
71
+ const bytes = await Bun.file(path).bytes();
50
72
  return parseSkillMd(skillId, decodeUtf8Strict(bytes));
51
73
  }
52
74
 
@@ -124,17 +146,35 @@ export function findShadowedSkills(vaultPath: string, localVaultPaths: string[])
124
146
  return shadowed.sort((a, b) => a.skill_id.localeCompare(b.skill_id));
125
147
  }
126
148
 
127
- /** Relative paths of everything under the skill dir except SKILL.md itself, sorted. */
149
+ /** Relative paths of everything under the skill dir except SKILL.md and the
150
+ * provenance sidecar, sorted. Symlinks are excluded rather than followed —
151
+ * install/sync already refuse a skill containing one, but a symlink can still
152
+ * reach the vault directly (a shared git-backed vault pulled in, or a hand-edit),
153
+ * and this function otherwise feeds skill content straight into content
154
+ * scanning (scan.ts) and drift hashing (provenance.ts). `skillId` must be a
155
+ * single path segment — not the stricter SKILL_ID_PATTERN, since scanPath's
156
+ * ad-hoc single-directory mode legitimately passes an arbitrary, not-yet-
157
+ * normalized directory name — so `..`/`.`/a path separator is rejected to
158
+ * keep the walk inside `vaultPath` without breaking that mode. */
128
159
  export function listSupportingFiles(vaultPath: string, skillId: string): string[] {
160
+ if (skillId === "" || skillId === "." || skillId === ".." || skillId.includes("/") || skillId.includes("\\")) {
161
+ return [];
162
+ }
129
163
  const root = join(vaultPath, skillId);
164
+ // A symlinked skill directory must not be walked into: readdirSync follows a
165
+ // symlinked path argument (unlike the entry.isSymbolicLink() check below, which
166
+ // only applies to entries *found by* the walk), so without this a symlinked
167
+ // `root` would return the target directory's file listing straight through.
168
+ if (!existsSync(root) || lstatSync(root).isSymbolicLink()) return [];
130
169
  const files: string[] = [];
131
170
  const walk = (dir: string) => {
132
171
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
172
+ if (entry.isSymbolicLink()) continue;
133
173
  const abs = join(dir, entry.name);
134
174
  if (entry.isDirectory()) walk(abs);
135
175
  else if (statSync(abs).isFile()) {
136
176
  const rel = relative(root, abs);
137
- if (rel !== "SKILL.md") files.push(rel);
177
+ if (rel !== "SKILL.md" && rel !== SKILLMUX_ORIGIN_FILENAME) files.push(rel);
138
178
  }
139
179
  }
140
180
  };