Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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