diff --git a/docs/guide/separate-input-root.md b/docs/guide/separate-input-root.md index 7658c3c9c..0492890eb 100644 --- a/docs/guide/separate-input-root.md +++ b/docs/guide/separate-input-root.md @@ -53,4 +53,6 @@ rulesync generate --input-root ~/.aiglobal --global --targets claudecode --featu ## Symlinks and trust -`--input-root` is `resolve()`-ed to an absolute path before being used, but symbolic links inside the resolved directory are **not** dereferenced via `realpath`. If a symlink inside the input root points outside it, Rulesync will follow it transparently. Because `--input-root` is a deliberate user-supplied flag, treat it like any other source directory: only point Rulesync at trees you control. +Rulesync follows symbolic links during file discovery. A symlink inside `.rulesync/` that points outside the directory will be followed transparently, and the resolved file content will be copied into the generated output. This is intentional: it lets you centralize shared skills or rules in one place and reference them via symlinks from multiple project directories without duplication. + +The trust boundary is the directory you point Rulesync at. `--input-root` is `resolve()`-ed to an absolute path before use, but there is no `realpath`-based boundary check on individual symlinks inside it. Only run Rulesync against trees you control. diff --git a/skills/rulesync/separate-input-root.md b/skills/rulesync/separate-input-root.md index 7658c3c9c..0492890eb 100644 --- a/skills/rulesync/separate-input-root.md +++ b/skills/rulesync/separate-input-root.md @@ -53,4 +53,6 @@ rulesync generate --input-root ~/.aiglobal --global --targets claudecode --featu ## Symlinks and trust -`--input-root` is `resolve()`-ed to an absolute path before being used, but symbolic links inside the resolved directory are **not** dereferenced via `realpath`. If a symlink inside the input root points outside it, Rulesync will follow it transparently. Because `--input-root` is a deliberate user-supplied flag, treat it like any other source directory: only point Rulesync at trees you control. +Rulesync follows symbolic links during file discovery. A symlink inside `.rulesync/` that points outside the directory will be followed transparently, and the resolved file content will be copied into the generated output. This is intentional: it lets you centralize shared skills or rules in one place and reference them via symlinks from multiple project directories without duplication. + +The trust boundary is the directory you point Rulesync at. `--input-root` is `resolve()`-ed to an absolute path before use, but there is no `realpath`-based boundary check on individual symlinks inside it. Only run Rulesync against trees you control. diff --git a/src/utils/file.test.ts b/src/utils/file.test.ts index 3742ad51f..db606f36b 100644 --- a/src/utils/file.test.ts +++ b/src/utils/file.test.ts @@ -1,3 +1,4 @@ +import { symlink } from "node:fs/promises"; import { join, resolve } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -549,6 +550,38 @@ describe("file utilities", () => { } }); }); + + describe("symlink support", () => { + it("should include a symlinked file in results", async () => { + const realFile = join(testDir, "real.md"); + const linkedFile = join(testDir, "linked.md"); + await writeFileContent(realFile, "content"); + await symlink(realFile, linkedFile); + + const results = await findFilesByGlobs(join(testDir, "*.md"), { type: "file" }); + + expect(results).toContain(linkedFile); + }); + + it("should include a symlinked directory and files inside it in results", async () => { + const realDir = join(testDir, "real-skill"); + await ensureDir(realDir); + await writeFileContent(join(realDir, "SKILL.md"), "skill content"); + + const skillsDir = join(testDir, "skills"); + await ensureDir(skillsDir); + const linkedDir = join(skillsDir, "linked-skill"); + await symlink(realDir, linkedDir); + + const dirResults = await findFilesByGlobs(join(skillsDir, "*"), { type: "dir" }); + expect(dirResults).toContain(linkedDir); + + const fileResults = await findFilesByGlobs(join(skillsDir, "**", "*.md"), { + type: "file", + }); + expect(fileResults).toContain(join(linkedDir, "SKILL.md")); + }); + }); }); }); diff --git a/src/utils/file.ts b/src/utils/file.ts index 4d3eb4238..22e6164a8 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -219,9 +219,15 @@ export async function findFilesByGlobs( const normalizedGlobs = Array.isArray(globs) ? globs.map((g) => g.replaceAll("\\", "/")) : globs.replaceAll("\\", "/"); + // followSymbolicLinks: true lets callers use symlinks to share skills/rules across + // directories without duplication. Callers are responsible for passing globs rooted + // inside a trusted directory (inputRoot/outputRoot); this function has no path-boundary + // enforcement. Note: git-client.ts intentionally skips symlinks during remote fetch + // because unresolved symlinks in a bare clone are meaningless — that is a distinct code + // path with different trust assumptions. const results = globbySync(normalizedGlobs, { absolute: true, - followSymbolicLinks: false, + followSymbolicLinks: true, ...globbyOptions, }); // Sort for consistent ordering across different glob implementations