@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 +38 -0
- package/README.md +1 -0
- package/docs/skill-management.md +32 -0
- package/package.json +1 -1
- package/src/cli.ts +99 -9
- package/src/commands/outdated.ts +112 -0
- package/src/commands/project.ts +6 -1
- package/src/commands/update.ts +264 -0
- package/src/install.ts +109 -5
- package/src/provenance.ts +99 -0
- package/src/router-core.ts +14 -2
- package/src/scan.ts +7 -1
- package/src/sync.ts +38 -8
- package/src/vault.ts +44 -4
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
package/docs/skill-management.md
CHANGED
|
@@ -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
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,
|
|
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 =
|
|
834
|
-
|
|
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
|
-
|
|
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
|
+
}
|
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": ${
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/router-core.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
141
|
+
for (const skillId of syncable) {
|
|
119
142
|
symlinkSync(join(skillSource(skillId), skillId), join(targetDir, skillId));
|
|
120
143
|
}
|
|
121
|
-
writeTargetMarker(targetDir, targetName, vaultPath,
|
|
122
|
-
return { added:
|
|
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
|
|
171
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
};
|