@klhapp/skillmux 1.8.0 → 1.9.1

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,44 @@ 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.1](https://github.com/klhq/skillmux/compare/v1.9.0...v1.9.1) (2026-08-30)
9
+
10
+
11
+ ### Fixed
12
+
13
+ * **install:** reject '.' / '..' skill ids derived during install (path traversal) ([#151](https://github.com/klhq/skillmux/issues/151)) ([2426613](https://github.com/klhq/skillmux/commit/2426613b79c67e2afa267de946492b6b020011bb))
14
+ * **install:** reject scp-like git URLs starting with '-' (argument injection RCE) ([#148](https://github.com/klhq/skillmux/issues/148)) ([6a9db28](https://github.com/klhq/skillmux/commit/6a9db28670201237dd17455592fa30cbce43b58f))
15
+ * **sync:** require approval before creating a new target directory ([#152](https://github.com/klhq/skillmux/issues/152)) ([6fcfd1a](https://github.com/klhq/skillmux/commit/6fcfd1a8beed87e5f88e2bd24b84a94a3adb3d89))
16
+ * **update:** validate skill-id against SKILL_ID_PATTERN before path-joining it ([#150](https://github.com/klhq/skillmux/issues/150)) ([0bfa807](https://github.com/klhq/skillmux/commit/0bfa8071a80ea4f70b2e6974cb2cb439666f8357))
17
+
18
+ ## [1.9.0](https://github.com/klhq/skillmux/compare/v1.8.0...v1.9.0) (2026-08-30)
19
+
20
+
21
+ ### Added
22
+
23
+ * track skill provenance and add outdated/update commands ([#133](https://github.com/klhq/skillmux/issues/133)) ([4d4f8c9](https://github.com/klhq/skillmux/commit/4d4f8c94a7e34e1ad030f9904995d02fa5fa2712))
24
+
25
+
26
+ ### Fixed
27
+
28
+ * **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))
29
+ * **install:** prevent symlink smuggling in skill content ([#136](https://github.com/klhq/skillmux/issues/136)) ([2df318c](https://github.com/klhq/skillmux/commit/2df318cfdc974b0f7b9042c3d68fa4fece9109d9))
30
+ * **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))
31
+ * **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))
32
+ * **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))
33
+ * **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))
34
+ * **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))
35
+ * **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))
36
+ * **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))
37
+ * **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))
38
+ * **vault:** refuse to read a symlinked SKILL.md ([#140](https://github.com/klhq/skillmux/issues/140)) ([3220fe3](https://github.com/klhq/skillmux/commit/3220fe393a81922f76729284d156644662f177f2))
39
+ * **vault:** secure listSupportingFiles against symlinks and traversal ([#138](https://github.com/klhq/skillmux/issues/138)) ([6343a30](https://github.com/klhq/skillmux/commit/6343a30747844c5f83b810cbfe3d19ed569ca7cd))
40
+
41
+
42
+ ### Changed
43
+
44
+ * **update:** skip cloning drifted skills before fetching ([1b247b6](https://github.com/klhq/skillmux/commit/1b247b6379b021692c839a77d44a81bd736f2bea))
45
+
8
46
  ## [1.8.0](https://github.com/klhq/skillmux/compare/v1.7.1...v1.8.0) (2026-08-28)
9
47
 
10
48
 
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.1",
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,
@@ -78,10 +82,12 @@ import {
78
82
  import { getStats, renderStatsText, type StatsResponse } from "./stats";
79
83
  import {
80
84
  installPostMergeHook,
85
+ resolveProjectPinDir,
81
86
  restoreMonolith as restoreMonolithTarget,
82
87
  syncProjectTargets,
83
88
  syncTarget,
84
89
  writeLocalVaultMarker,
90
+ type ProjectGroupInput,
85
91
  } from "./sync";
86
92
  import { scanVault, vaultResolutionOrder } from "./vault";
87
93
 
@@ -129,6 +135,8 @@ const KNOWN_COMMANDS = [
129
135
  "audit",
130
136
  "scan",
131
137
  "install",
138
+ "outdated",
139
+ "update",
132
140
  "eval",
133
141
  "doctor",
134
142
  "models",
@@ -142,6 +150,8 @@ function isDockerHostManagementCommand(command: string, subCommand: string): boo
142
150
  "init",
143
151
  "sync",
144
152
  "install",
153
+ "outdated",
154
+ "update",
145
155
  "project",
146
156
  "target",
147
157
  "core",
@@ -337,6 +347,12 @@ async function main() {
337
347
  case "install":
338
348
  await runInstall(rawArgv.slice(1), { isJson });
339
349
  break;
350
+ case "outdated":
351
+ await runOutdated(rawArgv.slice(1), { isJson });
352
+ break;
353
+ case "update":
354
+ await runUpdate(rawArgv.slice(1), { isJson });
355
+ break;
340
356
  case "eval":
341
357
  if (subCommand === "promote") {
342
358
  await runEvalPromote(commandArgs, { isJson, dryRun: isDryRun });
@@ -374,7 +390,7 @@ async function main() {
374
390
  const suggestion = suggestCorrection(command, KNOWN_COMMANDS);
375
391
  const msg = suggestion
376
392
  ? `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>`;
393
+ : `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
394
  throw new Error(msg);
379
395
  }
380
396
  }
@@ -559,10 +575,12 @@ Operations:
559
575
  skillmux report [--server <url> | --db <path>] --since <window> [--json]
560
576
  skillmux audit prune [--older-than <window>] [--dry-run] [--yes] [--json]
561
577
  skillmux eval promote --since <window> [--target <path>] [--dry-run] [--yes] [--json]
578
+ skillmux outdated [--allow-local-source] [--json]
579
+ skillmux update [skill-id] [--yes] [--dry-run] [--force] [--allow-local-source] [--fail-on low|medium|high] [--json]
562
580
 
563
581
  Commands:
564
- serve, index, sync, init, project, target, core, report, audit, scan, install, eval, doctor,
565
- skill, local-vault, config, models, context, completions`);
582
+ serve, index, sync, init, project, target, core, report, audit, scan, install, outdated, update,
583
+ eval, doctor, skill, local-vault, config, models, context, completions`);
566
584
  }
567
585
 
568
586
  // ---------------------------------------------------------------------------
@@ -755,21 +773,45 @@ function parseSyncArgs(args: string[]): {
755
773
  dryRun: boolean;
756
774
  restoreMonolith: boolean;
757
775
  installHook: boolean;
776
+ yes: boolean;
758
777
  } {
759
778
  let dryRun = false;
760
779
  let restoreMonolith = false;
761
780
  let installHook = false;
781
+ let yes = false;
762
782
  for (const arg of args) {
763
783
  if (arg === "--dry-run") dryRun = true;
764
784
  else if (arg === "--restore-monolith") restoreMonolith = true;
765
785
  else if (arg === "--install-hook") installHook = true;
786
+ else if (arg === "--yes") yes = true;
766
787
  else throw new Error(`unknown sync option: ${arg}`);
767
788
  }
768
- return { dryRun, restoreMonolith, installHook };
789
+ return { dryRun, restoreMonolith, installHook, yes };
790
+ }
791
+
792
+ /**
793
+ * A target directory that doesn't exist yet is about to be created by `sync`.
794
+ * `manifest.targets[*].dir` is vault content — readable and writable by whatever
795
+ * populated the vault (a shared git-backed vault pulled in, or a hand-edit) — and
796
+ * `sync` can run unattended via the `--install-hook` post-merge hook. Without this
797
+ * gate, a tampered manifest naming a brand-new path gets that directory silently
798
+ * created (and populated with symlinks) the next time anyone pulls. Creation for
799
+ * an as-yet-unseen directory therefore requires either `--yes` or an interactive
800
+ * confirmation; once the directory exists, later syncs never hit this path again.
801
+ */
802
+ async function confirmNewSyncTarget(label: string, dir: string, yes: boolean): Promise<boolean> {
803
+ if (yes) return true;
804
+ if (!isInteractive()) {
805
+ console.log(
806
+ `${label}: skipped — ${dir} does not exist yet; creating it requires approval. Re-run "skillmux sync --yes", or run "skillmux sync" interactively, once you've confirmed this target is expected.`,
807
+ );
808
+ return false;
809
+ }
810
+ return confirmAction(`${label}: create new target directory ${dir}?`);
769
811
  }
770
812
 
771
813
  async function runSync(args: string[]): Promise<void> {
772
- const { dryRun, restoreMonolith, installHook } = parseSyncArgs(args);
814
+ const { dryRun, restoreMonolith, installHook, yes } = parseSyncArgs(args);
773
815
  const config = await loadConfig();
774
816
  const vaultPath = expandHome(config.vault_path);
775
817
 
@@ -813,6 +855,16 @@ async function runSync(args: string[]): Promise<void> {
813
855
  continue;
814
856
  }
815
857
 
858
+ if (!dryRun && !existsSync(targetDir)) {
859
+ const approved = await confirmNewSyncTarget(targetName, targetDir, yes);
860
+ if (!approved) {
861
+ if (isInteractive()) {
862
+ console.log(`${targetName}: skipped — creating ${targetDir} was not approved`);
863
+ }
864
+ continue;
865
+ }
866
+ }
867
+
816
868
  const suffix = dryRun ? " (dry-run)" : "";
817
869
  const result = syncTarget(
818
870
  {
@@ -827,12 +879,32 @@ async function runSync(args: string[]): Promise<void> {
827
879
  console.log(
828
880
  `${targetName}: +${result.added.length} -${result.removed.length}${suffix}`,
829
881
  );
882
+ if (result.skipped.length > 0) {
883
+ console.log(
884
+ ` warning: refused to sync ${result.skipped.join(", ")} — skill directory contains a symlink`,
885
+ );
886
+ }
830
887
 
831
888
  if (target.project_groups.length > 0) {
832
889
  const allGroups = manifest.project ?? {};
833
- const projectGroups = Object.fromEntries(
834
- target.project_groups.map((name) => [name, allGroups[name]!]),
835
- );
890
+ const projectGroups: Record<string, ProjectGroupInput> = {};
891
+ for (const groupName of target.project_groups) {
892
+ const group = allGroups[groupName]!;
893
+ const approvedPaths: string[] = [];
894
+ for (const path of group.paths) {
895
+ // Mirror syncProjectTargets' own `if (!existsSync(path)) continue` so we
896
+ // never prompt for a project path it would silently skip anyway.
897
+ if (!existsSync(path)) continue;
898
+ const pinDir = resolveProjectPinDir(targetDir, path);
899
+ if (dryRun || existsSync(pinDir)) {
900
+ approvedPaths.push(path);
901
+ continue;
902
+ }
903
+ const approved = await confirmNewSyncTarget(`${targetName}/${groupName}`, pinDir, yes);
904
+ if (approved) approvedPaths.push(path);
905
+ }
906
+ projectGroups[groupName] = { ...group, paths: approvedPaths };
907
+ }
836
908
  const projectResults = syncProjectTargets(
837
909
  { vaultPath, targetDir, targetName, projectGroups, localVaultPaths },
838
910
  { dryRun },
@@ -841,6 +913,11 @@ async function runSync(args: string[]): Promise<void> {
841
913
  console.log(
842
914
  ` ${projectResult.group} -> ${projectResult.pinDir}: +${projectResult.added.length} -${projectResult.removed.length}${suffix}`,
843
915
  );
916
+ if (projectResult.skipped.length > 0) {
917
+ console.log(
918
+ ` warning: refused to sync ${projectResult.skipped.join(", ")} — skill directory contains a symlink`,
919
+ );
920
+ }
844
921
  }
845
922
  }
846
923
  }
@@ -1411,7 +1488,12 @@ async function runInit(
1411
1488
  ) {
1412
1489
  console.log(`\n${printLastMile()}`);
1413
1490
  }
1414
- if (guided && sync && confirmedTargets.length > 0) await runSync([]);
1491
+ // Reaching this point already required approval above (--yes, or an accepted
1492
+ // confirmAction naming these exact targets/dirs) — that approval covers whatever
1493
+ // new target directories this init just adopted, so runSync's own new-target
1494
+ // confirmation gate would just be a redundant (and non-interactively,
1495
+ // silently-skipping) re-ask.
1496
+ if (guided && sync && confirmedTargets.length > 0) await runSync(["--yes"]);
1415
1497
  }
1416
1498
 
1417
1499
  function parseReportArgs(args: string[]): {
@@ -1612,12 +1694,20 @@ async function runInstall(
1612
1694
  return;
1613
1695
  }
1614
1696
 
1697
+ const commit = resolveCloneCommit(cloneDir);
1615
1698
  const targetDir = installIntoVault(
1616
1699
  vaultPath,
1617
1700
  resolved.skillId,
1618
1701
  resolved.dir,
1619
1702
  force,
1620
1703
  );
1704
+ writeSkillOrigin(targetDir, {
1705
+ source_url: source.url,
1706
+ skill_path: source.skillPath,
1707
+ commit,
1708
+ installed_at: new Date().toISOString(),
1709
+ content_hash: hashSkillContent(targetDir),
1710
+ });
1621
1711
  emitSuccess(
1622
1712
  { isJson: options.isJson },
1623
1713
  { 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
+ }
@@ -397,7 +397,12 @@ export async function runProject(
397
397
  writeManifestAtomic(manifestPath, updated);
398
398
  if (request.sync) {
399
399
  try {
400
- await options.sync([]);
400
+ // Reaching here already required approval above (request.yes, or an
401
+ // accepted interactive confirmAction) — that approval covers whatever
402
+ // new target/pin directories this project setup implies, so the
403
+ // downstream sync's own new-target confirmation gate would just be a
404
+ // redundant (and, non-interactively, silently-skipping) re-ask.
405
+ await options.sync(["--yes"]);
401
406
  } catch (error) {
402
407
  throw new Error(
403
408
  `project configuration was saved, but sync failed; fix the reported issue and run "skillmux sync": ${
@@ -0,0 +1,264 @@
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 { SKILL_ID_PATTERN } from "../vault";
18
+ import { confirmIfNeeded } from "./shared";
19
+ import { checkOutdated } from "./outdated";
20
+
21
+ type UpdateKind = "update" | "up_to_date" | "skip_drift" | "skip_scan_failed" | "skip_read_error";
22
+
23
+ interface UpdatePlanItem {
24
+ skillId: string;
25
+ oldCommit: string;
26
+ newCommit: string;
27
+ contentChanged: boolean;
28
+ kind: UpdateKind;
29
+ findings?: ScanFinding[];
30
+ reason?: string;
31
+ cloneDir: string | null;
32
+ fetchedDir: string | null;
33
+ origin: SkillOrigin;
34
+ }
35
+
36
+ async function resolveCandidateOrigins(
37
+ vaultPath: string,
38
+ skillId: string | undefined,
39
+ allowLocalSource: boolean,
40
+ ): Promise<{ skillId: string; origin: SkillOrigin }[]> {
41
+ if (skillId) {
42
+ // skillId (the CLI's positional <skill-id>) is joined straight into vaultPath
43
+ // below, and that join ultimately reaches installIntoVault's rmSync(recursive)
44
+ // + cpSync on the write path — a "../"-shaped value escapes the vault and lets
45
+ // `skillmux update` delete and overwrite an arbitrary directory on disk. Batch
46
+ // mode never hits this because checkOutdated only enumerates real vault entries
47
+ // (already SKILL_ID_PATTERN-filtered); the explicit single-skill path is the
48
+ // only one that takes this string directly from argv, so validate it here.
49
+ if (!SKILL_ID_PATTERN.test(skillId)) {
50
+ throw new Error(`invalid skill id "${skillId}": expected lowercase letters, digits, and hyphens only`);
51
+ }
52
+ let origin: SkillOrigin | null;
53
+ try {
54
+ origin = readSkillOrigin(join(vaultPath, skillId));
55
+ } catch (error) {
56
+ throw new Error(
57
+ `"${skillId}" has a corrupt .skillmux-origin sidecar: ${error instanceof Error ? error.message : String(error)}`,
58
+ );
59
+ }
60
+ if (!origin) {
61
+ throw new Error(`"${skillId}" has no origin recorded — was this skill installed via "skillmux install"?`);
62
+ }
63
+ if (!allowLocalSource && isLocalFileUrl(origin.source_url)) {
64
+ throw new Error(`"${skillId}" has a local (file://) source — pass --allow-local-source to update it`);
65
+ }
66
+ return [{ skillId, origin }];
67
+ }
68
+ const outdated = await checkOutdated(vaultPath, { allowLocalSource });
69
+ return outdated
70
+ .filter((result) => result.status === "outdated")
71
+ .map((result) => ({ skillId: result.skill_id, origin: readSkillOrigin(join(vaultPath, result.skill_id))! }));
72
+ }
73
+
74
+ async function buildPlan(
75
+ vaultPath: string,
76
+ candidates: { skillId: string; origin: SkillOrigin }[],
77
+ failOn: ScanSeverity | undefined,
78
+ force: boolean,
79
+ ): Promise<UpdatePlanItem[]> {
80
+ const plan: UpdatePlanItem[] = [];
81
+ for (const { skillId, origin } of candidates) {
82
+ const skillDir = join(vaultPath, skillId);
83
+ let currentHash: string;
84
+ try {
85
+ currentHash = hashSkillContent(skillDir);
86
+ } catch (error) {
87
+ // A skill whose on-disk content can't be safely read (e.g. SKILL.md was
88
+ // swapped for a symlink after install — same threat model as AC7's drift
89
+ // check) is this skill's problem alone, matching checkOutdated's per-skill
90
+ // isolation: never let it abort the batch for every other candidate.
91
+ plan.push({
92
+ skillId,
93
+ oldCommit: origin.commit,
94
+ newCommit: origin.commit,
95
+ contentChanged: false,
96
+ kind: "skip_read_error",
97
+ reason: error instanceof Error ? error.message : String(error),
98
+ cloneDir: null,
99
+ fetchedDir: null,
100
+ origin,
101
+ });
102
+ continue;
103
+ }
104
+
105
+ // Drift is a purely local comparison — check it before fetching anything,
106
+ // so a drifted (and therefore skipped) skill never pays for a clone.
107
+ if (currentHash !== origin.content_hash && !force) {
108
+ plan.push({
109
+ skillId,
110
+ oldCommit: origin.commit,
111
+ newCommit: await remoteHeadCommit(origin.source_url),
112
+ contentChanged: false,
113
+ kind: "skip_drift",
114
+ cloneDir: null,
115
+ fetchedDir: null,
116
+ origin,
117
+ });
118
+ continue;
119
+ }
120
+
121
+ const cloneDir = await cloneToTemp(origin.source_url);
122
+ const resolved = resolveSkillDir(cloneDir, skillId, origin.skill_path);
123
+ const base = {
124
+ skillId,
125
+ oldCommit: origin.commit,
126
+ newCommit: resolveCloneCommit(cloneDir),
127
+ cloneDir,
128
+ fetchedDir: resolved.dir,
129
+ origin,
130
+ };
131
+
132
+ const { findings } = await validateSkillCandidate(skillId, resolved.dir);
133
+ if (scanExitCode(findings, failOn) !== 0) {
134
+ plan.push({ ...base, contentChanged: false, kind: "skip_scan_failed", findings });
135
+ continue;
136
+ }
137
+
138
+ const contentChanged = hashSkillContent(resolved.dir) !== currentHash;
139
+ plan.push({
140
+ ...base,
141
+ contentChanged,
142
+ kind: base.newCommit === origin.commit && !contentChanged ? "up_to_date" : "update",
143
+ });
144
+ }
145
+ return plan;
146
+ }
147
+
148
+ export function buildConfirmPrompt(toWrite: Pick<UpdatePlanItem, "skillId" | "origin">[]): string {
149
+ const lines = toWrite.map((item) => ` ${item.skillId} <- ${item.origin.source_url}`);
150
+ return `update:\n${lines.join("\n")}\n?`;
151
+ }
152
+
153
+ function statusFor(kind: UpdateKind, dryRun: boolean): string {
154
+ switch (kind) {
155
+ case "update":
156
+ return dryRun ? "would_update" : "updated";
157
+ case "skip_drift":
158
+ return dryRun ? "would_skip_drift" : "skipped_drift";
159
+ case "skip_scan_failed":
160
+ return dryRun ? "would_skip_scan_failed" : "skipped_scan_failed";
161
+ case "skip_read_error":
162
+ return "skipped_read_error";
163
+ default:
164
+ return "up_to_date";
165
+ }
166
+ }
167
+
168
+ function parseUpdateArgs(args: string[]): {
169
+ skillId?: string;
170
+ yes: boolean;
171
+ dryRun: boolean;
172
+ force: boolean;
173
+ failOn?: ScanSeverity;
174
+ allowLocalSource: boolean;
175
+ } {
176
+ let skillId: string | undefined;
177
+ let yes = false;
178
+ let dryRun = false;
179
+ let force = false;
180
+ let failOn: ScanSeverity | undefined;
181
+ let allowLocalSource = false;
182
+ for (let i = 0; i < args.length; i++) {
183
+ const arg = args[i];
184
+ if (arg === "--yes") yes = true;
185
+ else if (arg === "--dry-run") dryRun = true;
186
+ else if (arg === "--force") force = true;
187
+ else if (arg === "--allow-local-source") allowLocalSource = true;
188
+ else if (arg === "--fail-on") {
189
+ const value = args[++i];
190
+ if (value !== "low" && value !== "medium" && value !== "high") {
191
+ throw new Error("--fail-on must be low, medium, or high");
192
+ }
193
+ failOn = value;
194
+ } else if (arg === "--json") {
195
+ // handled globally
196
+ } else if (arg?.startsWith("--")) {
197
+ throw new Error(`unknown update option: ${arg}`);
198
+ } else if (skillId !== undefined) {
199
+ throw new Error("skillmux update accepts at most one <skill-id> argument");
200
+ } else {
201
+ skillId = arg;
202
+ }
203
+ }
204
+ return { skillId, yes, dryRun, force, failOn, allowLocalSource };
205
+ }
206
+
207
+ export async function runUpdate(args: string[], options: { isJson: boolean }): Promise<void> {
208
+ const { skillId, yes, dryRun, force, failOn, allowLocalSource } = parseUpdateArgs(args);
209
+ const vaultPath = expandHome((await loadConfig()).vault_path);
210
+
211
+ const candidates = await resolveCandidateOrigins(vaultPath, skillId, allowLocalSource);
212
+ const plan = await buildPlan(vaultPath, candidates, failOn, force);
213
+ try {
214
+ const toWrite = plan.filter((item) => item.kind === "update");
215
+
216
+ if (!dryRun && toWrite.length > 0) {
217
+ const proceed = await confirmIfNeeded({
218
+ confirmed: yes,
219
+ isJson: options.isJson,
220
+ prompt: buildConfirmPrompt(toWrite),
221
+ nonInteractiveError: "skillmux update requires --yes when run non-interactively",
222
+ });
223
+ if (!proceed) return;
224
+
225
+ for (const item of toWrite) {
226
+ // toWrite is filtered to kind === "update", which is only ever set after
227
+ // a successful clone above, so fetchedDir is always populated here.
228
+ const targetDir = installIntoVault(vaultPath, item.skillId, item.fetchedDir as string, true);
229
+ writeSkillOrigin(targetDir, {
230
+ source_url: item.origin.source_url,
231
+ skill_path: item.origin.skill_path,
232
+ commit: item.newCommit,
233
+ installed_at: new Date().toISOString(),
234
+ content_hash: hashSkillContent(targetDir),
235
+ });
236
+ }
237
+ }
238
+
239
+ const skills = plan.map((item) => ({
240
+ skill_id: item.skillId,
241
+ source_url: item.origin.source_url,
242
+ old_commit: item.oldCommit,
243
+ new_commit: item.newCommit,
244
+ content_changed: item.contentChanged,
245
+ status: statusFor(item.kind, dryRun),
246
+ ...(item.findings ? { findings: item.findings } : {}),
247
+ ...(item.reason ? { reason: item.reason } : {}),
248
+ }));
249
+
250
+ emitSuccess({ isJson: options.isJson }, { dry_run: dryRun, skills }, () => {
251
+ if (skills.length === 0) {
252
+ console.log("update: nothing to do");
253
+ return;
254
+ }
255
+ for (const s of skills) {
256
+ console.log(`[${s.status}] ${s.skill_id}`);
257
+ }
258
+ });
259
+ } finally {
260
+ for (const item of plan) {
261
+ if (item.cloneDir) rmSync(item.cloneDir, { recursive: true, force: true });
262
+ }
263
+ }
264
+ }
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,8 +12,26 @@ 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 {
16
- return GIT_URL_PREFIXES.some((prefix) => repo.startsWith(prefix)) || SCP_LIKE_URL_PATTERN.test(repo);
15
+ export function isGitUrl(repo: string): boolean {
16
+ if (GIT_URL_PREFIXES.some((prefix) => repo.startsWith(prefix))) return true;
17
+ // scp-like syntax (user@host:path) has no URL scheme, so whatever accepts it here
18
+ // hands the raw string to `git clone`/`git ls-remote` as a bare positional argument.
19
+ // If that string starts with `-`, git's own option parser reads it as a flag, not a
20
+ // repository — verified against git 2.55: `--upload-pack=<cmd>@host:path` makes git
21
+ // run `<cmd>` as a real local shell command instead of contacting a remote. Reject
22
+ // it outright rather than let a crafted string reach that argv slot.
23
+ return SCP_LIKE_URL_PATTERN.test(repo) && !repo.startsWith("-");
24
+ }
25
+
26
+ /** A `file://` source_url reaches the local filesystem directly, not just a network
27
+ * remote. That's fine when the user typed it themselves at `skillmux install` time,
28
+ * but a `.skillmux-origin` sidecar is vault content — readable and writable by
29
+ * whatever populated the vault (a shared git-backed vault pulled in, or a hand-edit),
30
+ * same threat model as every other vault-content read this codebase guards. `skillmux
31
+ * outdated`/`update` must not blindly git-clone/ls-remote whatever local path a
32
+ * forged sidecar names. */
33
+ export function isLocalFileUrl(url: string): boolean {
34
+ return url.startsWith("file://");
17
35
  }
18
36
 
19
37
  export function resolveRepoSource(repo: string): RepoSource {
@@ -49,11 +67,70 @@ export async function cloneToTemp(url: string): Promise<string> {
49
67
  return dir;
50
68
  }
51
69
 
70
+ export function resolveCloneCommit(cloneDir: string): string {
71
+ const proc = Bun.spawnSync(["git", "-C", cloneDir, "rev-parse", "HEAD"], { stdout: "pipe", stderr: "pipe" });
72
+ if (proc.exitCode !== 0) {
73
+ throw new Error(`git rev-parse HEAD failed in ${cloneDir}: ${proc.stderr.toString().trim()}`);
74
+ }
75
+ return proc.stdout.toString().trim();
76
+ }
77
+
78
+ export async function remoteHeadCommit(url: string, ref = "HEAD"): Promise<string> {
79
+ const proc = Bun.spawn(["git", "ls-remote", url, ref], { stdout: "pipe", stderr: "pipe" });
80
+ const exitCode = await proc.exited;
81
+ const stdout = await new Response(proc.stdout).text();
82
+ if (exitCode !== 0) {
83
+ const stderr = await new Response(proc.stderr).text();
84
+ throw new Error(`git ls-remote failed for ${url}: ${stderr.trim()}`);
85
+ }
86
+ const line = stdout.split("\n").find((l) => l.trim().length > 0);
87
+ if (!line) throw new Error(`git ls-remote returned no ref "${ref}" for ${url}`);
88
+ const sha = line.split("\t")[0]?.trim();
89
+ if (!sha || !/^[0-9a-f]{40}$/.test(sha)) {
90
+ throw new Error(`git ls-remote returned an unparseable SHA for ${url}: ${line}`);
91
+ }
92
+ return sha;
93
+ }
94
+
95
+ /** Recursively finds symlinks under `dir` (skipping `.git`), without following them.
96
+ * A skill's content must be regular files only — a symlink here is how a malicious
97
+ * skill smuggles an escape out of the vault once `skillmux sync` exposes it inside
98
+ * an agent's native skill directory. `dir` itself is checked too: a `skill_path`
99
+ * can point straight at a directory that git committed *as a symlink* (git supports
100
+ * storing symlink blobs) — walking its descendants alone would silently resolve
101
+ * through it and report the target's real files as clean. */
102
+ export function findSymlinks(dir: string): string[] {
103
+ const found: string[] = [];
104
+ if (lstatSync(dir).isSymbolicLink()) {
105
+ return ["(the skill directory itself is a symlink)"];
106
+ }
107
+ const walk = (current: string) => {
108
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
109
+ if (entry.name === ".git") continue;
110
+ const abs = join(current, entry.name);
111
+ if (entry.isSymbolicLink()) {
112
+ found.push(relative(dir, abs));
113
+ } else if (entry.isDirectory()) {
114
+ walk(abs);
115
+ }
116
+ }
117
+ };
118
+ walk(dir);
119
+ return found.sort();
120
+ }
121
+
52
122
  export interface ValidationResult {
53
123
  findings: ScanFinding[];
54
124
  }
55
125
 
56
126
  export async function validateSkillCandidate(skillId: string, dir: string): Promise<ValidationResult> {
127
+ const symlinks = findSymlinks(dir);
128
+ if (symlinks.length > 0) {
129
+ throw new Error(
130
+ `"${skillId}" contains symlink(s), which are not allowed in skill content: ${symlinks.join(", ")}`,
131
+ );
132
+ }
133
+
57
134
  const bytes = await Bun.file(join(dir, "SKILL.md")).bytes();
58
135
  const body = decodeUtf8Strict(bytes);
59
136
  parseSkillMd(skillId, body);
@@ -78,6 +155,12 @@ export async function validateSkillCandidate(skillId: string, dir: string): Prom
78
155
  }
79
156
 
80
157
  export function installIntoVault(vaultPath: string, skillId: string, sourceDir: string, force = false): string {
158
+ const symlinks = findSymlinks(sourceDir);
159
+ if (symlinks.length > 0) {
160
+ throw new Error(
161
+ `refusing to install "${skillId}": source contains symlink(s), which are not allowed in skill content: ${symlinks.join(", ")}`,
162
+ );
163
+ }
81
164
  const targetDir = join(vaultPath, skillId);
82
165
  if (existsSync(targetDir)) {
83
166
  if (!force) {
@@ -94,11 +177,32 @@ export interface ResolvedSkillDir {
94
177
  dir: string;
95
178
  }
96
179
 
180
+ /** The returned skillId is joined straight into vaultPath by installIntoVault's callers
181
+ * and fed to a real rmSync(recursive)+cpSync overwrite. Both branches below can produce
182
+ * "." or ".." for a crafted-but-plausible input: `skill_path` of "." (e.g. `skillmux
183
+ * install owner/repo/.`) survives the ".." segment check since "." isn't "..", and its
184
+ * basename is "." too; `fallbackName` comes from deriveRepoName(url), which can return
185
+ * ".." for a url whose last "/"- or ":"-delimited segment is literally "..". Verified
186
+ * end-to-end against the real CLI binary: the former makes `install --force` wipe the
187
+ * entire vault, the latter makes it wipe the vault's parent directory. Neither can ever
188
+ * legitimately be a skill id, so reject both outright rather than let them reach a join. */
189
+ function rejectTraversalSkillId(skillId: string): void {
190
+ if (skillId === "." || skillId === "..") {
191
+ throw new Error(`invalid skill id "${skillId}"`);
192
+ }
193
+ }
194
+
97
195
  export function resolveSkillDir(cloneDir: string, fallbackName: string, skillPath?: string): ResolvedSkillDir {
98
196
  if (skillPath) {
99
- return { skillId: basename(skillPath), dir: join(cloneDir, skillPath) };
197
+ if (skillPath.startsWith("/") || skillPath.split("/").includes("..")) {
198
+ throw new Error(`invalid skill_path "${skillPath}": must be a relative path within the repo`);
199
+ }
200
+ const skillId = basename(skillPath);
201
+ rejectTraversalSkillId(skillId);
202
+ return { skillId, dir: join(cloneDir, skillPath) };
100
203
  }
101
204
  if (existsSync(join(cloneDir, "SKILL.md"))) {
205
+ rejectTraversalSkillId(fallbackName);
102
206
  return { skillId: fallbackName, dir: cloneDir };
103
207
  }
104
208
  const discovered = readdirSync(cloneDir, { withFileTypes: true })
@@ -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
  };