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
+22
View File
@@ -199,6 +199,10 @@ async function mcpAdd({ name, url, bearerTokenEnvVar, command, args = [], env })
if (!url && !command) {
throw makeError("EBADARG", "mcpAdd requires either url or command");
}
if (!Array.isArray(args)) throw makeError("EBADARG", "args must be an array");
if (env !== undefined && (typeof env !== "object" || env === null || Array.isArray(env))) {
throw makeError("EBADARG", "env must be an object");
}
if (url) {
if (env) throw makeError("EBADARG", "env is not valid together with url (stdio-only)");
@@ -256,8 +260,26 @@ async function marketplaceRemove(name) {
return serialize(() => runCodex(["plugin", "marketplace", "remove", n]));
}
/**
* Best-effort human message from a failed run. The CLI prints its errors with
* a `✘` marker on either stream; fall back to the last non-empty line.
* Unchanged copy of cc-plugin-cli.js's cliMessage — same CLI-output shape,
* same heuristic.
*/
function cliMessage(res) {
const text = `${res.stderr || ""}\n${res.stdout || ""}`;
const lines = text
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
const marked = lines.find((l) => l.includes("✘"));
const picked = marked || lines[lines.length - 1] || "";
return picked.replace(/^✘\s*/, "").slice(0, 1000);
}
module.exports = {
runCodex,
cliMessage,
mcpList,
mcpAdd,
mcpRemove,
+48
View File
@@ -22,6 +22,11 @@ const {
isFileLike,
MAX_FILE_BYTES,
} = require("./cc-discovery");
// Shared symlink-escape guard — the same check writeArtifact/deleteArtifact
// use, so a read and a write of the same path agree on what "inside the
// root" means. See codex-mutate.js's assertNotSymlinkEscape for the leaf +
// ancestor symlink reasoning.
const { assertNotSymlinkEscape } = require("./codex-mutate");
function safeReadText(absPath) {
try {
@@ -286,6 +291,48 @@ function readOverview(opts = {}) {
};
}
// ── Single-file body endpoint (with strict path containment) ───────────
/**
* Read the body of one file living under CODEX_HOME, the project .codex
* dir, or (only for a file literally named AGENTS.md) the project root.
* Mirrors cc-discovery's readFileSafe, plus the same leaf/ancestor
* symlink-escape guard the write side (codex-mutate.js) uses — a string-only
* `isUnder` check alone would let a symlink dropped anywhere along the path
* read arbitrary files outside the root.
*/
function readFileSafe(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" };
}
const trustedRoot = isUnder(codexHome, resolved)
? codexHome
: isUnder(projectCodexDir, resolved)
? projectCodexDir
: projectRoot;
try {
assertNotSymlinkEscape(trustedRoot, resolved);
} catch (err) {
return { error: err.message };
}
const r = safeReadText(resolved);
if (!r) return { error: "file not readable" };
return { ok: true, file: resolved, ...r };
}
module.exports = {
readSkills,
readAgents,
@@ -293,5 +340,6 @@ module.exports = {
readMemory,
readConfig,
readOverview,
readFileSafe,
readTomlMeta,
};
+1
View File
@@ -300,6 +300,7 @@ module.exports = {
deleteArtifact,
listBackups,
resolveTarget, // exported for tests
assertNotSymlinkEscape, // shared with codex-discovery.js's readFileSafe — one check, both verbs
TYPES,
NAME_RE,
};