/** * @file config-write.js * @description Shared safe-write primitives for config writers (cc-mutate.js * for Claude Code, and any Codex equivalent): path containment, timestamped * backup-before-mutate, and atomic file write. Extracted out of cc-mutate.js * so both writers share one copy of this security-sensitive code instead of * diverging copies. * * Hard constraints (do not relax without a follow-up review): * - Backups happen BEFORE the mutation and land outside the tree being * backed up (caller passes `backupRoot`). * - Writes are atomic via temp file + fs.renameSync. Tmp is removed on * any failure path. * - `assertUnder` is the single containment check callers must run on any * resolved path before touching disk. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const path = require("node:path"); const { isUnder } = require("./cc-discovery"); function timestamp() { return new Date().toISOString().replace(/[:]/g, "-"); } function copyDirSync(src, dst) { fs.mkdirSync(dst, { recursive: true }); for (const ent of fs.readdirSync(src, { withFileTypes: true })) { const s = path.join(src, ent.name); const d = path.join(dst, ent.name); if (ent.isDirectory()) copyDirSync(s, d); else if (ent.isFile()) fs.copyFileSync(s, d); // symlinks/sockets/etc skipped intentionally — these surfaces are // text-file-only by spec } } /** * Always-on backup. Copies `source` (file or directory tree) into * `backupRoot` as `..bak`. Returns the backup path, or null if * `source` does not exist (e.g. brand-new file/skill). */ function createBackup({ source, backupRoot, type, name }) { if (!fs.existsSync(source)) return null; fs.mkdirSync(backupRoot, { recursive: true }); const stamp = timestamp(); const dst = path.join(backupRoot, `${name}.${stamp}.bak`); if (fs.statSync(source).isDirectory()) { copyDirSync(source, dst); } else { fs.copyFileSync(source, dst); } return dst; } /** * Atomic write: tmp file → fsync (best-effort) → rename. Tmp is unlinked * on any failure path. Caller is responsible for ensuring parent dir exists. */ function atomicWriteFile(filePath, content) { const dir = path.dirname(filePath); fs.mkdirSync(dir, { recursive: true }); const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`); let fd; try { fd = fs.openSync(tmp, "wx"); fs.writeSync(fd, content); try { fs.fsyncSync(fd); } catch { // fsync may fail on some filesystems / tmpfs — non-fatal } fs.closeSync(fd); fd = null; fs.renameSync(tmp, filePath); } catch (err) { try { if (fd != null) fs.closeSync(fd); } catch { /* ignore */ } try { if (fs.existsSync(tmp)) fs.unlinkSync(tmp); } catch { /* ignore */ } throw err; } } /** Throw EOUTOFROOT unless `target` resolves to `root` or a descendant. */ function assertUnder(root, target) { if (!isUnder(root, target)) { const err = new Error("resolved path is outside containment root"); err.code = "EOUTOFROOT"; throw err; } } /** * The containment check for WRITE paths. `assertUnder` compares strings, which * a symlink defeats: a link planted at `/skills/` — or at any * ancestor of it — is lexically inside the root while the write lands wherever * the link points. * * So resolve instead of compare. Walk up from `target` to the first component * that actually exists, realpath THAT, and require the result inside the * realpath'd root. Checking only `target` is not enough: on a create the leaf * does not exist yet, `realpathSync` fails with ENOENT, and a symlinked * ancestor sails through. * * A symlink that stays inside the root still works — the rule is where the * path lands, not whether a link was involved. * * Read paths deliberately do NOT use this. Reading through a link shows you a * file your own config directory points at, which is what the CLIs do; * writing through one clobbers something unrelated. * * ponytail: check-then-use, so a local attacker who swaps a directory for a * symlink between this call and the write still wins. Closing that needs * O_NOFOLLOW-style fd work with real platform variance. * * @param {string} trustedRoot the outermost root the write must stay inside — * never a subdirectory an attacker could replace with a link. * @param {string} target the path about to be written, created or removed. * @param {{create?: boolean, label?: string}} [opts] `create: false` skips * materialising the root (a read-side caller must not have side effects). */ function assertNoSymlinkEscape(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)) { const err = new Error(`resolved path escapes the ${opts.label || "config"} root: ${real}`); err.code = "EOUTOFROOT"; throw err; } } module.exports = { timestamp, createBackup, atomicWriteFile, assertUnder, assertNoSymlinkEscape, };