fix(rules-core): block symlinked rule directory escapes

This commit is contained in:
YeonGyu-Kim
2026-05-20 18:19:17 +09:00
parent 330e437f08
commit 2f5fc7c4e9
2 changed files with 80 additions and 1 deletions
+2 -1
View File
@@ -69,13 +69,14 @@ function addProjectRuleCandidates(
seenRealPaths: Set<string>,
cache: RuleScanCache | undefined,
): void {
const projectRootRealPath = safeRealpathSync(projectRoot);
let currentDir = startDir;
let distance = 0;
while (true) {
for (const [parent, subdir] of PROJECT_RULE_SUBDIRS) {
const source = `${parent}/${subdir}` as RuleSource;
const ruleDir = join(currentDir, parent, subdir);
for (const entry of scanDirectoryWithCache(ruleDir, cache)) {
for (const entry of scanDirectoryWithCache(ruleDir, cache, projectRootRealPath)) {
if (seenRealPaths.has(entry.realPath)) continue;
seenRealPaths.add(entry.realPath);
warnSisyphusRuleDeprecation(source, entry.path);
@@ -0,0 +1,78 @@
/// <reference path="../../../bun-test.d.ts" />
import { mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "bun:test";
import { clearProjectRootCache, findRuleFiles } from "./index";
import { _resetSisyphusRuleDeprecationWarningStateForTesting } from "./finder";
let testRoot: string | null = null;
function createTestRoot(name: string): string {
testRoot = join(tmpdir(), `${name}-${Date.now()}-${Math.random()}`);
mkdirSync(testRoot, { recursive: true });
return testRoot;
}
afterEach(() => {
_resetSisyphusRuleDeprecationWarningStateForTesting();
if (testRoot) {
rmSync(testRoot, { recursive: true, force: true });
testRoot = null;
}
clearProjectRootCache();
});
describe("rules-core security boundary", () => {
it("#given a project .omo/rules directory symlink escapes the workspace #when finding rule files #then escaped rules are rejected", () => {
// given
const root = createTestRoot("rules-core-project-dir-symlink-escape");
const projectRoot = join(root, "repo");
const homeDir = join(root, "home");
const outsideDir = join(root, "outside-rules");
const currentFile = join(projectRoot, "src", "index.ts");
const escapedRule = join(outsideDir, "leak.md");
mkdirSync(projectRoot, { recursive: true });
mkdirSync(join(projectRoot, ".git"));
mkdirSync(join(projectRoot, ".omo"), { recursive: true });
mkdirSync(join(projectRoot, "src"), { recursive: true });
mkdirSync(homeDir, { recursive: true });
mkdirSync(outsideDir, { recursive: true });
writeFileSync(currentFile, "export {};");
writeFileSync(escapedRule, "do not inject this external project rule");
symlinkSync(outsideDir, join(projectRoot, ".omo", "rules"), "dir");
// when
const found = findRuleFiles(projectRoot, homeDir, currentFile);
// then
expect(found.some((rule) => rule.realPath === realpathSync.native(escapedRule))).toBe(false);
});
it("#given a project .github/instructions directory symlink escapes the workspace #when finding rule files #then escaped instructions are rejected", () => {
// given
const root = createTestRoot("rules-core-github-dir-symlink-escape");
const projectRoot = join(root, "repo");
const homeDir = join(root, "home");
const outsideDir = join(root, "outside-instructions");
const currentFile = join(projectRoot, "src", "index.ts");
const escapedInstruction = join(outsideDir, "leak.instructions.md");
mkdirSync(projectRoot, { recursive: true });
mkdirSync(join(projectRoot, ".git"));
mkdirSync(join(projectRoot, ".github"), { recursive: true });
mkdirSync(join(projectRoot, "src"), { recursive: true });
mkdirSync(homeDir, { recursive: true });
mkdirSync(outsideDir, { recursive: true });
writeFileSync(currentFile, "export {};");
writeFileSync(escapedInstruction, "do not inject this external github instruction");
symlinkSync(outsideDir, join(projectRoot, ".github", "instructions"), "dir");
// when
const found = findRuleFiles(projectRoot, homeDir, currentFile);
// then
expect(found.some((rule) => rule.realPath === realpathSync.native(escapedInstruction))).toBe(false);
});
});