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:
@@ -72,7 +72,7 @@ describe("codex-cli argument validation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts only http(s) URLs", () => {
|
||||
it("accepts only http(s) URLs (mcpAdd's --url form)", () => {
|
||||
assert.equal(cli.assertUrl("https://example.com/mp.git", "url"), "https://example.com/mp.git");
|
||||
for (const bad of ["ssh://example.com/mp.git", "not a url", "file:///tmp/x"]) {
|
||||
assert.throws(() => cli.assertUrl(bad, "url"), /http\(s\)/, `accepted ${bad}`);
|
||||
@@ -81,6 +81,32 @@ describe("codex-cli argument validation", () => {
|
||||
assert.throws(() => cli.assertUrl("--force", "url"), /may not start with/);
|
||||
});
|
||||
|
||||
it("marketplaceAdd accepts the source forms `codex plugin marketplace add` actually takes", async () => {
|
||||
// Not assertUrl: `codex plugin marketplace add <SOURCE>` also takes
|
||||
// owner/repo and a local path, which a bare http(s)-only check rejected.
|
||||
const restore = stubCodex();
|
||||
try {
|
||||
assert.deepEqual(await cli.marketplaceAdd("openai/codex").then(argvOf), [
|
||||
"plugin",
|
||||
"marketplace",
|
||||
"add",
|
||||
"openai/codex",
|
||||
]);
|
||||
assert.deepEqual(await cli.marketplaceAdd("/abs/local/path").then(argvOf), [
|
||||
"plugin",
|
||||
"marketplace",
|
||||
"add",
|
||||
"/abs/local/path",
|
||||
]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
// ssh remotes are deliberately out of scope, same as cc-plugin-cli's
|
||||
// marketplace source guard — no credential/host-key story for them yet.
|
||||
await assert.rejects(() => cli.marketplaceAdd("git@github.com:owner/repo.git"), /source/);
|
||||
await assert.rejects(() => cli.marketplaceAdd("../escape"), /source/);
|
||||
});
|
||||
|
||||
it("rejects mcpAdd with neither or both of url/command", async () => {
|
||||
await assert.rejects(() => cli.mcpAdd({ name: "docs" }), /requires either/);
|
||||
await assert.rejects(
|
||||
@@ -267,6 +293,31 @@ describe("codex-cli run outcomes", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("unwraps `codex plugin list --json`'s {installed, available} object into a bare array", async () => {
|
||||
// Real codex-cli 0.149.0 shape — an object, unlike `mcp list`'s array.
|
||||
const restore = stubCodex(
|
||||
`cat <<'JSON'\n{"installed":[{"name":"demo"}],"available":[{"name":"other"}]}\nJSON`
|
||||
);
|
||||
try {
|
||||
const res = await cli.pluginList();
|
||||
assert.equal(res.ok, true);
|
||||
assert.deepEqual(res.data, [{ name: "demo" }]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("unwraps `codex plugin marketplace list --json`'s {marketplaces} object into a bare array", async () => {
|
||||
const restore = stubCodex(`cat <<'JSON'\n{"marketplaces":[{"name":"sg"}]}\nJSON`);
|
||||
try {
|
||||
const res = await cli.marketplaceList();
|
||||
assert.equal(res.ok, true);
|
||||
assert.deepEqual(res.data, [{ name: "sg" }]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves `data` undefined when stdout is not JSON", async () => {
|
||||
const restore = stubCodex('echo "removed docs"');
|
||||
try {
|
||||
|
||||
@@ -211,36 +211,50 @@ describe("/api/codex-config", () => {
|
||||
assert.equal(body.error.code, "READ_DENIED");
|
||||
});
|
||||
|
||||
it("file endpoint refuses a leaf symlink that escapes CODEX_HOME (no secret leaks)", async () => {
|
||||
it("file endpoint blocks a ?cwd=-driven escape (path outside the roots ?cwd= computes)", async () => {
|
||||
const otherCwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-config-othercwd-"));
|
||||
const { status, body } = await fetchJson(
|
||||
`/api/codex-config/file?cwd=${encodeURIComponent(otherCwd)}&path=${encodeURIComponent(
|
||||
path.join(FAKE_HOME, "..", "..", "etc", "passwd")
|
||||
)}`
|
||||
);
|
||||
assert.equal(status, 400);
|
||||
assert.equal(body.error.code, "READ_DENIED");
|
||||
fs.rmSync(otherCwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("file endpoint follows a leaf symlink whose LOCATION is inside CODEX_HOME (dotfiles setup)", async () => {
|
||||
// Ruling: containment applies to the link's location, not where it
|
||||
// points — the same thing the other four discovery readers already do.
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "codex-config-outside-"));
|
||||
fs.writeFileSync(path.join(outside, "secret.rules"), "TOP SECRET LEAF");
|
||||
fs.writeFileSync(path.join(outside, "secret.rules"), "MY DOTFILES RULE");
|
||||
const linkPath = path.join(FAKE_HOME, "rules", "leaf-link.rules");
|
||||
fs.symlinkSync(path.join(outside, "secret.rules"), linkPath, "file");
|
||||
try {
|
||||
const { status, body } = await fetchJson(
|
||||
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}&path=${encodeURIComponent(linkPath)}`
|
||||
);
|
||||
assert.equal(status, 400);
|
||||
assert.equal(body.error.code, "READ_DENIED");
|
||||
assert.doesNotMatch(JSON.stringify(body), /TOP SECRET LEAF/);
|
||||
assert.equal(status, 200);
|
||||
assert.equal(body.ok, true);
|
||||
assert.equal(body.text, "MY DOTFILES RULE");
|
||||
} finally {
|
||||
fs.rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("file endpoint refuses an ancestor symlink that escapes CODEX_HOME (no secret leaks)", async () => {
|
||||
it("file endpoint follows an ancestor symlink whose LOCATION is inside CODEX_HOME", async () => {
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "codex-config-outside-"));
|
||||
fs.writeFileSync(path.join(outside, "secret.txt"), "TOP SECRET ANCESTOR");
|
||||
const linkDir = path.join(FAKE_HOME, "evil-dir");
|
||||
fs.writeFileSync(path.join(outside, "linked.txt"), "MY DOTFILES ANCESTOR");
|
||||
const linkDir = path.join(FAKE_HOME, "linked-dir");
|
||||
fs.symlinkSync(outside, linkDir, "dir");
|
||||
try {
|
||||
const target = path.join(linkDir, "secret.txt");
|
||||
const target = path.join(linkDir, "linked.txt");
|
||||
const { status, body } = await fetchJson(
|
||||
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}&path=${encodeURIComponent(target)}`
|
||||
);
|
||||
assert.equal(status, 400);
|
||||
assert.equal(body.error.code, "READ_DENIED");
|
||||
assert.doesNotMatch(JSON.stringify(body), /TOP SECRET ANCESTOR/);
|
||||
assert.equal(status, 200);
|
||||
assert.equal(body.ok, true);
|
||||
assert.equal(body.text, "MY DOTFILES ANCESTOR");
|
||||
} finally {
|
||||
fs.rmSync(linkDir, { force: true });
|
||||
fs.rmSync(outside, { recursive: true, force: true });
|
||||
@@ -470,6 +484,33 @@ describe("/api/codex-config", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("GET /plugins unwraps the CLI's {installed, available} object into a bare array", async () => {
|
||||
// Real codex-cli 0.149.0 shape: `codex plugin list --json` returns an
|
||||
// object, not an array like `mcp list` — without the unwrap the Plugins
|
||||
// tab silently showed nothing (Array.isArray(res.plugins) was false).
|
||||
const restore = stubCodex(
|
||||
`cat <<'JSON'\n{"installed":[{"name":"demo"}],"available":[{"name":"other"}]}\nJSON`
|
||||
);
|
||||
try {
|
||||
const { status, body } = await fetchJson("/api/codex-config/plugins");
|
||||
assert.equal(status, 200);
|
||||
assert.deepEqual(body.plugins, [{ name: "demo" }]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("GET /marketplaces unwraps the CLI's {marketplaces} object into a bare array", async () => {
|
||||
const restore = stubCodex(`cat <<'JSON'\n{"marketplaces":[{"name":"sg"}]}\nJSON`);
|
||||
try {
|
||||
const { status, body } = await fetchJson("/api/codex-config/marketplaces");
|
||||
assert.equal(status, 200);
|
||||
assert.deepEqual(body.marketplaces, [{ name: "sg" }]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("GET /mcp|/plugins|/marketplaces treat empty stdout as an empty list (fresh-install case), not a 502", async () => {
|
||||
const restore = stubCodex(""); // exits 0, prints nothing — the normal fresh-install state
|
||||
try {
|
||||
@@ -589,7 +630,26 @@ describe("/api/codex-config", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("POST /marketplaces/add rejects a non-http(s) url", async () => {
|
||||
it("POST /marketplaces/add accepts an owner/repo source (not just http(s) urls)", async () => {
|
||||
const restore = stubCodex(ECHO_ARGV);
|
||||
try {
|
||||
const { status, body } = await fetchJson("/api/codex-config/marketplaces/add", {
|
||||
method: "POST",
|
||||
body: { url: "openai/codex" },
|
||||
});
|
||||
assert.equal(status, 200);
|
||||
assert.deepEqual(body.stdout.trim().split("\n"), [
|
||||
"plugin",
|
||||
"marketplace",
|
||||
"add",
|
||||
"openai/codex",
|
||||
]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("POST /marketplaces/add rejects a source that is none of path/owner-repo/http(s) url", async () => {
|
||||
const { status, body } = await fetchJson("/api/codex-config/marketplaces/add", {
|
||||
method: "POST",
|
||||
body: { url: "not a url" },
|
||||
|
||||
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user