fix(codex): stop 502ing empty CLI output, GET /file no longer mkdirs or leaks paths

This commit is contained in:
2026-09-08 20:15:12 +07:00
parent 29ed86d672
commit aca8fd02ee
6 changed files with 82 additions and 14 deletions
+16 -3
View File
@@ -300,6 +300,16 @@ function readOverview(opts = {}) {
* 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.
*
* A GET has no filesystem side effect: `assertNotSymlinkEscape` is called
* with `{create: false}` so it never mkdirs a caller-chosen root, and a
* missing root or file both come back as `{notFound: true}` rather than the
* write side's `EOUTOFROOT`. Failure messages never include the resolved
* path — a `GET /file` response is not the place to reveal what does or
* does not exist outside the root.
*
* @returns {{ok:true,file:string,size:number,mtime:number,truncated:boolean,text:string}
* | {notFound:true} | {error:string}}
*/
function readFileSafe(absPath, opts = {}) {
const codexHome = getCodexHome();
@@ -323,13 +333,16 @@ function readFileSafe(absPath, opts = {}) {
? projectCodexDir
: projectRoot;
try {
assertNotSymlinkEscape(trustedRoot, resolved);
assertNotSymlinkEscape(trustedRoot, resolved, { create: false });
} catch (err) {
return { error: err.message };
// A root that doesn't exist yet (ENOENT from realpathSync, since this
// call never mkdirs it) means there is nothing to find, not a refusal.
if (err.code === "ENOENT") return { notFound: true };
return { error: "path escapes the allowed root" };
}
const r = safeReadText(resolved);
if (!r) return { error: "file not readable" };
if (!r) return { notFound: true };
return { ok: true, file: resolved, ...r };
}
+7 -3
View File
@@ -153,15 +153,19 @@ function rmTreeSync(p) {
*
* `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.
* 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) {
fs.mkdirSync(trustedRoot, { recursive: true });
function assertNotSymlinkEscape(trustedRoot, target, opts = {}) {
if (opts.create !== false) fs.mkdirSync(trustedRoot, { recursive: true });
const realRoot = fs.realpathSync(trustedRoot);
let dir = target;