From dc19654b0c2a0d1992bb4dd0f58a55663e4b7600 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:43:14 +0900 Subject: [PATCH 1/3] fix(config): pin pid identity probes to trusted binaries --- src/config.ts | 71 ++++++++++++++++++++++++++++------ tests/config.test.ts | 90 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 149 insertions(+), 12 deletions(-) diff --git a/src/config.ts b/src/config.ts index 32831fd39..12d855fa0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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, @@ -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, "")); @@ -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", @@ -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; } diff --git a/tests/config.test.ts b/tests/config.test.ts index 0c1313b81..f5431240d 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -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, @@ -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 = ""; @@ -1830,6 +1836,88 @@ describe("opencodex config defaults", () => { expect(readFileSync(getPidPath(), "utf-8")).toBe(String(process.pid)); }); + test.if(process.platform !== "win32")("pid validation does not execute ps from PATH", () => { + const attackerDir = join(testDir, "attacker-bin"); + const markerPath = join(testDir, "executed"); + const fakePs = join(attackerDir, "ps"); + const previousPath = process.env.PATH; + mkdirSync(attackerDir); + writeFileSync(fakePs, `#!/bin/sh\ntouch '${markerPath}'\necho 'ocx start'\n`, { mode: 0o755 }); + + setOcxStartProcessCacheForTests([]); + try { + process.env.PATH = `${attackerDir}${delimiter}${previousPath ?? ""}`; + writePid(process.pid); + + expect(readPid()).toBeNull(); + expect(existsSync(markerPath)).toBe(false); + } finally { + 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); From 3cb0484ad84b1fdd8a4f3f01dc5e03ac1e68991d Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:52:37 +0900 Subject: [PATCH 2/3] test(config): cover fixed POSIX ps fallback --- tests/config.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/config.test.ts b/tests/config.test.ts index f5431240d..4378dfebe 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1836,22 +1836,31 @@ describe("opencodex config defaults", () => { expect(readFileSync(getPidPath(), "utf-8")).toBe(String(process.pid)); }); - test.if(process.platform !== "win32")("pid validation does not execute ps from PATH", () => { + test("pid validation does not execute ps from PATH", () => { const attackerDir = join(testDir, "attacker-bin"); const markerPath = join(testDir, "executed"); const fakePs = join(attackerDir, "ps"); const previousPath = process.env.PATH; + const probes: string[] = []; mkdirSync(attackerDir); writeFileSync(fakePs, `#!/bin/sh\ntouch '${markerPath}'\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([]); From bcb2ce79be43fe76a2ae40a15ae7380b64eb3aa8 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:00:33 +0900 Subject: [PATCH 3/3] test(config): make poisoned ps marker shell-safe --- tests/config.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/config.test.ts b/tests/config.test.ts index 4378dfebe..b527ff024 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1838,12 +1838,12 @@ describe("opencodex config defaults", () => { test("pid validation does not execute ps from PATH", () => { const attackerDir = join(testDir, "attacker-bin"); - const markerPath = join(testDir, "executed"); const fakePs = join(attackerDir, "ps"); + const markerPath = `${fakePs}.executed`; const previousPath = process.env.PATH; const probes: string[] = []; mkdirSync(attackerDir); - writeFileSync(fakePs, `#!/bin/sh\ntouch '${markerPath}'\necho 'ocx start'\n`, { mode: 0o755 }); + writeFileSync(fakePs, `#!/bin/sh\ntouch "$0.executed"\necho 'ocx start'\n`, { mode: 0o755 }); setOcxStartProcessCacheForTests([]); try {