diff --git a/src/cli/commands/skill.ts b/src/cli/commands/skill.ts index 9197d2a..0a94a26 100644 --- a/src/cli/commands/skill.ts +++ b/src/cli/commands/skill.ts @@ -14,7 +14,7 @@ * windsurf Windsurf (~/.codeium/windsurf/mcp_config.json) */ -import { readFile, writeFile, mkdir, rename, copyFile } from "node:fs/promises" +import { readFile, writeFile, mkdir, rename, copyFile, lstat } from "node:fs/promises" import { existsSync } from "node:fs" import { resolve, dirname } from "node:path" import { homedir, platform } from "node:os" @@ -101,6 +101,7 @@ function isToolDetected(tool: ToolDef): boolean { export async function readJsonConfig(path: string): Promise> { if (!existsSync(path)) return {} + await assertSafeConfigPath(path) const raw = await readFile(path, "utf-8") const normalized = raw.replace(/^\uFEFF/, "").trim() if (normalized.length === 0) return {} @@ -110,6 +111,7 @@ export async function readJsonConfig(path: string): Promise): Promise { await mkdir(dirname(path), { recursive: true }) if (existsSync(path)) { + await assertSafeConfigPath(path) await copyFile(path, path + ".backup") } const tmp = path + ".tmp" @@ -117,6 +119,16 @@ export async function writeJsonConfig(path: string, data: Record { + const stats = await lstat(path) + if (stats.isSymbolicLink()) { + throw new Error(`Refusing to use symlinked config path: ${path}`) + } + if (!stats.isFile()) { + throw new Error(`Config path is not a regular file: ${path}`) + } +} + function hasYouMdInstalled(config: Record, tool: ToolDef): boolean { let cur: unknown = config diff --git a/test/cli/skill.test.ts b/test/cli/skill.test.ts index aa61983..2869c7f 100644 --- a/test/cli/skill.test.ts +++ b/test/cli/skill.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { join } from "node:path"; -import { mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { mkdirSync, rmSync, writeFileSync, readFileSync, existsSync, symlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import { readJsonConfig, writeJsonConfig } from "../../src/cli/commands/skill"; @@ -52,6 +52,15 @@ describe("readJsonConfig", () => { const result = await readJsonConfig(filePath); expect(result).toEqual(data); }); + + it("rejects symlinked config files", async () => { + const targetPath = join(tempDir, "target.json"); + const linkPath = join(tempDir, "link.json"); + writeFileSync(targetPath, JSON.stringify({ ok: true }), "utf-8"); + symlinkSync(targetPath, linkPath); + + await expect(readJsonConfig(linkPath)).rejects.toThrow(/symlinked config path/); + }); }); describe("writeJsonConfig", () => { @@ -105,4 +114,13 @@ describe("writeJsonConfig", () => { const content = readFileSync(filePath, "utf-8"); expect(content).toBe(JSON.stringify(data, null, 2) + "\n"); }); + + it("rejects writing to symlinked config files", async () => { + const targetPath = join(tempDir, "target-write.json"); + const linkPath = join(tempDir, "link-write.json"); + writeFileSync(targetPath, JSON.stringify({ existing: true }), "utf-8"); + symlinkSync(targetPath, linkPath); + + await expect(writeJsonConfig(linkPath, { updated: true })).rejects.toThrow(/symlinked config path/); + }); });