Files
OmniRoute/tests/unit/cli-config-home-container.test.ts
SB Yoon d46e8d72c9 feat(cli): refuse ephemeral container auto-config writes (#10057)
* feat(cli): refuse ephemeral container auto-config writes

Detect containerized OmniRoute and block CLI/API config writes into
throwaway homes unless a bind mount or explicit opt-in is present, and
honor compose host-profile CLI_CONFIG_HOME mounts outside the container home.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(changelog): name fragment for #10057

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: yansigit <yansigit@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
2026-08-16 00:42:14 -03:00

150 lines
6.0 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
// The compose `host` profile mounts the operator's real config dirs at
// /host-home, which sits OUTSIDE the container user's home (/home/node). Before
// this fix getCliConfigHome() silently dropped that override and every write
// landed back in the ephemeral container home. See docker-compose.yml.
const modulePath = path.join(process.cwd(), "src/shared/services/cliRuntime.ts");
const originalEnv = { ...process.env };
async function importFresh(label: string) {
return import(`${pathToFileURL(modulePath).href}?case=${label}-${Date.now()}-${Math.random()}`);
}
function restoreEnv() {
for (const key of Object.keys(process.env)) {
if (!(key in originalEnv)) delete process.env[key];
}
Object.assign(process.env, originalEnv);
}
test.afterEach(restoreEnv);
// Deps are injected because CI and dev machines are not containers and macOS
// has no /proc/self/mountinfo at all.
const HOST_PROFILE_MOUNTINFO = [
"31 28 254:1 /volumes/omniroute-data/_data /app/data rw,relatime - ext4 /dev/vda1 rw",
"44 28 254:1 /Users/me/.codex /host-home/.codex rw,relatime - ext4 /dev/vda1 rw",
"45 28 254:1 /Users/me/.claude /host-home/.claude rw,relatime - ext4 /dev/vda1 rw",
].join("\n");
const containerDeps = {
existsSync: (p: string) => p === "/.dockerenv",
readFileSync: (p: string, _enc: string) => {
if (p === "/proc/self/mountinfo") return HOST_PROFILE_MOUNTINFO;
if (p === "/proc/1/cgroup") return "12:cpuset:/docker/abc\n";
throw new Error("ENOENT");
},
env: {} as NodeJS.ProcessEnv,
};
const hostDeps = {
existsSync: (_p: string) => false,
readFileSync: (_p: string, _enc: string) => "12:cpuset:/\n",
env: {} as NodeJS.ProcessEnv,
};
test("container + bind-mounted CLI_CONFIG_HOME outside home is honoured", async () => {
const cliRuntime = await importFresh("container-mounted");
process.env.CLI_CONFIG_HOME = "/host-home";
assert.equal(cliRuntime.getCliConfigHome(containerDeps), "/host-home");
});
test("container + unmounted CLI_CONFIG_HOME outside home still falls back", async () => {
const cliRuntime = await importFresh("container-unmounted");
process.env.CLI_CONFIG_HOME = "/opt/not-mounted";
assert.equal(cliRuntime.getCliConfigHome(containerDeps), os.homedir());
});
test("host machine keeps rejecting an outside-home CLI_CONFIG_HOME", async () => {
const cliRuntime = await importFresh("host-outside");
process.env.CLI_CONFIG_HOME = "/tmp/outside-home";
assert.equal(cliRuntime.getCliConfigHome(hostDeps), os.homedir());
// ...and with the real (non-container) environment too.
assert.equal(cliRuntime.getCliConfigHome(), os.homedir());
});
test("container exception does not bypass the other CLI_CONFIG_HOME guards", async () => {
const cliRuntime = await importFresh("container-guards");
const home = os.homedir();
process.env.CLI_CONFIG_HOME = "relative/host-home";
assert.equal(cliRuntime.getCliConfigHome(containerDeps), home, "relative paths rejected");
process.env.CLI_CONFIG_HOME = "/host-home/../etc";
assert.equal(cliRuntime.getCliConfigHome(containerDeps), home, "traversal rejected");
process.env.CLI_CONFIG_HOME = "/host-home;rm -rf /";
assert.equal(cliRuntime.getCliConfigHome(containerDeps), home, "metacharacters rejected");
});
test("an in-home CLI_CONFIG_HOME is unaffected by container detection", async () => {
const cliRuntime = await importFresh("in-home");
const safe = path.join(os.homedir(), "tmp-cli-config-home");
process.env.CLI_CONFIG_HOME = safe;
assert.equal(cliRuntime.getCliConfigHome(containerDeps), safe);
assert.equal(cliRuntime.getCliConfigHome(hostDeps), safe);
});
// ── ensureCliConfigWriteAllowed ──────────────────────────────────────────────
test("ensureCliConfigWriteAllowed without a path keeps flag-only behavior", async () => {
const cliRuntime = await importFresh("gate-flag-only");
assert.equal(cliRuntime.ensureCliConfigWriteAllowed(), null);
process.env.CLI_ALLOW_CONFIG_WRITES = "false";
assert.match(cliRuntime.ensureCliConfigWriteAllowed(), /CLI_ALLOW_CONFIG_WRITES=false/);
});
test("ensureCliConfigWriteAllowed refuses an ephemeral container target", async () => {
const cliRuntime = await importFresh("gate-ephemeral");
const message = cliRuntime.ensureCliConfigWriteAllowed("/home/node/.codex", { containerDeps });
assert.ok(message, "expected a refusal");
assert.match(message, /Refusing to write/);
assert.match(message, /\/home\/node\/\.codex/);
assert.match(message, /omniroute connect/);
assert.match(message, /CLI_CONFIG_HOME=\/host-home/);
});
test("ensureCliConfigWriteAllowed allows a bind-mounted container target", async () => {
const cliRuntime = await importFresh("gate-mounted");
assert.equal(
cliRuntime.ensureCliConfigWriteAllowed("/host-home/.codex/config.toml", { containerDeps }),
null
);
});
test("ensureCliConfigWriteAllowed allows any target on a host", async () => {
const cliRuntime = await importFresh("gate-host");
assert.equal(
cliRuntime.ensureCliConfigWriteAllowed(path.join(os.homedir(), ".codex"), {
containerDeps: hostDeps,
}),
null
);
});
test("OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE overrides the container refusal", async () => {
const cliRuntime = await importFresh("gate-override");
process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE = "true";
assert.equal(
cliRuntime.ensureCliConfigWriteAllowed("/home/node/.codex", { containerDeps }),
null
);
});
test("the write-disabled flag still wins over the container override", async () => {
const cliRuntime = await importFresh("gate-precedence");
process.env.CLI_ALLOW_CONFIG_WRITES = "false";
process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE = "true";
assert.match(
cliRuntime.ensureCliConfigWriteAllowed("/home/node/.codex", { containerDeps }),
/CLI_ALLOW_CONFIG_WRITES=false/
);
});