fix(codex): close symlink escape on GET /file, pin project writes to server cwd

This commit is contained in:
2026-09-08 20:07:57 +07:00
parent 32839332f2
commit 29ed86d672
7 changed files with 246 additions and 89 deletions
+30 -67
View File
@@ -20,14 +20,10 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { Router } = require("express");
const codexDiscovery = require("../lib/codex-discovery");
const codexMutate = require("../lib/codex-mutate");
const codexCli = require("../lib/codex-cli");
const { getCodexHome, getProjectCodexDir, getProjectRoot } = require("../lib/codex-home");
const { isUnder, MAX_FILE_BYTES } = require("../lib/cc-discovery");
const { broadcast } = require("../websocket");
const router = Router();
@@ -100,53 +96,17 @@ router.get("/config", (req, res) => {
// GET /api/codex-config/file?path=/abs/path — return body of a single file.
// Path must resolve under CODEX_HOME, the project .codex dir, or (only for a
// file literally named AGENTS.md) the project root. No dedicated reader for
// this exists in codex-discovery.js (it only reads whole surfaces), so this
// mirrors cc-discovery's readFileSafe against the Codex roots instead.
function readCodexFileSafe(absPath, opts = {}) {
const codexHome = getCodexHome();
const projectCodexDir = getProjectCodexDir(opts.cwd);
const projectRoot = getProjectRoot(opts.cwd);
const resolved = path.resolve(absPath);
const inside = [codexHome, projectCodexDir, projectRoot].some((root) => isUnder(root, resolved));
if (!inside) return { error: "path is outside allowed roots" };
if (
isUnder(projectRoot, resolved) &&
!isUnder(projectCodexDir, resolved) &&
path.basename(resolved) !== "AGENTS.md"
) {
return { error: "only AGENTS.md is readable from project root" };
}
let stat;
try {
stat = fs.statSync(resolved);
} catch {
return { error: "file not readable" };
}
if (!stat.isFile()) return { error: "file not readable" };
let text;
try {
text = fs.readFileSync(resolved, "utf8");
} catch {
return { error: "file not readable" };
}
const truncated = stat.size > MAX_FILE_BYTES;
return {
ok: true,
file: resolved,
size: stat.size,
mtime: stat.mtimeMs,
truncated,
text: truncated ? text.slice(0, MAX_FILE_BYTES) : text,
};
}
// file literally named AGENTS.md) the project root. codexDiscovery.readFileSafe
// re-checks containment with the same leaf/ancestor symlink-escape guard the
// write side uses (codex-mutate.js's assertNotSymlinkEscape) — a plain
// string-only path check would let a symlink dropped under the root read
// arbitrary files outside it.
router.get("/file", (req, res) => {
const p = req.query.path;
if (typeof p !== "string" || !p) {
return res.status(400).json({ error: { code: "BAD_PATH", message: "path is required" } });
}
const result = readCodexFileSafe(p, { cwd: cwdOf(req) });
const result = codexDiscovery.readFileSafe(p, { cwd: cwdOf(req) });
if (result.error)
return res.status(400).json({ error: { code: "READ_DENIED", message: result.error } });
res.json(result);
@@ -157,6 +117,11 @@ router.get("/file", (req, res) => {
// PUT /api/codex-config/file — create or overwrite. Body: { scope, type, name?, content }
// DELETE /api/codex-config/file — delete. Body: { scope, type, name? }
// GET /api/codex-config/backups[?scope=&type=] — list backups
//
// Writes resolve "project" scope against the SERVER's cwd, never `?cwd=`: a
// write target chosen by the caller would let any request create a file
// anywhere on disk (same reasoning cc-config.js's PATCH /settings gives for
// refusing `?cwd=` there). Reads keep the override.
router.put("/file", (req, res) => {
const { scope, type, name, content } = req.body || {};
@@ -166,7 +131,7 @@ router.put("/file", (req, res) => {
.json({ error: { code: "EBADREQ", message: "scope and type are required" } });
}
try {
const result = codexMutate.writeArtifact({ scope, type, name, content, cwd: cwdOf(req) });
const result = codexMutate.writeArtifact({ scope, type, name, content, cwd: process.cwd() });
emitChanged({ action: "write", scope, type, name: name || null });
res.json(result);
} catch (err) {
@@ -182,7 +147,7 @@ router.delete("/file", (req, res) => {
.json({ error: { code: "EBADREQ", message: "scope and type are required" } });
}
try {
const result = codexMutate.deleteArtifact({ scope, type, name, cwd: cwdOf(req) });
const result = codexMutate.deleteArtifact({ scope, type, name, cwd: process.cwd() });
emitChanged({ action: "delete", scope, type, name: name || null });
res.json(result);
} catch (err) {
@@ -221,28 +186,12 @@ function cliError(res, err) {
.json({ error: { code: err.code || "EINTERNAL", message: err.message } });
}
/**
* Best-effort human message from a failed run. codex-cli.js does not export
* one (unlike cc-plugin-cli's cliMessage), so this route-local helper does
* the same "last non-empty line, prefer a marked one" heuristic.
*/
function cliMessage(result) {
const text = `${result.stderr || ""}\n${result.stdout || ""}`;
const lines = text
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
const marked = lines.find((l) => l.includes("✘") || l.toLowerCase().includes("error"));
const picked = marked || lines[lines.length - 1] || "";
return picked.replace(/^✘\s*/, "").slice(0, 1000);
}
/** A non-zero CLI exit is a 422, not a 500 — the caller can inspect stderr. */
function cliFailure(res, result) {
return res.status(422).json({
error: {
code: "ECLIEXIT",
message: cliMessage(result) || `codex exited with code ${result.code}`,
message: codexCli.cliMessage(result) || `codex exited with code ${result.code}`,
},
code: result.code,
stdout: result.stdout,
@@ -250,10 +199,24 @@ function cliFailure(res, result) {
});
}
/** Read-through result for a GET route backed by the CLI. */
/**
* Read-through result for a GET route backed by `codex … --json`. `result.data`
* is only set when stdout parsed as JSON (see runCodex) — an ok:true result
* with unparseable stdout means the CLI's output shape changed under us, so
* this is a 502, not a 200 with a string where the OpenAPI fragment promises
* an array/object.
*/
function cliRead(res, result, key) {
if (!result.ok) return cliFailure(res, result);
res.json({ [key]: result.data !== undefined ? result.data : result.stdout });
if (result.data === undefined) {
return res.status(502).json({
error: {
code: "ECLIBADOUTPUT",
message: `could not parse \`codex\` JSON output for ${key}`,
},
});
}
res.json({ [key]: result.data });
}
/** Result for a mutating route: broadcast on success, 422 on CLI failure. */