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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-26 00:47:03 +00:00
1 parent 9f16710851
commit acb59268a7
2 files changed
+18 -1

No files matched your search

+1 -1
View File
@@ -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));
}
/**
+17
View File
@@ -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");