diff --git a/lib/context-bill.ts b/lib/context-bill.ts index 2cf411915..32b4c6b03 100644 --- a/lib/context-bill.ts +++ b/lib/context-bill.ts @@ -221,7 +221,7 @@ function entryKind(p: string, e: fs.Dirent): "file" | "dir" | null { */ function encloses(outer: string, inner: string): boolean { const rel = path.relative(outer, inner); - return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); + return rel === "" || (rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel)); } /** diff --git a/test/context-bill.test.ts b/test/context-bill.test.ts index 2e6ce82ab..be0da9791 100644 --- a/test/context-bill.test.ts +++ b/test/context-bill.test.ts @@ -299,6 +299,8 @@ describe("upstream fix b: installed-tree layout (repo-checkout subdir skip)", () fs.symlinkSync(tmp, path.join(root, "parent")); fs.symlinkSync(path.join(root, "qa"), path.join(root, "qa", "references", "loop")); fs.symlinkSync(tmp, path.join(root, "qa", "references", "up")); + // A loop to a directory below the walk root: only the ancestor guard stops it. + fs.symlinkSync(path.join(root, "qa", "references"), path.join(root, "qa", "references", "again")); expect(findSkillDirs(root).map((d) => path.relative(root, d))).toEqual(["qa"]); expect(walkMd(path.join(root, "qa")).map((f) => path.relative(root, f)).sort()).toEqual([ @@ -310,6 +312,21 @@ describe("upstream fix b: installed-tree layout (repo-checkout subdir skip)", () fs.rmSync(tmp, { recursive: true, force: true }); }); + it("refuses a parent link when the root's own name starts with '..'", () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-dotdot-")); + fs.mkdirSync(path.join(tmp, "outside")); + fs.writeFileSync(path.join(tmp, "outside", "SKILL.md"), "---\nname: outside\ndescription: d\n---\n# o\n"); + const root = path.join(tmp, "..skills"); + fs.mkdirSync(path.join(root, "qa"), { recursive: true }); + fs.writeFileSync(path.join(root, "qa", "SKILL.md"), "---\nname: qa\ndescription: d\n---\n# qa\n"); + fs.symlinkSync(tmp, path.join(root, "up")); + + expect(findSkillDirs(root).map((d) => path.relative(root, d))).toEqual(["qa"]); + expect(walkMd(root).map((f) => path.relative(root, f))).toEqual([path.join("qa", "SKILL.md")]); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + it("prefers ./skills, then .agents/skills, then .claude/skills, project before user", async () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-detect-")); const home = path.join(tmp, "home");