@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/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
  };
Binary file