2ade30f670
The Claude writer's containment was lexical only: a symlink at ~/.claude/<type>/<name>, or at any ancestor of it, is a string inside the root while the write lands wherever it points. The backup destination had the same exposure, and delete copies through the link before unlinking it. PUT/DELETE /file also honoured ?cwd=, so a project-scope write could be aimed at any directory on disk. PATCH /settings already refused that, with the reasoning in a comment; the two other write routes never adopted it. The guard moves into config-write.js so both writers run one copy.
144 lines
5.6 KiB
JavaScript
144 lines
5.6 KiB
JavaScript
/**
|
|
* @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/<name>` — 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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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;
|
|
}
|
|
});
|
|
});
|