From 2f5fc7c4e96c4e8148bbcf7bfbb5d4ea0d450df6 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 20 May 2026 18:19:17 +0900 Subject: [PATCH] fix(rules-core): block symlinked rule directory escapes --- packages/rules-core/src/finder.ts | 3 +- .../rules-core/src/security-boundary.test.ts | 78 +++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 packages/rules-core/src/security-boundary.test.ts diff --git a/packages/rules-core/src/finder.ts b/packages/rules-core/src/finder.ts index b7cb4aea9..6509803d2 100644 --- a/packages/rules-core/src/finder.ts +++ b/packages/rules-core/src/finder.ts @@ -69,13 +69,14 @@ function addProjectRuleCandidates( seenRealPaths: Set, 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); diff --git a/packages/rules-core/src/security-boundary.test.ts b/packages/rules-core/src/security-boundary.test.ts new file mode 100644 index 000000000..548d7557f --- /dev/null +++ b/packages/rules-core/src/security-boundary.test.ts @@ -0,0 +1,78 @@ +/// + +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); + }); +});