Skip to content

Commit 0a9039e

Browse files
committed
reject symlinked skill roots when bundling
1 parent 71660f0 commit 0a9039e

2 files changed

Lines changed: 31 additions & 1 deletion

File tree

packages/workspace-server/src/services/skills/skill-bundler.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,19 @@ function getSafeSkillFileName(name: string): string {
2323
}
2424

2525
async function assertSkillRoot(skillPath: string): Promise<string> {
26-
const root = await fs.promises.realpath(path.resolve(skillPath));
26+
const lexical = path.resolve(skillPath);
27+
const parentReal = await fs.promises.realpath(path.dirname(lexical));
28+
const root = await fs.promises.realpath(lexical);
29+
// A symlinked skill root bundles whatever it points at, so a repository could
30+
// commit `.claude/skills/foo -> ~/.claude/skills/foo` and exfiltrate a
31+
// directory from outside the repo into an uploaded bundle. Only the skill
32+
// directory itself must be real; symlinked ancestors (e.g. /tmp on macOS)
33+
// stay legal.
34+
if (root !== path.join(parentReal, path.basename(lexical))) {
35+
throw new Error(
36+
"Local skill bundle root must be a real directory, not a symlink",
37+
);
38+
}
2739
const skillMdPath = path.join(root, "SKILL.md");
2840
const stat = await fs.promises.stat(skillMdPath);
2941
if (!stat.isFile()) {

packages/workspace-server/src/services/skills/skills.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,24 @@ describe("write-path guard", () => {
502502
).rejects.toThrow("not a known skill directory");
503503
});
504504

505+
it("rejects a symlinked skill root", async () => {
506+
// A repository could commit `.claude/skills/foo` as a symlink to a
507+
// directory outside the repo; bundling must refuse to follow it rather
508+
// than upload the external target.
509+
const target = await createSkill(root, "linked");
510+
const linkPath = path.join(repoSkillsDir, "linked");
511+
await symlink(target, linkPath, "dir");
512+
const service = makeService();
513+
514+
await expect(
515+
service.bundleLocalSkill({
516+
name: "linked",
517+
source: "repo",
518+
path: linkPath,
519+
}),
520+
).rejects.toThrow("not a symlink");
521+
});
522+
505523
it.each([
506524
["bundled skill", () => path.join(pluginPath, "skills", "bundled-skill")],
507525
["arbitrary directory", () => path.join(root, "rogue")],

0 commit comments

Comments
 (0)