@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 +10 -0
- package/package.json +1 -1
- package/src/cli.ts +62 -6
- package/src/commands/project.ts +6 -1
- package/src/commands/update.ts +11 -0
- package/src/install.ts +27 -2
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
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 =
|
|
855
|
-
|
|
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
|
-
|
|
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[]): {
|
package/src/commands/project.ts
CHANGED
|
@@ -397,7 +397,12 @@ export async function runProject(
|
|
|
397
397
|
writeManifestAtomic(manifestPath, updated);
|
|
398
398
|
if (request.sync) {
|
|
399
399
|
try {
|
|
400
|
-
|
|
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": ${
|
package/src/commands/update.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 })
|