2ade30f670
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.
305 lines
11 KiB
JavaScript
305 lines
11 KiB
JavaScript
/**
|
|
* @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
|
|
* <root>/codex-config-backups/<type>/, 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.
|
|
* `<root>/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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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 <root> 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 `<root>/<subdir>` (e.g. `<root>/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 };
|
|
}
|
|
|
|
/** <root>/codex-config-backups/<type>/, always rooted the same way as the read side's <root>. */
|
|
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
|
|
// `<root>/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,
|
|
};
|