@klhapp/skillmux 1.8.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.md +1 -0
- package/docs/skill-management.md +32 -0
- package/package.json +1 -1
- package/src/cli.ts +37 -3
- package/src/commands/outdated.ts +112 -0
- package/src/commands/update.ts +253 -0
- package/src/install.ts +82 -3
- 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,34 @@ 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.0](https://github.com/klhq/skillmux/compare/v1.8.0...v1.9.0) (2026-08-30)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
* track skill provenance and add outdated/update commands ([#133](https://github.com/klhq/skillmux/issues/133)) ([4d4f8c9](https://github.com/klhq/skillmux/commit/4d4f8c94a7e34e1ad030f9904995d02fa5fa2712))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
* **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))
|
|
19
|
+
* **install:** prevent symlink smuggling in skill content ([#136](https://github.com/klhq/skillmux/issues/136)) ([2df318c](https://github.com/klhq/skillmux/commit/2df318cfdc974b0f7b9042c3d68fa4fece9109d9))
|
|
20
|
+
* **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))
|
|
21
|
+
* **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))
|
|
22
|
+
* **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))
|
|
23
|
+
* **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))
|
|
24
|
+
* **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))
|
|
25
|
+
* **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))
|
|
26
|
+
* **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))
|
|
27
|
+
* **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))
|
|
28
|
+
* **vault:** refuse to read a symlinked SKILL.md ([#140](https://github.com/klhq/skillmux/issues/140)) ([3220fe3](https://github.com/klhq/skillmux/commit/3220fe393a81922f76729284d156644662f177f2))
|
|
29
|
+
* **vault:** secure listSupportingFiles against symlinks and traversal ([#138](https://github.com/klhq/skillmux/issues/138)) ([6343a30](https://github.com/klhq/skillmux/commit/6343a30747844c5f83b810cbfe3d19ed569ca7cd))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
* **update:** skip cloning drifted skills before fetching ([1b247b6](https://github.com/klhq/skillmux/commit/1b247b6379b021692c839a77d44a81bd736f2bea))
|
|
35
|
+
|
|
8
36
|
## [1.8.0](https://github.com/klhq/skillmux/compare/v1.7.1...v1.8.0) (2026-08-28)
|
|
9
37
|
|
|
10
38
|
|
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,
|
|
@@ -129,6 +133,8 @@ const KNOWN_COMMANDS = [
|
|
|
129
133
|
"audit",
|
|
130
134
|
"scan",
|
|
131
135
|
"install",
|
|
136
|
+
"outdated",
|
|
137
|
+
"update",
|
|
132
138
|
"eval",
|
|
133
139
|
"doctor",
|
|
134
140
|
"models",
|
|
@@ -142,6 +148,8 @@ function isDockerHostManagementCommand(command: string, subCommand: string): boo
|
|
|
142
148
|
"init",
|
|
143
149
|
"sync",
|
|
144
150
|
"install",
|
|
151
|
+
"outdated",
|
|
152
|
+
"update",
|
|
145
153
|
"project",
|
|
146
154
|
"target",
|
|
147
155
|
"core",
|
|
@@ -337,6 +345,12 @@ async function main() {
|
|
|
337
345
|
case "install":
|
|
338
346
|
await runInstall(rawArgv.slice(1), { isJson });
|
|
339
347
|
break;
|
|
348
|
+
case "outdated":
|
|
349
|
+
await runOutdated(rawArgv.slice(1), { isJson });
|
|
350
|
+
break;
|
|
351
|
+
case "update":
|
|
352
|
+
await runUpdate(rawArgv.slice(1), { isJson });
|
|
353
|
+
break;
|
|
340
354
|
case "eval":
|
|
341
355
|
if (subCommand === "promote") {
|
|
342
356
|
await runEvalPromote(commandArgs, { isJson, dryRun: isDryRun });
|
|
@@ -374,7 +388,7 @@ async function main() {
|
|
|
374
388
|
const suggestion = suggestCorrection(command, KNOWN_COMMANDS);
|
|
375
389
|
const msg = suggestion
|
|
376
390
|
? `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>`;
|
|
391
|
+
: `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
392
|
throw new Error(msg);
|
|
379
393
|
}
|
|
380
394
|
}
|
|
@@ -559,10 +573,12 @@ Operations:
|
|
|
559
573
|
skillmux report [--server <url> | --db <path>] --since <window> [--json]
|
|
560
574
|
skillmux audit prune [--older-than <window>] [--dry-run] [--yes] [--json]
|
|
561
575
|
skillmux eval promote --since <window> [--target <path>] [--dry-run] [--yes] [--json]
|
|
576
|
+
skillmux outdated [--allow-local-source] [--json]
|
|
577
|
+
skillmux update [skill-id] [--yes] [--dry-run] [--force] [--allow-local-source] [--fail-on low|medium|high] [--json]
|
|
562
578
|
|
|
563
579
|
Commands:
|
|
564
|
-
serve, index, sync, init, project, target, core, report, audit, scan, install,
|
|
565
|
-
skill, local-vault, config, models, context, completions`);
|
|
580
|
+
serve, index, sync, init, project, target, core, report, audit, scan, install, outdated, update,
|
|
581
|
+
eval, doctor, skill, local-vault, config, models, context, completions`);
|
|
566
582
|
}
|
|
567
583
|
|
|
568
584
|
// ---------------------------------------------------------------------------
|
|
@@ -827,6 +843,11 @@ async function runSync(args: string[]): Promise<void> {
|
|
|
827
843
|
console.log(
|
|
828
844
|
`${targetName}: +${result.added.length} -${result.removed.length}${suffix}`,
|
|
829
845
|
);
|
|
846
|
+
if (result.skipped.length > 0) {
|
|
847
|
+
console.log(
|
|
848
|
+
` warning: refused to sync ${result.skipped.join(", ")} — skill directory contains a symlink`,
|
|
849
|
+
);
|
|
850
|
+
}
|
|
830
851
|
|
|
831
852
|
if (target.project_groups.length > 0) {
|
|
832
853
|
const allGroups = manifest.project ?? {};
|
|
@@ -841,6 +862,11 @@ async function runSync(args: string[]): Promise<void> {
|
|
|
841
862
|
console.log(
|
|
842
863
|
` ${projectResult.group} -> ${projectResult.pinDir}: +${projectResult.added.length} -${projectResult.removed.length}${suffix}`,
|
|
843
864
|
);
|
|
865
|
+
if (projectResult.skipped.length > 0) {
|
|
866
|
+
console.log(
|
|
867
|
+
` warning: refused to sync ${projectResult.skipped.join(", ")} — skill directory contains a symlink`,
|
|
868
|
+
);
|
|
869
|
+
}
|
|
844
870
|
}
|
|
845
871
|
}
|
|
846
872
|
}
|
|
@@ -1612,12 +1638,20 @@ async function runInstall(
|
|
|
1612
1638
|
return;
|
|
1613
1639
|
}
|
|
1614
1640
|
|
|
1641
|
+
const commit = resolveCloneCommit(cloneDir);
|
|
1615
1642
|
const targetDir = installIntoVault(
|
|
1616
1643
|
vaultPath,
|
|
1617
1644
|
resolved.skillId,
|
|
1618
1645
|
resolved.dir,
|
|
1619
1646
|
force,
|
|
1620
1647
|
);
|
|
1648
|
+
writeSkillOrigin(targetDir, {
|
|
1649
|
+
source_url: source.url,
|
|
1650
|
+
skill_path: source.skillPath,
|
|
1651
|
+
commit,
|
|
1652
|
+
installed_at: new Date().toISOString(),
|
|
1653
|
+
content_hash: hashSkillContent(targetDir),
|
|
1654
|
+
});
|
|
1621
1655
|
emitSuccess(
|
|
1622
1656
|
{ isJson: options.isJson },
|
|
1623
1657
|
{ 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
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
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 { confirmIfNeeded } from "./shared";
|
|
18
|
+
import { checkOutdated } from "./outdated";
|
|
19
|
+
|
|
20
|
+
type UpdateKind = "update" | "up_to_date" | "skip_drift" | "skip_scan_failed" | "skip_read_error";
|
|
21
|
+
|
|
22
|
+
interface UpdatePlanItem {
|
|
23
|
+
skillId: string;
|
|
24
|
+
oldCommit: string;
|
|
25
|
+
newCommit: string;
|
|
26
|
+
contentChanged: boolean;
|
|
27
|
+
kind: UpdateKind;
|
|
28
|
+
findings?: ScanFinding[];
|
|
29
|
+
reason?: string;
|
|
30
|
+
cloneDir: string | null;
|
|
31
|
+
fetchedDir: string | null;
|
|
32
|
+
origin: SkillOrigin;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function resolveCandidateOrigins(
|
|
36
|
+
vaultPath: string,
|
|
37
|
+
skillId: string | undefined,
|
|
38
|
+
allowLocalSource: boolean,
|
|
39
|
+
): Promise<{ skillId: string; origin: SkillOrigin }[]> {
|
|
40
|
+
if (skillId) {
|
|
41
|
+
let origin: SkillOrigin | null;
|
|
42
|
+
try {
|
|
43
|
+
origin = readSkillOrigin(join(vaultPath, skillId));
|
|
44
|
+
} catch (error) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`"${skillId}" has a corrupt .skillmux-origin sidecar: ${error instanceof Error ? error.message : String(error)}`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
if (!origin) {
|
|
50
|
+
throw new Error(`"${skillId}" has no origin recorded — was this skill installed via "skillmux install"?`);
|
|
51
|
+
}
|
|
52
|
+
if (!allowLocalSource && isLocalFileUrl(origin.source_url)) {
|
|
53
|
+
throw new Error(`"${skillId}" has a local (file://) source — pass --allow-local-source to update it`);
|
|
54
|
+
}
|
|
55
|
+
return [{ skillId, origin }];
|
|
56
|
+
}
|
|
57
|
+
const outdated = await checkOutdated(vaultPath, { allowLocalSource });
|
|
58
|
+
return outdated
|
|
59
|
+
.filter((result) => result.status === "outdated")
|
|
60
|
+
.map((result) => ({ skillId: result.skill_id, origin: readSkillOrigin(join(vaultPath, result.skill_id))! }));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function buildPlan(
|
|
64
|
+
vaultPath: string,
|
|
65
|
+
candidates: { skillId: string; origin: SkillOrigin }[],
|
|
66
|
+
failOn: ScanSeverity | undefined,
|
|
67
|
+
force: boolean,
|
|
68
|
+
): Promise<UpdatePlanItem[]> {
|
|
69
|
+
const plan: UpdatePlanItem[] = [];
|
|
70
|
+
for (const { skillId, origin } of candidates) {
|
|
71
|
+
const skillDir = join(vaultPath, skillId);
|
|
72
|
+
let currentHash: string;
|
|
73
|
+
try {
|
|
74
|
+
currentHash = hashSkillContent(skillDir);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
// A skill whose on-disk content can't be safely read (e.g. SKILL.md was
|
|
77
|
+
// swapped for a symlink after install — same threat model as AC7's drift
|
|
78
|
+
// check) is this skill's problem alone, matching checkOutdated's per-skill
|
|
79
|
+
// isolation: never let it abort the batch for every other candidate.
|
|
80
|
+
plan.push({
|
|
81
|
+
skillId,
|
|
82
|
+
oldCommit: origin.commit,
|
|
83
|
+
newCommit: origin.commit,
|
|
84
|
+
contentChanged: false,
|
|
85
|
+
kind: "skip_read_error",
|
|
86
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
87
|
+
cloneDir: null,
|
|
88
|
+
fetchedDir: null,
|
|
89
|
+
origin,
|
|
90
|
+
});
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Drift is a purely local comparison — check it before fetching anything,
|
|
95
|
+
// so a drifted (and therefore skipped) skill never pays for a clone.
|
|
96
|
+
if (currentHash !== origin.content_hash && !force) {
|
|
97
|
+
plan.push({
|
|
98
|
+
skillId,
|
|
99
|
+
oldCommit: origin.commit,
|
|
100
|
+
newCommit: await remoteHeadCommit(origin.source_url),
|
|
101
|
+
contentChanged: false,
|
|
102
|
+
kind: "skip_drift",
|
|
103
|
+
cloneDir: null,
|
|
104
|
+
fetchedDir: null,
|
|
105
|
+
origin,
|
|
106
|
+
});
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const cloneDir = await cloneToTemp(origin.source_url);
|
|
111
|
+
const resolved = resolveSkillDir(cloneDir, skillId, origin.skill_path);
|
|
112
|
+
const base = {
|
|
113
|
+
skillId,
|
|
114
|
+
oldCommit: origin.commit,
|
|
115
|
+
newCommit: resolveCloneCommit(cloneDir),
|
|
116
|
+
cloneDir,
|
|
117
|
+
fetchedDir: resolved.dir,
|
|
118
|
+
origin,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const { findings } = await validateSkillCandidate(skillId, resolved.dir);
|
|
122
|
+
if (scanExitCode(findings, failOn) !== 0) {
|
|
123
|
+
plan.push({ ...base, contentChanged: false, kind: "skip_scan_failed", findings });
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const contentChanged = hashSkillContent(resolved.dir) !== currentHash;
|
|
128
|
+
plan.push({
|
|
129
|
+
...base,
|
|
130
|
+
contentChanged,
|
|
131
|
+
kind: base.newCommit === origin.commit && !contentChanged ? "up_to_date" : "update",
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return plan;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function buildConfirmPrompt(toWrite: Pick<UpdatePlanItem, "skillId" | "origin">[]): string {
|
|
138
|
+
const lines = toWrite.map((item) => ` ${item.skillId} <- ${item.origin.source_url}`);
|
|
139
|
+
return `update:\n${lines.join("\n")}\n?`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function statusFor(kind: UpdateKind, dryRun: boolean): string {
|
|
143
|
+
switch (kind) {
|
|
144
|
+
case "update":
|
|
145
|
+
return dryRun ? "would_update" : "updated";
|
|
146
|
+
case "skip_drift":
|
|
147
|
+
return dryRun ? "would_skip_drift" : "skipped_drift";
|
|
148
|
+
case "skip_scan_failed":
|
|
149
|
+
return dryRun ? "would_skip_scan_failed" : "skipped_scan_failed";
|
|
150
|
+
case "skip_read_error":
|
|
151
|
+
return "skipped_read_error";
|
|
152
|
+
default:
|
|
153
|
+
return "up_to_date";
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function parseUpdateArgs(args: string[]): {
|
|
158
|
+
skillId?: string;
|
|
159
|
+
yes: boolean;
|
|
160
|
+
dryRun: boolean;
|
|
161
|
+
force: boolean;
|
|
162
|
+
failOn?: ScanSeverity;
|
|
163
|
+
allowLocalSource: boolean;
|
|
164
|
+
} {
|
|
165
|
+
let skillId: string | undefined;
|
|
166
|
+
let yes = false;
|
|
167
|
+
let dryRun = false;
|
|
168
|
+
let force = false;
|
|
169
|
+
let failOn: ScanSeverity | undefined;
|
|
170
|
+
let allowLocalSource = false;
|
|
171
|
+
for (let i = 0; i < args.length; i++) {
|
|
172
|
+
const arg = args[i];
|
|
173
|
+
if (arg === "--yes") yes = true;
|
|
174
|
+
else if (arg === "--dry-run") dryRun = true;
|
|
175
|
+
else if (arg === "--force") force = true;
|
|
176
|
+
else if (arg === "--allow-local-source") allowLocalSource = true;
|
|
177
|
+
else if (arg === "--fail-on") {
|
|
178
|
+
const value = args[++i];
|
|
179
|
+
if (value !== "low" && value !== "medium" && value !== "high") {
|
|
180
|
+
throw new Error("--fail-on must be low, medium, or high");
|
|
181
|
+
}
|
|
182
|
+
failOn = value;
|
|
183
|
+
} else if (arg === "--json") {
|
|
184
|
+
// handled globally
|
|
185
|
+
} else if (arg?.startsWith("--")) {
|
|
186
|
+
throw new Error(`unknown update option: ${arg}`);
|
|
187
|
+
} else if (skillId !== undefined) {
|
|
188
|
+
throw new Error("skillmux update accepts at most one <skill-id> argument");
|
|
189
|
+
} else {
|
|
190
|
+
skillId = arg;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return { skillId, yes, dryRun, force, failOn, allowLocalSource };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export async function runUpdate(args: string[], options: { isJson: boolean }): Promise<void> {
|
|
197
|
+
const { skillId, yes, dryRun, force, failOn, allowLocalSource } = parseUpdateArgs(args);
|
|
198
|
+
const vaultPath = expandHome((await loadConfig()).vault_path);
|
|
199
|
+
|
|
200
|
+
const candidates = await resolveCandidateOrigins(vaultPath, skillId, allowLocalSource);
|
|
201
|
+
const plan = await buildPlan(vaultPath, candidates, failOn, force);
|
|
202
|
+
try {
|
|
203
|
+
const toWrite = plan.filter((item) => item.kind === "update");
|
|
204
|
+
|
|
205
|
+
if (!dryRun && toWrite.length > 0) {
|
|
206
|
+
const proceed = await confirmIfNeeded({
|
|
207
|
+
confirmed: yes,
|
|
208
|
+
isJson: options.isJson,
|
|
209
|
+
prompt: buildConfirmPrompt(toWrite),
|
|
210
|
+
nonInteractiveError: "skillmux update requires --yes when run non-interactively",
|
|
211
|
+
});
|
|
212
|
+
if (!proceed) return;
|
|
213
|
+
|
|
214
|
+
for (const item of toWrite) {
|
|
215
|
+
// toWrite is filtered to kind === "update", which is only ever set after
|
|
216
|
+
// a successful clone above, so fetchedDir is always populated here.
|
|
217
|
+
const targetDir = installIntoVault(vaultPath, item.skillId, item.fetchedDir as string, true);
|
|
218
|
+
writeSkillOrigin(targetDir, {
|
|
219
|
+
source_url: item.origin.source_url,
|
|
220
|
+
skill_path: item.origin.skill_path,
|
|
221
|
+
commit: item.newCommit,
|
|
222
|
+
installed_at: new Date().toISOString(),
|
|
223
|
+
content_hash: hashSkillContent(targetDir),
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const skills = plan.map((item) => ({
|
|
229
|
+
skill_id: item.skillId,
|
|
230
|
+
source_url: item.origin.source_url,
|
|
231
|
+
old_commit: item.oldCommit,
|
|
232
|
+
new_commit: item.newCommit,
|
|
233
|
+
content_changed: item.contentChanged,
|
|
234
|
+
status: statusFor(item.kind, dryRun),
|
|
235
|
+
...(item.findings ? { findings: item.findings } : {}),
|
|
236
|
+
...(item.reason ? { reason: item.reason } : {}),
|
|
237
|
+
}));
|
|
238
|
+
|
|
239
|
+
emitSuccess({ isJson: options.isJson }, { dry_run: dryRun, skills }, () => {
|
|
240
|
+
if (skills.length === 0) {
|
|
241
|
+
console.log("update: nothing to do");
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
for (const s of skills) {
|
|
245
|
+
console.log(`[${s.status}] ${s.skill_id}`);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
} finally {
|
|
249
|
+
for (const item of plan) {
|
|
250
|
+
if (item.cloneDir) rmSync(item.cloneDir, { recursive: true, force: true });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
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,10 +12,21 @@ 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 {
|
|
15
|
+
export function isGitUrl(repo: string): boolean {
|
|
16
16
|
return GIT_URL_PREFIXES.some((prefix) => repo.startsWith(prefix)) || SCP_LIKE_URL_PATTERN.test(repo);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/** A `file://` source_url reaches the local filesystem directly, not just a network
|
|
20
|
+
* remote. That's fine when the user typed it themselves at `skillmux install` time,
|
|
21
|
+
* but a `.skillmux-origin` sidecar is vault content — readable and writable by
|
|
22
|
+
* whatever populated the vault (a shared git-backed vault pulled in, or a hand-edit),
|
|
23
|
+
* same threat model as every other vault-content read this codebase guards. `skillmux
|
|
24
|
+
* outdated`/`update` must not blindly git-clone/ls-remote whatever local path a
|
|
25
|
+
* forged sidecar names. */
|
|
26
|
+
export function isLocalFileUrl(url: string): boolean {
|
|
27
|
+
return url.startsWith("file://");
|
|
28
|
+
}
|
|
29
|
+
|
|
19
30
|
export function resolveRepoSource(repo: string): RepoSource {
|
|
20
31
|
if (isGitUrl(repo)) return { url: repo };
|
|
21
32
|
|
|
@@ -49,11 +60,70 @@ export async function cloneToTemp(url: string): Promise<string> {
|
|
|
49
60
|
return dir;
|
|
50
61
|
}
|
|
51
62
|
|
|
63
|
+
export function resolveCloneCommit(cloneDir: string): string {
|
|
64
|
+
const proc = Bun.spawnSync(["git", "-C", cloneDir, "rev-parse", "HEAD"], { stdout: "pipe", stderr: "pipe" });
|
|
65
|
+
if (proc.exitCode !== 0) {
|
|
66
|
+
throw new Error(`git rev-parse HEAD failed in ${cloneDir}: ${proc.stderr.toString().trim()}`);
|
|
67
|
+
}
|
|
68
|
+
return proc.stdout.toString().trim();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function remoteHeadCommit(url: string, ref = "HEAD"): Promise<string> {
|
|
72
|
+
const proc = Bun.spawn(["git", "ls-remote", url, ref], { stdout: "pipe", stderr: "pipe" });
|
|
73
|
+
const exitCode = await proc.exited;
|
|
74
|
+
const stdout = await new Response(proc.stdout).text();
|
|
75
|
+
if (exitCode !== 0) {
|
|
76
|
+
const stderr = await new Response(proc.stderr).text();
|
|
77
|
+
throw new Error(`git ls-remote failed for ${url}: ${stderr.trim()}`);
|
|
78
|
+
}
|
|
79
|
+
const line = stdout.split("\n").find((l) => l.trim().length > 0);
|
|
80
|
+
if (!line) throw new Error(`git ls-remote returned no ref "${ref}" for ${url}`);
|
|
81
|
+
const sha = line.split("\t")[0]?.trim();
|
|
82
|
+
if (!sha || !/^[0-9a-f]{40}$/.test(sha)) {
|
|
83
|
+
throw new Error(`git ls-remote returned an unparseable SHA for ${url}: ${line}`);
|
|
84
|
+
}
|
|
85
|
+
return sha;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Recursively finds symlinks under `dir` (skipping `.git`), without following them.
|
|
89
|
+
* A skill's content must be regular files only — a symlink here is how a malicious
|
|
90
|
+
* skill smuggles an escape out of the vault once `skillmux sync` exposes it inside
|
|
91
|
+
* an agent's native skill directory. `dir` itself is checked too: a `skill_path`
|
|
92
|
+
* can point straight at a directory that git committed *as a symlink* (git supports
|
|
93
|
+
* storing symlink blobs) — walking its descendants alone would silently resolve
|
|
94
|
+
* through it and report the target's real files as clean. */
|
|
95
|
+
export function findSymlinks(dir: string): string[] {
|
|
96
|
+
const found: string[] = [];
|
|
97
|
+
if (lstatSync(dir).isSymbolicLink()) {
|
|
98
|
+
return ["(the skill directory itself is a symlink)"];
|
|
99
|
+
}
|
|
100
|
+
const walk = (current: string) => {
|
|
101
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
102
|
+
if (entry.name === ".git") continue;
|
|
103
|
+
const abs = join(current, entry.name);
|
|
104
|
+
if (entry.isSymbolicLink()) {
|
|
105
|
+
found.push(relative(dir, abs));
|
|
106
|
+
} else if (entry.isDirectory()) {
|
|
107
|
+
walk(abs);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
walk(dir);
|
|
112
|
+
return found.sort();
|
|
113
|
+
}
|
|
114
|
+
|
|
52
115
|
export interface ValidationResult {
|
|
53
116
|
findings: ScanFinding[];
|
|
54
117
|
}
|
|
55
118
|
|
|
56
119
|
export async function validateSkillCandidate(skillId: string, dir: string): Promise<ValidationResult> {
|
|
120
|
+
const symlinks = findSymlinks(dir);
|
|
121
|
+
if (symlinks.length > 0) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`"${skillId}" contains symlink(s), which are not allowed in skill content: ${symlinks.join(", ")}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
57
127
|
const bytes = await Bun.file(join(dir, "SKILL.md")).bytes();
|
|
58
128
|
const body = decodeUtf8Strict(bytes);
|
|
59
129
|
parseSkillMd(skillId, body);
|
|
@@ -78,6 +148,12 @@ export async function validateSkillCandidate(skillId: string, dir: string): Prom
|
|
|
78
148
|
}
|
|
79
149
|
|
|
80
150
|
export function installIntoVault(vaultPath: string, skillId: string, sourceDir: string, force = false): string {
|
|
151
|
+
const symlinks = findSymlinks(sourceDir);
|
|
152
|
+
if (symlinks.length > 0) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`refusing to install "${skillId}": source contains symlink(s), which are not allowed in skill content: ${symlinks.join(", ")}`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
81
157
|
const targetDir = join(vaultPath, skillId);
|
|
82
158
|
if (existsSync(targetDir)) {
|
|
83
159
|
if (!force) {
|
|
@@ -96,6 +172,9 @@ export interface ResolvedSkillDir {
|
|
|
96
172
|
|
|
97
173
|
export function resolveSkillDir(cloneDir: string, fallbackName: string, skillPath?: string): ResolvedSkillDir {
|
|
98
174
|
if (skillPath) {
|
|
175
|
+
if (skillPath.startsWith("/") || skillPath.split("/").includes("..")) {
|
|
176
|
+
throw new Error(`invalid skill_path "${skillPath}": must be a relative path within the repo`);
|
|
177
|
+
}
|
|
99
178
|
return { skillId: basename(skillPath), dir: join(cloneDir, skillPath) };
|
|
100
179
|
}
|
|
101
180
|
if (existsSync(join(cloneDir, "SKILL.md"))) {
|
|
@@ -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
|
};
|