Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/guide/separate-input-root.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 3 additions & 1 deletion skills/rulesync/separate-input-root.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
33 changes: 33 additions & 0 deletions src/utils/file.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { symlink } from "node:fs/promises";
import { join, resolve } from "node:path";

import { afterEach, beforeEach, describe, expect, it } from "vitest";
Expand Down Expand Up @@ -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"));
});
});
});
});

Expand Down
8 changes: 7 additions & 1 deletion src/utils/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading