fix(codex-config): close whole-branch review defects in the Codex panel

Plugins/marketplaces GET routes were silently empty because codex-cli.js
never unwrapped the CLI's {installed,available}/{marketplaces} objects
into arrays; backup writes bypassed the symlink-escape guard entirely;
readFileSafe was stricter than every other Codex reader, showing a
symlinked skill/rule in the list and then refusing to open it;
marketplaceAdd only accepted http(s) URLs, rejecting owner/repo sources
the CLI itself takes. Also: docs drift, a dead i18n key, and a scope-cast
hazard in the shared Editor/ConfirmDelete modals.

Fixes, one per finding: unwrap installed/marketplaces in codex-cli.js so
every list route keeps returning a bare list; guard the backup path in
codex-mutate.js with the same assertNotSymlinkEscape the write path
already used (Claude's cc-mutate.js has no such guard on its primary
write path either, so left untouched there); relax readFileSafe's
containment to lexical-only, matching the other four Codex readers,
while leaving the write side's realpath check untouched; reuse
cc-plugin-cli's assertSource for marketplaceAdd instead of a third
validator (ssh remains out of scope, no credential story for it yet).

Server: 1557/1557 (was 1548). Client: tsc -b clean, 469/469 tests.
This commit is contained in:
2026-09-09 01:17:06 +07:00
parent fef3e640d0
commit acff059e92
16 changed files with 359 additions and 108 deletions
+54
View File
@@ -266,6 +266,60 @@ describe("codex-mutate", () => {
}
});
it("refuses to write (and back up) when <root>/codex-config-backups is a symlink escaping the root", () => {
cm.writeArtifact({ scope: "user", type: "rules", name: "toclobber", content: "v1", cwd: REPO });
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "codex-mut-outside-"));
const backupsLink = path.join(TMP, "codex-config-backups");
fs.rmSync(backupsLink, { recursive: true, force: true });
fs.symlinkSync(outside, backupsLink, "dir");
try {
assert.throws(
() =>
cm.writeArtifact({
scope: "user",
type: "rules",
name: "toclobber",
content: "v2 — attacker wants this in `outside`",
cwd: REPO,
}),
(e) => e.code === "EOUTOFROOT"
);
assert.equal(fs.readdirSync(outside).length, 0, "backup must not land outside the root");
} finally {
fs.rmSync(backupsLink, { force: true });
fs.rmSync(outside, { recursive: true, force: true });
}
});
it("refuses to delete (and back up) when <root>/codex-config-backups is a symlink escaping the root", () => {
cm.writeArtifact({
scope: "user",
type: "rules",
name: "todelete2",
content: "keep-me",
cwd: REPO,
});
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "codex-mut-outside-"));
const backupsLink = path.join(TMP, "codex-config-backups");
fs.rmSync(backupsLink, { recursive: true, force: true });
fs.symlinkSync(outside, backupsLink, "dir");
try {
assert.throws(
() => cm.deleteArtifact({ scope: "user", type: "rules", name: "todelete2", cwd: REPO }),
(e) => e.code === "EOUTOFROOT"
);
assert.equal(fs.readdirSync(outside).length, 0, "backup must not land outside the root");
assert.equal(
fs.readFileSync(path.join(TMP, "rules", "todelete2.rules"), "utf8"),
"keep-me",
"the original file must survive a refused backup"
);
} finally {
fs.rmSync(backupsLink, { force: true });
fs.rmSync(outside, { recursive: true, force: true });
}
});
for (const badName of ["..", ".", "/etc/passwd", "a/b", "a\0b"]) {
it(`rejects the bad name ${JSON.stringify(badName)} with EBADNAME and writes nothing`, () => {
const target = path.join(TMP, "agents", `${badName.replace(/[/\0]/g, "_")}.toml`);