Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/codex/catalog-write-serialization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
resolveEffectiveUserIdentity,
samePathIdentity,
} from "./user-identity";
import { assertNotRealCodexHomeUnderTest } from "../lib/test-home-guard";

/**
* Authorization to perform the fixed sequence of catalog mutations inside ONE
Expand Down Expand Up @@ -177,6 +178,9 @@ export function withCatalogWriteSerialization<T>(
write: (permit: CatalogWritePermit) => T,
options: CatalogWriteOptions,
): CatalogSerializationOutcome<T> {
// First, ahead of the owner precheck and its diagnostics: a test process never gets a permit for
// the real Codex home, whatever its CODEX_HOME resolved to (#6529).
assertNotRealCodexHomeUnderTest(canonicalCodexHome);
// Re-evaluate on every acquisition; unknown evidence cannot authorize a takeover.
const refusedOwner = ownerRefusal(canonicalCodexHome);
// A precheck refusal may still acquire K for append-only diagnostics; it never gets a permit.
Expand Down
21 changes: 13 additions & 8 deletions src/config/atomic-write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
} from "node:fs";
import { basename, dirname, join } from "node:path";
import { recordOwnedConfigPath } from "../lib/config-ownership";
import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
import { assertNotRealCodexHomeUnderTest, assertNotRealHomeUnderTest } from "../lib/test-home-guard";
import {
forgetEphemeralSecretPath,
hardenSecretPath,
Expand Down Expand Up @@ -114,14 +114,19 @@ export function resolveWriteTarget(path: string): string {
}

function assertResolvedTargetAllowed(path: string, target: string): void {
// Codex-home files (catalog, models cache, journal, config.toml, profile) are addressed through
// CODEX_HOME, which falls back to os.homedir() when a test's teardown deletes it, and os.homedir()
// ignores HOME. That is how test runs rewrote the real catalog (#6529), so every atomic write
// whose directory is the real Codex home is refused under an armed test process.
let realParent: string | null = null;
try {
realParent = realpathSync(dirname(target));
} catch {
realParent = null;
}
assertNotRealCodexHomeUnderTest(realParent ?? dirname(target));
if (target === path) {
let realParent: string;
try {
realParent = realpathSync(dirname(target));
} catch {
return;
}
if (realParent !== dirname(target)) assertNotRealHomeUnderTest(realParent);
if (realParent !== null && realParent !== dirname(target)) assertNotRealHomeUnderTest(realParent);
return;
}
assertNotRealHomeUnderTest(dirname(target));
Expand Down
3 changes: 2 additions & 1 deletion src/lib/test-home-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,8 @@ export function assertNotRealCodexHomeUnderTest(dir: string): void {
if (canonicalize(dir) !== PROTECTED_CODEX_HOME) return;
throw new Error(
`refusing to write the real Codex home (${PROTECTED_CODEX_HOME}) from a test process. `
+ "Point CODEX_HOME at a temp directory for this test before writing native auth.json.",
+ "Point CODEX_HOME at a temp directory for this test before writing Codex files "
+ "(auth.json, the catalog, models_cache.json, the journal or config.toml).",
);
}

Expand Down
77 changes: 77 additions & 0 deletions tests/ci-workflows/test-home-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,83 @@ const canSymlink = (() => {
expect(probe.stdout).not.toContain("WRITE_ALLOWED");
});

test("armed catalog, cache, journal and config writes and K permits reject the protected Codex home (#6529)", async () => {
const probeId = beginProbe("02b-codex-home-files");
const { realHome, codexHome } = sentinelHome();
// CODEX_HOME is deliberately left at the protected home: a teardown that deletes CODEX_HOME sends
// every Codex writer to os.homedir()/.codex, which is how test runs rewrote a live catalog.
const probe = await runProbe(probeId, `
import { join } from "node:path";
import { atomicWriteFile } from "${REPO_ROOT_URL}src/config/atomic-write";
import { withCatalogWriteSerialization } from "${REPO_ROOT_URL}src/codex/catalog-write-serialization";
const home = ${JSON.stringify(codexHome)};
const results = [];
for (const name of ["opencodex-catalog.json", "models_cache.json", "opencodex-journal.json", "config.toml"]) {
try { atomicWriteFile(join(home, name), "{}"); results.push(name + ":WRITTEN"); }
catch (err) { results.push(name + (String(err).includes("refusing to write the real Codex home") ? ":REFUSED" : ":OTHER")); }
}
let ran = false;
try { withCatalogWriteSerialization(home, () => { ran = true; }, { intent: "refresh", writer: "probe" }); results.push("K:GRANTED"); }
catch (err) { results.push(String(err).includes("refusing to write the real Codex home") ? "K:REFUSED" : "K:OTHER"); }
results.push(ran ? "callback:RAN" : "callback:SKIPPED");
console.log(JSON.stringify(results));
`, { OCX_TEST_HOME_GUARD: "1", OCX_REAL_HOME: realHome, CODEX_HOME: codexHome });
Comment thread
coderabbitai[bot] marked this conversation as resolved.

expect(JSON.parse(probe.stdout.trim().split("\n").at(-1) ?? "[]")).toEqual([
"opencodex-catalog.json:REFUSED",
"models_cache.json:REFUSED",
"opencodex-journal.json:REFUSED",
"config.toml:REFUSED",
"K:REFUSED",
"callback:SKIPPED",
]);
for (const name of ["opencodex-catalog.json", "models_cache.json", "opencodex-journal.json", "config.toml"]) {
expect(() => readFileSync(join(codexHome, name))).toThrow();
}
});

test("armed writes reached through the unset-CODEX_HOME fallback reject the protected Codex home (#6529)", async () => {
const probeId = beginProbe("02c-codex-home-fallback");
const { realHome, codexHome } = sentinelHome();
// A Codex home in use (Codex writes sessions/), so the default-home lookup settles on it.
mkdirSync(join(codexHome, "sessions"), { recursive: true });
// The incident path: CODEX_HOME absent, so every writer resolves os.homedir()/.codex.
const probe = await runProbe(probeId, `
import { join } from "node:path";
import { realpathSync } from "node:fs";
import { getCodexHome } from "${REPO_ROOT_URL}src/codex/paths";
import { atomicWriteFile } from "${REPO_ROOT_URL}src/config/atomic-write";
import { withCatalogWriteSerialization } from "${REPO_ROOT_URL}src/codex/catalog-write-serialization";
const home = getCodexHome();
const results = [home === realpathSync(${JSON.stringify(codexHome)}) ? "home:SENTINEL" : "home:OTHER"];
for (const name of ["opencodex-catalog.json", "models_cache.json", "opencodex-journal.json", "config.toml"]) {
try { atomicWriteFile(join(home, name), "{}"); results.push(name + ":WRITTEN"); }
catch (err) { results.push(name + (String(err).includes("refusing to write the real Codex home") ? ":REFUSED" : ":OTHER")); }
}
try { withCatalogWriteSerialization(home, () => null, { intent: "refresh", writer: "probe" }); results.push("K:GRANTED"); }
catch (err) { results.push(String(err).includes("refusing to write the real Codex home") ? "K:REFUSED" : "K:OTHER"); }
console.log(JSON.stringify(results));
`, {
OCX_TEST_HOME_GUARD: "1",
OCX_REAL_HOME: realHome,
HOME: realHome,
USERPROFILE: realHome,
CODEX_HOME: undefined,
});

expect(JSON.parse(probe.stdout.trim().split("\n").at(-1) ?? "[]")).toEqual([
"home:SENTINEL",
"opencodex-catalog.json:REFUSED",
"models_cache.json:REFUSED",
"opencodex-journal.json:REFUSED",
"config.toml:REFUSED",
"K:REFUSED",
]);
for (const name of ["opencodex-catalog.json", "models_cache.json", "opencodex-journal.json", "config.toml"]) {
expect(() => readFileSync(join(codexHome, name))).toThrow();
}
});

test.skipIf(!canSymlink)("armed + a symlink escaping a temp home into the protected home: refused", async () => {
const probeId = beginProbe("03-symlink-file");
// Atomic writes resolve their destination through symlinks, so a temp home whose
Expand Down
Loading