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.
This commit is contained in:
2026-09-09 08:30:45 +07:00
parent 5720e8d5f8
commit 2ade30f670
6 changed files with 320 additions and 37 deletions
+13 -2
View File
@@ -178,7 +178,17 @@ router.put("/file", (req, res) => {
.json({ error: { code: "EBADREQ", message: "scope and type are required" } });
}
try {
const result = ccMutate.writeArtifact({ scope, type, name, content, project, cwd: cwdOf(req) });
// process.cwd(), never ?cwd= — the same rule PATCH /settings already
// follows: a caller-chosen write target lets any request drop a file
// anywhere on disk. Reads keep the override; writes do not.
const result = ccMutate.writeArtifact({
scope,
type,
name,
content,
project,
cwd: process.cwd(),
});
emitChanged({ action: "write", scope, type, name: name || null, project: project || null });
res.json(result);
} catch (err) {
@@ -194,7 +204,8 @@ router.delete("/file", (req, res) => {
.json({ error: { code: "EBADREQ", message: "scope and type are required" } });
}
try {
const result = ccMutate.deleteArtifact({ scope, type, name, project, cwd: cwdOf(req) });
// Pinned for the same reason as PUT above.
const result = ccMutate.deleteArtifact({ scope, type, name, project, cwd: process.cwd() });
emitChanged({ action: "delete", scope, type, name: name || null, project: project || null });
res.json(result);
} catch (err) {