fix: harden symlink skill discovery traversal (#1085)

This commit is contained in:
Charles Packer
2026-02-21 12:35:14 -08:00
committed by GitHub
parent 313f3eaaea
commit a3964ae61f
2 changed files with 166 additions and 18 deletions

View File

@@ -0,0 +1,111 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import {
existsSync,
mkdirSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import { discoverSkills } from "../../agent/skills";
describe.skipIf(process.platform === "win32")(
"skills discovery with symlinks",
() => {
const testDir = join(process.cwd(), ".test-skills-discovery");
const projectSkillsDir = join(testDir, ".skills");
const originalCwd = process.cwd();
const writeSkill = (skillDir: string, skillName: string) => {
mkdirSync(skillDir, { recursive: true });
writeFileSync(
join(skillDir, "SKILL.md"),
`---\nname: ${skillName}\ndescription: ${skillName} description\n---\n\n# ${skillName}\n`,
);
};
beforeEach(() => {
mkdirSync(testDir, { recursive: true });
process.chdir(testDir);
});
afterEach(() => {
process.chdir(originalCwd);
if (existsSync(testDir)) {
rmSync(testDir, { recursive: true, force: true });
}
});
test("discovers skills from symlinked directories", async () => {
mkdirSync(projectSkillsDir, { recursive: true });
const externalSkillDir = join(testDir, "external-skill");
writeSkill(externalSkillDir, "Linked Skill");
symlinkSync(
externalSkillDir,
join(projectSkillsDir, "linked-skill"),
"dir",
);
const result = await discoverSkills(projectSkillsDir, undefined, {
skipBundled: true,
sources: ["project"],
});
expect(result.errors).toHaveLength(0);
expect(result.skills.some((skill) => skill.id === "linked-skill")).toBe(
true,
);
});
test("handles symlink cycles without hanging and still discovers siblings", async () => {
mkdirSync(projectSkillsDir, { recursive: true });
writeSkill(join(projectSkillsDir, "good-skill"), "Good Skill");
const cycleDir = join(projectSkillsDir, "cycle");
mkdirSync(cycleDir, { recursive: true });
symlinkSync("..", join(cycleDir, "loop"), "dir");
const result = (await Promise.race([
discoverSkills(projectSkillsDir, undefined, {
skipBundled: true,
sources: ["project"],
}),
new Promise<never>((_, reject) => {
setTimeout(
() => reject(new Error("skills discovery timed out")),
2000,
);
}),
])) as Awaited<ReturnType<typeof discoverSkills>>;
expect(result.skills.some((skill) => skill.id === "good-skill")).toBe(
true,
);
});
test("continues discovery when a dangling symlink cannot be inspected", async () => {
mkdirSync(projectSkillsDir, { recursive: true });
writeSkill(join(projectSkillsDir, "healthy-skill"), "Healthy Skill");
symlinkSync(
join(projectSkillsDir, "missing-target"),
join(projectSkillsDir, "broken-link"),
"dir",
);
const result = await discoverSkills(projectSkillsDir, undefined, {
skipBundled: true,
sources: ["project"],
});
expect(result.skills.some((skill) => skill.id === "healthy-skill")).toBe(
true,
);
expect(
result.errors.some((error) => error.path.includes("broken-link")),
).toBe(true);
});
},
);