diff --git a/src/__tests__/rules.test.ts b/src/__tests__/rules.test.ts index 17215a2c..8b1f7d89 100644 --- a/src/__tests__/rules.test.ts +++ b/src/__tests__/rules.test.ts @@ -813,6 +813,21 @@ describe('RulesHandler — Cursor-compatible .mdc handling', () => { expect(teamContent).not.toContain('globs'); }); + it('pushItem preserves a namespaced rule destination', async () => { + const sourcePath = path.join(homeDir, '.claude/rules/scoped.md'); + await fse.ensureDir(path.dirname(sourcePath)); + await fse.writeFile(sourcePath, 'Scoped rule body.'); + + await handler.pushItem( + { name: 'scoped', type: 'rules', sourcePath, relativePath: 'rules/frontend/scoped.md' }, + teamConfig, + localConfig, + ); + + expect(await fse.readFile(path.join(repoPath, 'rules/frontend/scoped.md'), 'utf-8')).toBe('Scoped rule body.'); + expect(await fse.pathExists(path.join(repoPath, 'rules/scoped.md'))).toBe(false); + }); + it('pushItem preserves the team rule `paths:` frontmatter when pushing from cursor', async () => { // The team rule is scoped; only its body may cross back from Cursor. await fse.writeFile( diff --git a/src/resources/rules.ts b/src/resources/rules.ts index 61216bbd..a68ed3d6 100644 --- a/src/resources/rules.ts +++ b/src/resources/rules.ts @@ -11,6 +11,7 @@ import { mergeCopilotBodyIntoTeamMd, teamRuleToCopilotInstructions, } from './copilot-instructions.js'; +import { assertWithinRoot } from '../utils/path-safety.js'; import { ruleFileExtensionForTool, ruleStemFromFilename, @@ -152,7 +153,13 @@ export class RulesHandler extends ResourceHandler { } async pushItem(item: ResourceItem, _teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { - const dest = path.join(localConfig.repo.localPath, 'rules', `${item.name}.md`); + const rulesRoot = path.join(localConfig.repo.localPath, 'rules'); + const dest = path.resolve(localConfig.repo.localPath, item.relativePath); + assertWithinRoot( + rulesRoot, + dest, + `Invalid rule destination outside team repo rules directory: ${item.relativePath}`, + ); if (item.sourcePath !== dest) { if (item.sourcePath.endsWith('.mdc')) { // Source is a tool-native `.mdc`. Only its markdown body is pushed: the