/** * @file cc-mutate-containment.test.js * @description Path-containment tests for cc-mutate.js's artifact writer. * * `isUnder` compares strings, which a symlink defeats: a link planted at * `~/.claude/skills/` — or at any ancestor of it — is lexically inside * the root while the write lands wherever the link points. The Codex writer * hit both variants (leaf first, then ancestor on a fresh create, where * realpath fails with ENOENT and a naive check waves it through), and the * backup DESTINATION is a third path with the same exposure. * * Every case here asserts the refusal AND that nothing appeared outside the * root — a thrown error with the write already done is not a fix. * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it, beforeEach, after } = require("node:test"); const assert = require("node:assert/strict"); const fs = require("node:fs"); const path = require("node:path"); const os = require("node:os"); const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "cc-mutate-containment-")); const FAKE_HOME = path.join(TMP, "home", ".claude"); const OUTSIDE = path.join(TMP, "outside"); fs.mkdirSync(FAKE_HOME, { recursive: true }); fs.mkdirSync(OUTSIDE, { recursive: true }); process.env.CLAUDE_HOME = FAKE_HOME; const ccMutate = require("../lib/cc-mutate"); after(() => fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5 })); beforeEach(() => { fs.rmSync(FAKE_HOME, { recursive: true, force: true }); fs.rmSync(OUTSIDE, { recursive: true, force: true }); fs.mkdirSync(FAKE_HOME, { recursive: true }); fs.mkdirSync(OUTSIDE, { recursive: true }); }); const write = (over = {}) => ccMutate.writeArtifact({ scope: "user", type: "skills", name: "victim", content: "NEW", ...over, }); describe("cc-mutate: symlink containment on the write path", () => { it("refuses a leaf symlink pointing out of the root, and writes nothing there", () => { const outsideDir = path.join(OUTSIDE, "target"); fs.mkdirSync(outsideDir, { recursive: true }); fs.writeFileSync(path.join(outsideDir, "SKILL.md"), "ORIGINAL"); fs.mkdirSync(path.join(FAKE_HOME, "skills"), { recursive: true }); fs.symlinkSync(outsideDir, path.join(FAKE_HOME, "skills", "victim")); assert.throws( () => write(), (e) => e.code === "EOUTOFROOT" ); assert.equal(fs.readFileSync(path.join(outsideDir, "SKILL.md"), "utf8"), "ORIGINAL"); }); it("refuses a symlinked ANCESTOR even when the artifact itself is new", () => { // The case a leaf-only check misses: realpath on a not-yet-existing leaf // throws ENOENT, so the walk has to climb to the symlinked parent. fs.symlinkSync(OUTSIDE, path.join(FAKE_HOME, "skills")); assert.throws( () => write({ name: "fresh" }), (e) => e.code === "EOUTOFROOT" ); assert.equal(fs.existsSync(path.join(OUTSIDE, "fresh")), false); }); it("refuses to back up through a symlinked backup root", () => { // rmSync/unlink only remove the link, but the backup runs first and // copies THROUGH it — so the destination needs the guard too. fs.mkdirSync(path.join(FAKE_HOME, "skills", "victim"), { recursive: true }); fs.writeFileSync(path.join(FAKE_HOME, "skills", "victim", "SKILL.md"), "OLD"); fs.symlinkSync(OUTSIDE, path.join(FAKE_HOME, "cc-config-backups")); assert.throws( () => write(), (e) => e.code === "EOUTOFROOT" ); assert.deepEqual(fs.readdirSync(OUTSIDE), []); // the original is untouched — the backup failing must not have written assert.equal( fs.readFileSync(path.join(FAKE_HOME, "skills", "victim", "SKILL.md"), "utf8"), "OLD" ); }); it("refuses a delete whose target is a symlink out of the root", () => { const outsideFile = path.join(OUTSIDE, "SKILL.md"); fs.writeFileSync(outsideFile, "ORIGINAL"); fs.mkdirSync(path.join(FAKE_HOME, "skills"), { recursive: true }); fs.symlinkSync(OUTSIDE, path.join(FAKE_HOME, "skills", "victim")); assert.throws( () => ccMutate.deleteArtifact({ scope: "user", type: "skills", name: "victim" }), (e) => e.code === "EOUTOFROOT" ); assert.equal(fs.readFileSync(outsideFile, "utf8"), "ORIGINAL"); }); }); describe("cc-mutate: symlinks that stay inside the root still work", () => { it("writes through a symlink whose target is inside the root", () => { // Reorganising within ~/.claude is legitimate; the rule is where the path // lands, not whether a link was involved. const real = path.join(FAKE_HOME, "real-skills"); fs.mkdirSync(real, { recursive: true }); fs.symlinkSync(real, path.join(FAKE_HOME, "skills")); const res = write({ name: "ok" }); assert.equal(res.created, true); assert.equal(fs.readFileSync(path.join(real, "ok", "SKILL.md"), "utf8"), "NEW"); }); it("works when CLAUDE_HOME itself is a symlink — the dotfiles case", () => { // A dotfiles-managed ~/.claude is ordinary. Comparing an unresolved root // against a resolved target would reject every write on such a machine. const realHome = path.join(TMP, "real-home"); fs.rmSync(realHome, { recursive: true, force: true }); fs.mkdirSync(realHome, { recursive: true }); const linkedHome = path.join(TMP, "linked-home"); fs.rmSync(linkedHome, { recursive: true, force: true }); fs.symlinkSync(realHome, linkedHome); process.env.CLAUDE_HOME = linkedHome; try { const res = write({ name: "ok" }); assert.equal(res.created, true); assert.equal(fs.readFileSync(path.join(realHome, "skills", "ok", "SKILL.md"), "utf8"), "NEW"); } finally { process.env.CLAUDE_HOME = FAKE_HOME; } }); });