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
+9 -23
View File
@@ -35,7 +35,12 @@ const fs = require("node:fs");
const path = require("node:path");
const { getCodexHome, getProjectCodexDir, getProjectRoot } = require("./codex-home");
const { MAX_FILE_BYTES, isUnder } = require("./cc-discovery");
const { createBackup, atomicWriteFile, assertUnder } = require("./config-write");
const {
createBackup,
atomicWriteFile,
assertUnder,
assertNoSymlinkEscape,
} = require("./config-write");
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
@@ -165,28 +170,9 @@ function rmTreeSync(p) {
* scope for this task (see task-5 fix round 2).
*/
function assertNotSymlinkEscape(trustedRoot, target, opts = {}) {
if (opts.create !== false) fs.mkdirSync(trustedRoot, { recursive: true });
const realRoot = fs.realpathSync(trustedRoot);
let dir = target;
let real;
for (;;) {
try {
real = fs.realpathSync(dir);
break;
} catch {
const parent = path.dirname(dir);
if (parent === dir) {
real = dir; // hit the filesystem root without finding anything real
break;
}
dir = parent;
}
}
if (!isUnder(realRoot, real)) {
throw makeError("EOUTOFROOT", `resolved path escapes the Codex root: ${real}`);
}
// Lives in config-write.js so the Claude writer runs the identical check —
// this guard cost four fix rounds to get right and must not exist twice.
assertNoSymlinkEscape(trustedRoot, target, { ...opts, label: "Codex" });
}
// ── Public API ─────────────────────────────────────────────────────────