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
71 changes: 60 additions & 11 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ import { assertNotRealHomeUnderTest } from "./lib/test-home-guard";
import { isLocalAttestationSecret } from "./lib/local-management-attestation";
import { providerDestinationConfigError } from "./lib/destination-policy";
import { redactSecretString } from "./lib/redact";
import {
resolveTrustedWindowsPowerShellExe,
resolveTrustedWindowsSystemDirectory,
} from "./lib/windows-elevation";
import { openRouterRoutingConfigError } from "./providers/openrouter-routing";
import {
isWirePinnedModel,
Expand Down Expand Up @@ -3070,14 +3074,51 @@ export function verifyPidIdentity(candidatePid: number): number | null {
return isLikelyOcxStartProcess(candidatePid) ? candidatePid : null;
}

type ProcessCommandLineExec = (
executable: string,
args: string[],
options: {
encoding: BufferEncoding;
stdio: ["ignore", "pipe", "ignore"];
timeout: number;
windowsHide: boolean;
},
) => string;

const defaultProcessCommandLineExec: ProcessCommandLineExec = (executable, args, options) =>
execFileSync(executable, args, options);
let processCommandLineExec = defaultProcessCommandLineExec;
let processCommandLinePlatformForTests: NodeJS.Platform | null = null;

/** Test-only seam for verifying the exact system executable selected by pid identity probes. */
export function setProcessCommandLineExecForTests(next: ProcessCommandLineExec | null): void {
processCommandLineExec = next ?? defaultProcessCommandLineExec;
}

/** Test-only seam so cross-platform tests do not mutate process.platform. */
export function setProcessCommandLinePlatformForTests(next: NodeJS.Platform | null): void {
processCommandLinePlatformForTests = next;
}

function readProcessCommandLine(pid: number): string | undefined {
if (!Number.isSafeInteger(pid) || pid <= 0) return undefined;
const platform = processCommandLinePlatformForTests ?? process.platform;
try {
if (process.platform === "win32") {
if (platform === "linux") {
try {
const output = readFileSync(`/proc/${pid}/cmdline`, "utf-8");
const value = output.replace(/\0/g, " ").trim();
if (value) return value;
} catch {
/* procfs unavailable — use the fixed ps fallback below */
}
}
if (platform === "win32") {
// Prefer WMIC over PowerShell: much faster cold start, and windowsHide avoids console flash.
// Fall back to PowerShell when WMIC is absent (newer Windows images).
const wmic = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\wbem\\WMIC.exe`;
const wmic = join(resolveTrustedWindowsSystemDirectory(), "wbem", "WMIC.exe");
try {
const output = execFileSync(wmic, [
const output = processCommandLineExec(wmic, [
"process", "where", `ProcessId=${pid}`, "get", "CommandLine", "/VALUE",
], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000, windowsHide: true });
const match = /^CommandLine=(.*)$/m.exec(output.replace(/\r/g, ""));
Expand All @@ -3086,7 +3127,7 @@ function readProcessCommandLine(pid: number): string | undefined {
} catch {
/* WMIC missing or failed — fall through */
}
const output = execFileSync("powershell.exe", [
const output = processCommandLineExec(resolveTrustedWindowsPowerShellExe(), [
"-NoProfile",
"-NoLogo",
"-NonInteractive",
Expand All @@ -3097,13 +3138,21 @@ function readProcessCommandLine(pid: number): string | undefined {
], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000, windowsHide: true });
return output.trim() || undefined;
}
const output = execFileSync("ps", ["-p", String(pid), "-o", "command="], {
encoding: "utf-8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 1000,
windowsHide: true,
});
return output.trim() || undefined;
for (const ps of ["/bin/ps", "/usr/bin/ps"]) {
try {
const output = processCommandLineExec(ps, ["-p", String(pid), "-o", "command="], {
encoding: "utf-8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 1000,
windowsHide: true,
});
const value = output.trim();
if (value) return value;
} catch {
/* try the other fixed system path */
}
}
return undefined;
} catch {
return undefined;
}
Expand Down
99 changes: 98 additions & 1 deletion tests/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, symlinkSync, truncateSync, unlinkSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { delimiter, dirname, join, resolve } from "node:path";
import {
CODEX_SHIM_AUTO_RESTORE_ENV,
codexAutoStartEnabled,
Expand All @@ -18,15 +18,21 @@ import {
positiveIntegerConfigError,
positiveIntegerRecordConfigError,
readConfigDiagnostics,
readPid,
readRuntimePort,
removePid,
removeRuntimePort,
ocxStartProcessCacheSizeForTests,
setOcxStartProcessCacheForTests,
setProcessCommandLineExecForTests,
setProcessCommandLinePlatformForTests,
validateConfigCandidate,
writeRuntimePort,
writePid,
} from "../src/config";

import * as windowsAcl from "../src/lib/windows-secret-acl";
import { setTrustedWindowsSystemDirectoryResolverForTests } from "../src/lib/windows-elevation";
import { AtomicWriteResidualTempError, atomicWriteFile, atomicWriteFileAsync, hardenConfigDir, hardenExistingSecret, renameAtomicFile, saveConfig } from "../src/config";
let testDir = "";

Expand Down Expand Up @@ -1830,6 +1836,97 @@ describe("opencodex config defaults", () => {
expect(readFileSync(getPidPath(), "utf-8")).toBe(String(process.pid));
});

test("pid validation does not execute ps from PATH", () => {
const attackerDir = join(testDir, "attacker-bin");
const fakePs = join(attackerDir, "ps");
const markerPath = `${fakePs}.executed`;
const previousPath = process.env.PATH;
const probes: string[] = [];
mkdirSync(attackerDir);
writeFileSync(fakePs, `#!/bin/sh\ntouch "$0.executed"\necho 'ocx start'\n`, { mode: 0o755 });

setOcxStartProcessCacheForTests([]);
try {
setProcessCommandLinePlatformForTests("darwin");
setProcessCommandLineExecForTests((executable) => {
probes.push(executable);
throw new Error("fixed ps probe unavailable");
});
process.env.PATH = `${attackerDir}${delimiter}${previousPath ?? ""}`;
writePid(process.pid);

expect(readPid()).toBeNull();
expect(probes).toEqual(["/bin/ps", "/usr/bin/ps"]);
expect(existsSync(markerPath)).toBe(false);
} finally {
setProcessCommandLineExecForTests(null);
setProcessCommandLinePlatformForTests(null);
if (previousPath === undefined) delete process.env.PATH;
else process.env.PATH = previousPath;
setOcxStartProcessCacheForTests([]);
}

expect(process.env.PATH).toBe(previousPath);
expect(ocxStartProcessCacheSizeForTests()).toBe(0);
});

test("pid validation selects only trusted Windows process probes", () => {
const previousSystemRoot = process.env.SystemRoot;
const previousWindir = process.env.WINDIR;
const trustedSystem32 = join(testDir, "trusted", "System32");
const trustedWmic = join(trustedSystem32, "wbem", "WMIC.exe");
const trustedPowerShell = join(
trustedSystem32,
"WindowsPowerShell",
"v1.0",
"powershell.exe",
);
const attackerRoot = join(testDir, "attacker-windows");
const calls: string[] = [];

try {
mkdirSync(dirname(trustedPowerShell), { recursive: true });
writeFileSync(trustedPowerShell, "", { mode: 0o755 });
setProcessCommandLinePlatformForTests("win32");
setTrustedWindowsSystemDirectoryResolverForTests(() => trustedSystem32);
process.env.SystemRoot = attackerRoot;
process.env.WINDIR = attackerRoot;
writeFileSync(getPidPath(), String(process.pid), "utf-8");
setOcxStartProcessCacheForTests([]);

setProcessCommandLineExecForTests((executable) => {
calls.push(executable);
if (executable === trustedWmic) return "CommandLine=ocx start\r\n";
throw new Error(`unexpected process probe: ${executable}`);
});
expect(readPid()).toBe(process.pid);
expect(calls).toEqual([trustedWmic]);

calls.length = 0;
setOcxStartProcessCacheForTests([]);
setProcessCommandLineExecForTests((executable) => {
calls.push(executable);
if (executable === trustedWmic) throw new Error("WMIC unavailable");
if (executable === trustedPowerShell) return "ocx start\n";
throw new Error(`unexpected process probe: ${executable}`);
});
expect(readPid()).toBe(process.pid);
expect(calls).toEqual([trustedWmic, trustedPowerShell]);
expect(calls.every(executable => !executable.startsWith(attackerRoot))).toBe(true);
} finally {
setProcessCommandLineExecForTests(null);
setProcessCommandLinePlatformForTests(null);
setTrustedWindowsSystemDirectoryResolverForTests(null);
setOcxStartProcessCacheForTests([]);
if (previousSystemRoot === undefined) delete process.env.SystemRoot;
else process.env.SystemRoot = previousSystemRoot;
if (previousWindir === undefined) delete process.env.WINDIR;
else process.env.WINDIR = previousWindir;
}

expect(ocxStartProcessCacheSizeForTests()).toBe(0);
});

test("removes pid file only when the expected pid still matches", () => {
writeFileSync(getPidPath(), "111", "utf-8");
removePid(222);
Expand Down
Loading