@klhapp/skillmux 1.9.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,16 @@ 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
+
8
18
  ## [1.9.0](https://github.com/klhq/skillmux/compare/v1.8.0...v1.9.0) (2026-08-30)
9
19
 
10
20
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@klhapp/skillmux",
3
- "version": "1.9.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
@@ -82,10 +82,12 @@ import {
82
82
  import { getStats, renderStatsText, type StatsResponse } from "./stats";
83
83
  import {
84
84
  installPostMergeHook,
85
+ resolveProjectPinDir,
85
86
  restoreMonolith as restoreMonolithTarget,
86
87
  syncProjectTargets,
87
88
  syncTarget,
88
89
  writeLocalVaultMarker,
90
+ type ProjectGroupInput,
89
91
  } from "./sync";
90
92
  import { scanVault, vaultResolutionOrder } from "./vault";
91
93
 
@@ -771,21 +773,45 @@ function parseSyncArgs(args: string[]): {
771
773
  dryRun: boolean;
772
774
  restoreMonolith: boolean;
773
775
  installHook: boolean;
776
+ yes: boolean;
774
777
  } {
775
778
  let dryRun = false;
776
779
  let restoreMonolith = false;
777
780
  let installHook = false;
781
+ let yes = false;
778
782
  for (const arg of args) {
779
783
  if (arg === "--dry-run") dryRun = true;
780
784
  else if (arg === "--restore-monolith") restoreMonolith = true;
781
785
  else if (arg === "--install-hook") installHook = true;
786
+ else if (arg === "--yes") yes = true;
782
787
  else throw new Error(`unknown sync option: ${arg}`);
783
788
  }
784
- 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}?`);
785
811
  }
786
812
 
787
813
  async function runSync(args: string[]): Promise<void> {
788
- const { dryRun, restoreMonolith, installHook } = parseSyncArgs(args);
814
+ const { dryRun, restoreMonolith, installHook, yes } = parseSyncArgs(args);
789
815
  const config = await loadConfig();
790
816
  const vaultPath = expandHome(config.vault_path);
791
817
 
@@ -829,6 +855,16 @@ async function runSync(args: string[]): Promise<void> {
829
855
  continue;
830
856
  }
831
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
+
832
868
  const suffix = dryRun ? " (dry-run)" : "";
833
869
  const result = syncTarget(
834
870
  {
@@ -851,9 +887,24 @@ async function runSync(args: string[]): Promise<void> {
851
887
 
852
888
  if (target.project_groups.length > 0) {
853
889
  const allGroups = manifest.project ?? {};
854
- const projectGroups = Object.fromEntries(
855
- target.project_groups.map((name) => [name, allGroups[name]!]),
856
- );
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
+ }
857
908
  const projectResults = syncProjectTargets(
858
909
  { vaultPath, targetDir, targetName, projectGroups, localVaultPaths },
859
910
  { dryRun },
@@ -1437,7 +1488,12 @@ async function runInit(
1437
1488
  ) {
1438
1489
  console.log(`\n${printLastMile()}`);
1439
1490
  }
1440
- 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"]);
1441
1497
  }
1442
1498
 
1443
1499
  function parseReportArgs(args: string[]): {
@@ -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": ${
@@ -14,6 +14,7 @@ import { emitSuccess } from "../output";
14
14
  import { hashSkillContent, readSkillOrigin, writeSkillOrigin } from "../provenance";
15
15
  import type { SkillOrigin } from "../provenance";
16
16
  import { type ScanFinding, type ScanSeverity, scanExitCode } from "../scan";
17
+ import { SKILL_ID_PATTERN } from "../vault";
17
18
  import { confirmIfNeeded } from "./shared";
18
19
  import { checkOutdated } from "./outdated";
19
20
 
@@ -38,6 +39,16 @@ async function resolveCandidateOrigins(
38
39
  allowLocalSource: boolean,
39
40
  ): Promise<{ skillId: string; origin: SkillOrigin }[]> {
40
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
+ }
41
52
  let origin: SkillOrigin | null;
42
53
  try {
43
54
  origin = readSkillOrigin(join(vaultPath, skillId));
package/src/install.ts CHANGED
@@ -13,7 +13,14 @@ const GIT_URL_PREFIXES = ["http://", "https://", "git://", "ssh://", "file://"];
13
13
  const SCP_LIKE_URL_PATTERN = /^[^/\s]+@[^/\s]+:/;
14
14
 
15
15
  export function isGitUrl(repo: string): boolean {
16
- return GIT_URL_PREFIXES.some((prefix) => repo.startsWith(prefix)) || SCP_LIKE_URL_PATTERN.test(repo);
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("-");
17
24
  }
18
25
 
19
26
  /** A `file://` source_url reaches the local filesystem directly, not just a network
@@ -170,14 +177,32 @@ export interface ResolvedSkillDir {
170
177
  dir: string;
171
178
  }
172
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
+
173
195
  export function resolveSkillDir(cloneDir: string, fallbackName: string, skillPath?: string): ResolvedSkillDir {
174
196
  if (skillPath) {
175
197
  if (skillPath.startsWith("/") || skillPath.split("/").includes("..")) {
176
198
  throw new Error(`invalid skill_path "${skillPath}": must be a relative path within the repo`);
177
199
  }
178
- return { skillId: basename(skillPath), dir: join(cloneDir, skillPath) };
200
+ const skillId = basename(skillPath);
201
+ rejectTraversalSkillId(skillId);
202
+ return { skillId, dir: join(cloneDir, skillPath) };
179
203
  }
180
204
  if (existsSync(join(cloneDir, "SKILL.md"))) {
205
+ rejectTraversalSkillId(fallbackName);
181
206
  return { skillId: fallbackName, dir: cloneDir };
182
207
  }
183
208
  const discovered = readdirSync(cloneDir, { withFileTypes: true })