/** * @file codex-mutate.js * @description Mutation helpers for the Codex Config panel. Handles * create / overwrite / delete on Codex's text-file surfaces: skills, agents, * rules, memory (AGENTS.md), and config (config.toml). Mirrors cc-mutate.js's * structure (single `resolveTarget` chokepoint, regex-validated names, * containment re-check, backup-before-mutation, atomic write) so the two * panels share the same safety shape and error-code vocabulary. * * Hard constraints (do not relax without a follow-up review): * - `memory` and `config` are fixed filenames (AGENTS.md / config.toml) — * they take NO `name`. Passing one is a caller error (EBADNAME). * - Every write/delete of an existing file creates a timestamped backup * BEFORE the mutation. Backups land under * /codex-config-backups//, a directory Codex never scans, so * a deleted skill can never come back as a discoverable skill named * after its backup. * - Writes are atomic via temp file + fs.renameSync (see config-write.js). * - Names are validated against a strict allowlist regex; resolved paths * are double-checked to live under the expected containment root before * any I/O. That string-only check does not see through a symlink placed * anywhere along the path — including the type's own subdir (e.g. * `/skills`) — so the nearest existing ancestor of the target is * additionally realpath-resolved and required to land inside the * realpath'd scope root (see `assertNotSymlinkEscape`). A symlink that * merely reorganises files within the root (a supported install * pattern, and a symlinked CODEX_HOME itself) keeps working; one * pointing outside is refused. * - Codex has no `project-local` scope and no auto-memory: only "user" and * "project" are valid scopes. * @author Nguyễn Ngọc Trí Vĩ */ 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, assertNoSymlinkEscape, } = require("./config-write"); const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; const TYPES = { skills: { kind: "dir", subdir: "skills", filename: "SKILL.md" }, agents: { kind: "file", subdir: "agents", ext: ".toml" }, rules: { kind: "file", subdir: "rules", ext: ".rules" }, memory: { kind: "fixed", file: "AGENTS.md" }, // AGENTS.md, no `name` config: { kind: "fixed", file: "config.toml" }, // config.toml, no `name` }; function makeError(code, message) { const err = new Error(message); err.code = code; return err; } function assertScope(scope) { if (scope !== "user" && scope !== "project") { throw makeError("EBADSCOPE", `unknown scope: ${scope}`); } } /** The a (scope) resolves under for skills/agents/rules subdirs. */ function rootForScope(scope, opts = {}) { return scope === "user" ? getCodexHome() : getProjectCodexDir(opts.cwd); } /** Fixed-filename path for memory/config, per scope's own layout rule. */ function fixedPathFor(type, scope, opts = {}) { if (type === "memory") { return scope === "user" ? path.join(getCodexHome(), "AGENTS.md") : path.join(getProjectRoot(opts.cwd), "AGENTS.md"); } // config return scope === "user" ? path.join(getCodexHome(), "config.toml") : path.join(getProjectCodexDir(opts.cwd), "config.toml"); } /** * Resolve the on-disk target for a (scope, type, name) tuple, the * containment root used for string-only path-traversal checks, and the * trusted root used for the realpath-based symlink-escape check. * * `trustedRoot` is deliberately the OUTER root (CODEX_HOME / project .codex * dir / project root), not `containmentRoot`: for skills/agents/rules, * `containmentRoot` is `/` (e.g. `/skills`), and that * subdir itself is exactly what an attacker could replace with a symlink — * checking a target against its own compromised parent would always pass. * * Returns: { kind: "file" | "dir", target, filePath, containmentRoot, trustedRoot } */ function resolveTarget(scope, type, name, opts = {}) { const spec = TYPES[type]; if (!spec) throw makeError("EBADTYPE", `unknown type: ${type}`); assertScope(scope); if (spec.kind === "fixed") { if (name !== undefined && name !== null) { throw makeError("EBADNAME", `${type} is a fixed file and takes no name`); } const filePath = fixedPathFor(type, scope, opts); // No attacker-placeable segment between the trusted root and the fixed // filename, so the containment root doubles as the trusted root. const root = path.dirname(filePath); return { kind: "file", target: filePath, filePath, containmentRoot: root, trustedRoot: root }; } if (typeof name !== "string" || !NAME_RE.test(name)) { throw makeError("EBADNAME", `name must match ${NAME_RE}`); } const root = rootForScope(scope, opts); const subdirAbs = path.join(root, spec.subdir); if (spec.kind === "dir") { const target = path.join(subdirAbs, name); return { kind: "dir", target, filePath: path.join(target, spec.filename), containmentRoot: subdirAbs, trustedRoot: root, }; } const target = path.join(subdirAbs, name + spec.ext); return { kind: "file", target, filePath: target, containmentRoot: subdirAbs, trustedRoot: root }; } /** /codex-config-backups//, always rooted the same way as the read side's . */ function backupRootFor(scope, type, opts = {}) { return path.join(rootForScope(scope, opts), "codex-config-backups", type); } function rmTreeSync(p) { fs.rmSync(p, { recursive: true, force: true }); } /** * Symlinks are a supported install pattern (Task 3's reader follows them), * so a symlink under `trustedRoot` is not rejected outright — only one that * dereferences outside it. `trustedRoot` itself may be a symlink (a * dotfiles-managed CODEX_HOME is a normal setup) — that's fine, it just * means everything is compared against wherever it really points. * * `target` may not exist yet (a fresh create). Realpath can't resolve a * path that isn't there, so this walks up to the nearest ancestor that DOES * exist, realpath's that, and requires it to land inside the realpath'd * root. Everything below that existing ancestor is plain path segments this * module built itself from a NAME_RE-validated name or a fixed literal, so * they can't smuggle in a symlink — only an EXISTING ancestor can. * * `trustedRoot` is created first (mkdirSync recursive — safe, it is never * attacker-controlled) so the walk is guaranteed to bottom out at or below * it, never wander past it into whatever happens to contain it. Pass * `{create: false}` for a read-only caller (codex-discovery.js's * readFileSafe): a GET must have no filesystem side effect, so it skips the * mkdir and instead lets a missing `trustedRoot` surface as the native * `ENOENT` from `realpathSync` — the caller treats that as "not found". * * ponytail: this check and the write/delete that follows are not atomic — * a symlink swapped in between them could still redirect one write. Closing * that needs O_NOFOLLOW-style fd work with real platform variance; out of * scope for this task (see task-5 fix round 2). */ function assertNotSymlinkEscape(trustedRoot, target, opts = {}) { // 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 ───────────────────────────────────────────────────────── /** * Create or overwrite a single text artifact. `name` is required for * skills/agents/rules and forbidden for memory/config. * * @param {{scope:string, type:string, name?:string, content:string, cwd?:string}} args */ function writeArtifact(args) { const { scope, type, name, content, cwd } = args; if (typeof content !== "string") throw makeError("EBADCONTENT", "content must be a string"); if (Buffer.byteLength(content, "utf8") > MAX_FILE_BYTES) { throw makeError("ETOOLARGE", `content exceeds ${MAX_FILE_BYTES} bytes`); } const r = resolveTarget(scope, type, name, { cwd }); assertUnder(r.containmentRoot, r.target); assertNotSymlinkEscape(r.trustedRoot, r.target); const existedBefore = fs.existsSync(r.filePath); let backup = null; if (existedBefore) { const backupRoot = backupRootFor(scope, type, { cwd }); // The backup write never went through the symlink-escape guard: a // `/codex-config-backups` symlink would send the previous file's // body wherever it points. Same check as the target above, same reason. assertNotSymlinkEscape(r.trustedRoot, backupRoot); backup = createBackup({ source: r.kind === "dir" ? r.target : r.filePath, backupRoot, type, name: path.basename(r.target), }); } if (r.kind === "dir") { fs.mkdirSync(r.target, { recursive: true }); } atomicWriteFile(r.filePath, content); return { file: r.filePath, created: !existedBefore, backup }; } /** * Delete a single text artifact. Backup is mandatory and runs first; if * the target does not exist, no backup is attempted. */ function deleteArtifact(args) { const { scope, type, name, cwd } = args; const r = resolveTarget(scope, type, name, { cwd }); assertUnder(r.containmentRoot, r.target); if (!fs.existsSync(r.target)) { throw makeError("ENOTFOUND", `${type}/${name || TYPES[type].file} does not exist`); } assertNotSymlinkEscape(r.trustedRoot, r.target); const backupRoot = backupRootFor(scope, type, { cwd }); assertNotSymlinkEscape(r.trustedRoot, backupRoot); const backup = createBackup({ source: r.target, backupRoot, type, name: path.basename(r.target), }); if (r.kind === "dir") { rmTreeSync(r.target); } else { fs.unlinkSync(r.target); } return { file: r.filePath, backup }; } /** * List backups for either all types or a specific (scope, type) bucket. * Returns [{ type, name, file, mtime, size }]. */ function listBackups(opts = {}) { const scopes = opts.scope ? [opts.scope] : ["user", "project"]; const types = opts.type ? [opts.type] : Object.keys(TYPES); for (const scope of scopes) assertScope(scope); for (const type of types) { if (!TYPES[type]) throw makeError("EBADTYPE", `unknown type: ${type}`); } const out = []; for (const scope of scopes) { for (const type of types) { const root = backupRootFor(scope, type, opts); let entries = []; try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { continue; } for (const ent of entries) { const full = path.join(root, ent.name); let stat; try { stat = fs.statSync(full); } catch { continue; } out.push({ type, name: ent.name, file: full, mtime: stat.mtimeMs, size: ent.isDirectory() ? null : stat.size, }); } } } return out.sort((a, b) => b.mtime - a.mtime); } module.exports = { writeArtifact, deleteArtifact, listBackups, resolveTarget, // exported for tests assertNotSymlinkEscape, // shared with codex-discovery.js's readFileSafe — one check, both verbs TYPES, NAME_RE, };