Files
AgentHub/server/__tests__/cc-mutate-containment.test.js
nntrivi2001 2ade30f670 fix(cc-config): close the symlink write escape and the caller-chosen write target
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.
2026-09-09 08:30:45 +07:00

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;
}
});
});