From acb59268a7f80a0963df6f93e547f55c63161252 Mon Sep 17 00:00:00 2001 From: garrytan Date: Mon, 26 Oct 2026 00:47:03 +0000 Subject: [PATCH] fix(context-bill): a root named '..x' no longer lets a parent link climb out encloses() tested rel.startsWith('..'), which also matches a first segment such as '..skills', so a link to the parent of a root under such a name was followed out of the root. Compare the '..' segment exactly. Adds that case plus a loop to a directory below the walk root, which only the ancestor guard stops (found by /ship pre-landing review). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- lib/context-bill.ts | 2 +- test/context-bill.test.ts | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) 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");