fix(codex): close symlink write escape and validate listBackups scope/type

This commit is contained in:
2026-09-08 17:34:10 +07:00
parent c9541dc509
commit f3a2f41ed7
2 changed files with 126 additions and 7 deletions
+82 -4
View File
@@ -127,7 +127,8 @@ describe("codex-mutate", () => {
assert.equal(p.file, path.join(REPO, ".codex", "config.toml"));
});
it("rejects a scope that is neither user nor project", () => {
it("rejects a scope that is neither user nor project, and writes nothing", () => {
const target = path.join(TMP, "rules", "x.rules");
assert.throws(
() =>
cm.writeArtifact({
@@ -139,14 +140,71 @@ describe("codex-mutate", () => {
}),
(e) => e.code === "EBADSCOPE"
);
assert.equal(fs.existsSync(target), false);
});
it("rejects a type outside TYPES", () => {
it("rejects a type outside TYPES, and writes nothing", () => {
const target = path.join(TMP, "commands", "x.md");
assert.throws(
() =>
cm.writeArtifact({ scope: "user", type: "commands", name: "x", content: "x", cwd: REPO }),
(e) => e.code === "EBADTYPE"
);
assert.equal(fs.existsSync(target), false);
});
it("refuses a symlinked skill dir that escapes both Codex roots, and writes nothing outside", () => {
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "codex-mut-outside-"));
const linkPath = path.join(TMP, "skills", "escape-link");
fs.mkdirSync(path.dirname(linkPath), { recursive: true });
fs.symlinkSync(outside, linkPath, "dir");
assert.throws(
() =>
cm.writeArtifact({
scope: "user",
type: "skills",
name: "escape-link",
content: "pwned",
cwd: REPO,
}),
(e) => e.code === "EOUTOFROOT"
);
assert.equal(fs.existsSync(path.join(outside, "SKILL.md")), false);
fs.rmSync(outside, { recursive: true, force: true });
});
it("follows a symlinked skill dir that points back inside the same root", () => {
const realDir = path.join(TMP, "skills", "real-dir");
fs.mkdirSync(realDir, { recursive: true });
const linkPath = path.join(TMP, "skills", "inside-link");
fs.symlinkSync(realDir, linkPath, "dir");
const { file, created } = cm.writeArtifact({
scope: "user",
type: "skills",
name: "inside-link",
content: "fine",
cwd: REPO,
});
assert.equal(created, true);
assert.equal(fs.readFileSync(file, "utf8"), "fine");
assert.equal(fs.readFileSync(path.join(realDir, "SKILL.md"), "utf8"), "fine");
});
it("refuses to back up a symlinked artifact that escapes the roots when deleting it", () => {
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "codex-mut-outside-"));
fs.writeFileSync(path.join(outside, "secret.rules"), "top secret");
const linkPath = path.join(TMP, "rules", "escape-del.rules");
fs.mkdirSync(path.dirname(linkPath), { recursive: true });
fs.symlinkSync(path.join(outside, "secret.rules"), linkPath, "file");
assert.throws(
() => cm.deleteArtifact({ scope: "user", type: "rules", name: "escape-del", cwd: REPO }),
(e) => e.code === "EOUTOFROOT"
);
assert.equal(fs.existsSync(path.join(outside, "secret.rules")), true);
fs.rmSync(outside, { recursive: true, force: true });
});
for (const badName of ["..", ".", "/etc/passwd", "a/b", "a\0b"]) {
@@ -167,7 +225,8 @@ describe("codex-mutate", () => {
});
}
it("rejects a name passed to memory", () => {
it("rejects a name passed to memory, and writes nothing", () => {
const target = path.join(TMP, "AGENTS.md");
assert.throws(
() =>
cm.writeArtifact({
@@ -179,9 +238,12 @@ describe("codex-mutate", () => {
}),
(e) => e.code === "EBADNAME"
);
assert.equal(fs.existsSync(target), false);
});
it("rejects a name passed to config", () => {
it("rejects a name passed to config, and leaves any existing config.toml untouched", () => {
const target = path.join(TMP, "config.toml");
const before = fs.existsSync(target) ? fs.readFileSync(target, "utf8") : null;
assert.throws(
() =>
cm.writeArtifact({
@@ -193,6 +255,8 @@ describe("codex-mutate", () => {
}),
(e) => e.code === "EBADNAME"
);
const after = fs.existsSync(target) ? fs.readFileSync(target, "utf8") : null;
assert.equal(after, before);
});
it("rejects content over the size limit and leaves the existing file untouched", () => {
@@ -234,4 +298,18 @@ describe("codex-mutate", () => {
assert.equal(rows[0].type, "rules");
assert.equal(rows[0].name.startsWith("listed.rules."), true);
});
it("refuses a listBackups scope that is neither user nor project", () => {
assert.throws(
() => cm.listBackups({ scope: "../../../../tmp" }),
(e) => e.code === "EBADSCOPE"
);
});
it("refuses a listBackups type that tries to traverse outside the backups root", () => {
assert.throws(
() => cm.listBackups({ scope: "user", type: "../../../../tmp" }),
(e) => e.code === "EBADTYPE"
);
});
});