From 93eb841e5dc413e39a090442c716eca6f84bdabb Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Wed, 28 Oct 2026 05:24:16 +0000 Subject: [PATCH 1/8] fix(codex): validate CLI and desktop launcher execution boundaries --- .../content/docs/guides/chatgpt-desktop.md | 14 +- .../content/docs/tr/guides/chatgpt-desktop.md | 15 +- scripts/test-layout/layout-clients.json | 1 + scripts/test-layout/layout-exec-boundary.json | 1 + src/chatgpt/app-server-shim/launcher.ts | 20 ++- src/cli/codex-launcher.ts | 25 ++-- src/cli/codex-shim-autorestore.ts | 2 + src/cli/internal-command.ts | 22 ++- structure/cli-management.md | 2 +- structure/clients/chatgpt-desktop.md | 27 +++- .../desktop-app-server-exec-boundary.test.ts | 76 ++++++++++ .../desktop-app-server-shim-launcher.test.ts | 17 ++- .../exec-launcher-boundary.test.ts | 133 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 2 + 14 files changed, 319 insertions(+), 38 deletions(-) create mode 100644 tests/clients/desktop-app-server-exec-boundary.test.ts create mode 100644 tests/codex-integration/exec-launcher-boundary.test.ts diff --git a/docs-site/src/content/docs/guides/chatgpt-desktop.md b/docs-site/src/content/docs/guides/chatgpt-desktop.md index 458acdfbedf..8f195bfa829 100644 --- a/docs-site/src/content/docs/guides/chatgpt-desktop.md +++ b/docs-site/src/content/docs/guides/chatgpt-desktop.md @@ -104,10 +104,11 @@ restart transaction. ## Failure behavior and known limits -When the platform is not macOS, the OpenCodex runtime is missing, or the filter -self-test fails, the launcher runs the original binary with untouched stdout. A -missing bundled app-server binary is the exception: there is nothing to fall back -to, so the launcher exits with an error (see below). +The launcher verifies the executable before every start, including Desktop respawns. +If stdout filtering is unavailable or its self-test fails, the verified executable +runs with untouched stdout. If executable verification fails or the OpenCodex +validator is unavailable, the launcher exits with an error without starting the +app-server. A missing bundled app-server binary also prevents startup (see below). A filter that passes the self-test and then dies mid-session closes the pipe. What the bundled app-server does after that has not been verified; it may get SIGPIPE or a write error and be respawned by Desktop through the same launcher. @@ -117,8 +118,9 @@ remaining stream to raw bytes. The experiment depends on the bundled binary path, the app honoring `CODEX_CLI_PATH`, and current RPC field shapes. Updates may change these. A moved -or removed OpenCodex installation fails the launcher preflight and runs the -original binary. Run `ocx chatgpt launch` again after relocating the installation. +or removed OpenCodex installation can leave the launcher unable to verify the +executable, so it refuses startup. Run `ocx chatgpt launch` again after relocating +the installation, or `ocx chatgpt restore` to remove the override. If an app update moves or removes the bundled app-server binary itself, the launcher cannot start it: it prints a message naming `ocx chatgpt launch` and `ocx chatgpt restore` on stderr and exits, and Desktop cannot start its diff --git a/docs-site/src/content/docs/tr/guides/chatgpt-desktop.md b/docs-site/src/content/docs/tr/guides/chatgpt-desktop.md index 847b14d27f0..b3dd5372fc3 100644 --- a/docs-site/src/content/docs/tr/guides/chatgpt-desktop.md +++ b/docs-site/src/content/docs/tr/guides/chatgpt-desktop.md @@ -101,10 +101,12 @@ etkisiz bir yeniden başlatma işlemi garanti etmez. ## Hata davranışı ve bilinen sınırlar -Platform macOS değilse, OpenCodex çalışma zamanı eksikse veya filtre öz sınaması -başarısızsa başlatıcı özgün dosyayı stdout'u değiştirmeden çalıştırır. Paketlenmiş -app-server dosyasının eksikliği istisnadır: geri dönülecek dosya olmadığı için -başlatıcı hatayla çıkar. +Başlatıcı, Desktop'ın yeniden başlattığı işlemler dahil her çalıştırmadan önce +yürütülebilir dosyayı doğrular. Stdout filtresi kullanılamıyorsa veya öz sınaması +başarısızsa doğrulanmış dosya stdout değiştirilmeden çalışır. Dosya doğrulaması +başarısızsa ya da OpenCodex doğrulayıcısı kullanılamıyorsa başlatıcı app-server'ı +çalıştırmadan hatayla çıkar. Paketlenmiş app-server dosyasının eksikliği de +başlatmayı engeller. Öz sınamayı geçen filtre oturum ortasında çökerse boru kapanır. Paketlenmiş app-server'ın bundan sonra ne yaptığı doğrulanmamıştır; SIGPIPE/yazma hatası @@ -115,8 +117,9 @@ ham baytlara geçirir. Deney; paketlenmiş dosya yoluna, uygulamanın `CODEX_CLI_PATH` değerini kullanmasına ve güncel RPC alan biçimlerine bağlıdır. Güncellemeler bunları değiştirebilir. -OpenCodex taşınır veya kaldırılırsa başlatıcının ön kontrolü başarısız olur ve -özgün dosya çalışır. Kurulumu taşıdıktan sonra `ocx chatgpt launch` komutunu yeniden çalıştırın. +OpenCodex taşınır veya kaldırılırsa başlatıcı dosyayı doğrulayamayabilir ve +başlatmayı reddeder. Kurulumu taşıdıktan sonra `ocx chatgpt launch` komutunu yeniden +çalıştırın veya geçersiz kılmayı kaldırmak için `ocx chatgpt restore` kullanın. Uygulama güncellemesi paketlenmiş app-server dosyasını taşır veya kaldırırsa başlatıcı onu çalıştıramaz: stderr üzerinde `ocx chatgpt launch` ve diff --git a/scripts/test-layout/layout-clients.json b/scripts/test-layout/layout-clients.json index c7dc4e9131f..ca8b89bf22a 100644 --- a/scripts/test-layout/layout-clients.json +++ b/scripts/test-layout/layout-clients.json @@ -28,6 +28,7 @@ "droid-normalized-reasoning-defaults.test.ts": "clients", "desktop-app-server-shim.test.ts": "clients", "desktop-app-server-shim-launcher.test.ts": "clients", + "desktop-app-server-exec-boundary.test.ts": "clients", "desktop-bundle-trust-async.test.ts": "clients", "desktop-chatgpt-config.test.ts": "clients", "desktop-supervised-restart.test.ts": "clients", diff --git a/scripts/test-layout/layout-exec-boundary.json b/scripts/test-layout/layout-exec-boundary.json index e64ba5579f4..e3cc45defa4 100644 --- a/scripts/test-layout/layout-exec-boundary.json +++ b/scripts/test-layout/layout-exec-boundary.json @@ -1,5 +1,6 @@ { "exec-trust-boundary.test.ts": "codex-integration", "exec-boundary-launch.test.ts": "codex-integration", + "exec-launcher-boundary.test.ts": "codex-integration", "exec-known-app-bundles.test.ts": "codex-integration" } diff --git a/src/chatgpt/app-server-shim/launcher.ts b/src/chatgpt/app-server-shim/launcher.ts index 53142ddafbf..7aa2d09cce5 100644 --- a/src/chatgpt/app-server-shim/launcher.ts +++ b/src/chatgpt/app-server-shim/launcher.ts @@ -47,24 +47,40 @@ function shellQuote(value: string): string { } /** - * A failed precondition or a failed self-test runs the original binary with untouched stdout. + * A failed filter self-test runs the verified binary with untouched stdout. + * Executable verification is required on every invocation, including passthrough and respawns. * A filter that passes the self-test and then dies mid-session closes the pipe. * Expected (not yet validated against the bundled app-server): the server gets SIGPIPE or a * write error and Desktop respawns it through the same launcher. */ -export function buildChatgptShimLauncher(argv: readonly string[], real = CHATGPT_APP_CODEX_BINARY): string { +export function buildChatgptShimLauncher( + argv: readonly string[], + real = CHATGPT_APP_CODEX_BINARY, + validator: readonly string[] = [process.execPath, ...selfLaunchArgv(["internal", "codex-exec-target"])], +): string { return `#!/bin/bash # opencodex (experimental): ChatGPT app-server stdout passes through the quota-gate filter. REAL=${shellQuote(real)} FILTER=(${argv.map(shellQuote).join(" ")}) +VALIDATE=(${validator.map(shellQuote).join(" ")}) if [ ! -x "$REAL" ]; then echo "opencodex (experimental): the bundled app-server is no longer at $REAL; run 'ocx chatgpt launch' again or 'ocx chatgpt restore'." >&2 exit 127 fi +validate_real() { + local target + if [ ! -x "\${VALIDATE[0]}" ] || ! target=$("\${VALIDATE[@]}" -- "$REAL") || [ -z "$target" ]; then + echo "opencodex: app-server execution verification failed; run 'ocx chatgpt launch' again or 'ocx chatgpt restore'." >&2 + return 126 + fi + REAL="$target" +} if [ "$(uname -s)" = "Darwin" ] && [ -x "\${FILTER[0]}" ] \\ && "\${FILTER[@]}" --self-test >/dev/null 2>&1; then + validate_real || exit 126 exec "$REAL" "$@" > >(exec "\${FILTER[@]}") fi +validate_real || exit 126 exec "$REAL" "$@" `; } diff --git a/src/cli/codex-launcher.ts b/src/cli/codex-launcher.ts index 98fc9f103ea..287266ef967 100644 --- a/src/cli/codex-launcher.ts +++ b/src/cli/codex-launcher.ts @@ -25,13 +25,13 @@ import { spawn } from "node:child_process"; import { loadConfig } from "../config"; import { getCodexRoutingKind, type CodexRoutingKind } from "../codex/inject"; import { inspectCodexShimBackingForCommand } from "../codex/shim"; +import { validatedCodexExecInvocationAsync, type CodexExecBoundaryAsyncDeps } from "../codex/exec-boundary"; import { CODEX_GLOBAL_OPTIONS_WITH_VALUE, CODEX_INTERNAL_COMMANDS, CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC } from "../codex/shim-templates"; import { withoutSiblingMarker } from "../codex/sibling-start"; import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; import { tokenCollidesWithAdmin } from "../lib/admin-secrets"; import { selfLaunchArgv } from "../lib/self-launch-argv"; import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../lib/service-secrets"; -import { commandInvocation } from "../lib/win-exec"; import { findExecutableOnPath } from "../remote-control/workspace-executable"; import { findLiveProxy, type LiveProxy } from "../server/proxy-liveness"; import { codexCredentialDestinationIsTrusted, resolveCodexLaunchHome } from "./codex-launch-auth"; @@ -265,14 +265,23 @@ export function resolveCodexLaunchCommand( } } -/** Launch the selected Codex command with inherited IO and preserve its exit status. */ -function spawnCodex(args: readonly string[], env: CodexLaunchEnv): Promise { +export interface CodexSpawnDeps extends CodexLaunchCommandDeps { + execBoundary?: CodexExecBoundaryAsyncDeps; + spawn?: typeof spawn; +} + +/** Validate the final backing command immediately before each launch, including fast paths. */ +export async function spawnCodex(args: readonly string[], env: CodexLaunchEnv, deps: CodexSpawnDeps = {}): Promise { + let launch: CodexLaunchCommand, inv: Awaited>; + try { + launch = resolveCodexLaunchCommand(env, deps); + inv = await validatedCodexExecInvocationAsync(launch.command, args, { ...deps.execBoundary, env: launch.env as NodeJS.ProcessEnv }); + } catch (error) { + console.error(error instanceof CodexNotFoundError ? CODEX_INSTALL_HINT : `❌ Cannot launch codex: ${error instanceof Error ? error.message : "executable verification failed"}`); + return 1; + } return new Promise(resolve => { - let launch: CodexLaunchCommand; - try { launch = resolveCodexLaunchCommand(env); } - catch (error) { console.error(error instanceof CodexNotFoundError ? CODEX_INSTALL_HINT : CODEX_UNVERIFIED_SHIM); resolve(1); return; } - const inv = commandInvocation(launch.command, args); - const child = spawn(inv.file, inv.args, { stdio: "inherit", env: launch.env as NodeJS.ProcessEnv, ...inv.options }); + const child = (deps.spawn ?? spawn)(inv.file, inv.args, { stdio: "inherit", env: launch.env as NodeJS.ProcessEnv, ...inv.options }); child.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "ENOENT") { console.error(CODEX_INSTALL_HINT); diff --git a/src/cli/codex-shim-autorestore.ts b/src/cli/codex-shim-autorestore.ts index 86750714f3b..626bff50da9 100644 --- a/src/cli/codex-shim-autorestore.ts +++ b/src/cli/codex-shim-autorestore.ts @@ -21,6 +21,8 @@ export function skipsCodexShimAutoRestore(command: string | undefined, args: str if (command === "status" || command === "doctor") return true; // The launcher owns shim handoff after classifying routing, including native fast paths. if (command === "codex") return true; + // Exec admission only observes the selected target; it must never repair a shim first. + if (command === "internal" && args[1] === "codex-exec-target") return true; // `lab` is read-only inspection; it must not trigger shim side effects. if (command === "lab") return true; // Local messaging owns its helpers; even malformed usage must not repair/start anything. diff --git a/src/cli/internal-command.ts b/src/cli/internal-command.ts index 7799ceb52b2..b7b096ee771 100644 --- a/src/cli/internal-command.ts +++ b/src/cli/internal-command.ts @@ -16,15 +16,33 @@ * that same user, and a same-uid process could call `kill` directly. A token here would * protect nothing and would imply a boundary that does not exist. */ -const USAGE = "Usage: ocx internal desktop-restart-handoff --plan | chatgpt-app-server-filter [--self-test]"; +import type { CodexExecBoundaryAsyncDeps } from "../codex/exec-boundary"; + +const USAGE = "Usage: ocx internal desktop-restart-handoff --plan | chatgpt-app-server-filter [--self-test] | codex-exec-target -- "; function optionValue(args: readonly string[], name: string): string | undefined { const index = args.indexOf(name); return index >= 0 ? args[index + 1] : undefined; } -export async function handleInternalCommand(args: readonly string[]): Promise { +export async function handleInternalCommand(args: readonly string[], execBoundary: CodexExecBoundaryAsyncDeps = {}): Promise { const sub = args[0]; + if (sub === "codex-exec-target") { + if (args.length !== 3 || args[1] !== "--" || !args[2] || /[\r\n]/.test(args[2])) { + console.error(USAGE); + return 2; + } + try { + const { assertCodexExecTargetTrustedAsync } = await import("../codex/exec-boundary"); + const target = await assertCodexExecTargetTrustedAsync(args[2], execBoundary); + if (!target || /[\r\n]/.test(target)) throw new Error("invalid executable path"); + console.log(target); + return 0; + } catch (error) { + console.error(`OpenCodex refused the app-server executable: ${error instanceof Error ? error.message : "verification failed"}`); + return 1; + } + } if (sub === "chatgpt-app-server-filter") { if (args.length > 2 || (args[1] !== undefined && args[1] !== "--self-test")) { console.error(USAGE); diff --git a/structure/cli-management.md b/structure/cli-management.md index 8a3683d7c2d..c5d1577f132 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -34,7 +34,7 @@ For fresh credentials, the launcher resolves a native literal `CODEX_HOME` once `src/cli/codex-launcher.ts` implements `ocx codex` without rewriting routing. Native/foreign routes pass through; local/unknown routes attempt startup and warn on failure. Fast paths skip startup/token loading. A matched wrapper uses its backing directly. Unknown state, including a throwing inspector, requires a separate bypass check via `src/cli/codex-launch-command.ts`; managed Windows CMD wrappers require complete generated-template equality before the selected absolute path executes with bypass. Obsolete, unreadable or ambiguous wrappers and lookup errors refuse launch without exception contents. A missing executable instead reports the Codex install hint, including on help/version fast paths. This content check does not establish Windows backing provenance or prevent concurrent replacement. `src/cli/codex-launch-auth.ts` separately requires a parsed managed transport block matching this installation's loopback listener or persisted local client connection; names/comments alone grant no token. Profiles, distinct project layers scanned through the filesystem root, destination-changing argv and environment overrides prevent automatic loading. An existing ancestor config is excluded only when its realpath matches the home config; a different file or failed identity resolution refuses. Token-file overrides and management-token collisions remain refused; user exports are preserved. No token enters argv or diagnostics. -The `codex` namespace forwards all help arguments unchanged; `ocx help codex` retains wrapper help. It skips root shim repair so the routing-aware launcher owns the handoff. +The `codex` namespace forwards all help arguments unchanged; `ocx help codex` retains wrapper help. It skips root shim repair so the routing-aware launcher owns the handoff. Every handoff, including help/version and passthrough, awaits the [execution trust contract](clients/chatgpt-desktop.md#codex-app-execution-trust) for the selected backing and spawns the returned invocation. Fast paths bypass startup and credential loading, not executable validation. The hidden `internal codex-exec-target` helper returns a single verified path for the generated app-server launcher, skips root shim auto-repair even on malformed arguments, and stays outside public capability discovery. Forwarded `-c`/`--config` and `-p`/`--profile` overrides make effective routing unverified for automatic service-token injection. They retain original argv and user-exported credentials but never load a new token from disk. Single-word prompts require `--` so they cannot select an unknown native subcommand. Missing system files also require a protected namespace: the closest existing directory must prevent another user from creating the missing layer, and its ancestors must prevent replacement. A normal Windows ProgramData directory that grants Users directory creation cannot authorize an absent OpenAI/Codex namespace; the launcher withholds credentials without creating or changing that directory. macOS opens each validated entry without following its final link (O_SYMLINK for the link entry itself), keeps dev/ino as bigint and requires descriptor dev/ino/type to match its original lstat, and reads its ACL with acl_get_fd_np. Only NULL with immediately captured ENOENT on this descriptor query means no extended ACL; open/stat/identity/close failures and every other NULL/error refuse. Every opened descriptor closes in finally. For a present ACL it validates and enumerates every entry through the documented -1/EINVAL terminator. Deny-only and read-only allow entries are accepted; any allow granting write/add-file, append/add-subdirectory, delete, delete-child, attribute, extended-attribute, security or ownership mutation refuses regardless of earlier denies. Unknown tags and FFI/enumeration failures refuse. Forced managed-preference checks remain mandatory. Pure policy and Darwin-gated native ACL coverage lives in `tests/codex-integration/codex-launcher-darwin-acl.test.ts`; modeled IO regressions for all three walks live in `tests/codex-integration/codex-launcher-darwin-identity.test.ts`. Tests model a protected empty machine namespace inside their private fixture; they do not modify real system configuration. Windows fixtures probe ancestor ownership before positive assertions; an unavailable protected location requires a proved ownership refusal and an explicit skip reason. Negative mutations and no-token/zero-read assertions execute even with unsafe ancestors; credential-file negatives bypass destination verification independently. Native checks use production defaults. `src/cli/root.ts`, `src/cli/help.ts`, `src/cli/help-navigation.ts` diff --git a/structure/clients/chatgpt-desktop.md b/structure/clients/chatgpt-desktop.md index b647bd7af5d..fd13ab10758 100644 --- a/structure/clients/chatgpt-desktop.md +++ b/structure/clients/chatgpt-desktop.md @@ -7,18 +7,22 @@ accepts only an optional boolean; malformed reads disable the leaf, while live writes reject malformed values and unknown fields. The launcher under the config directory re-enters the current CLI using -`process.execPath` and `selfLaunchArgv()`. Its hidden internal filter command stays +`process.execPath` and `selfLaunchArgv()`. Its hidden filter and executable-validation commands stay out of the public command registry and generated skill surface. Source installs include the CLI entry argument; compiled builds use only the executable and internal command arguments. -The launcher checks macOS, executable presence and a successful filter self-test, -then replaces itself with the bundled app-server using shell exec. Only stdout +The launcher uses macOS and a successful filter self-test to select stdout filtering. +Every invocation, including a Desktop respawn and the unfiltered branch, re-enters +`internal codex-exec-target -- ` immediately before shell exec. The helper applies +the execution trust contract below and returns the exact target to execute; the shell +does not use `uname` as execution authority. Only stdout passes through the filter. Stdin, stderr, process identity and the real server's exit status retain the direct app/server relationship. -When the platform is not macOS, the runtime is missing, or the filter self-test fails, -the launcher runs the original binary with untouched stdout. A missing bundled binary +When filtering is unavailable or its self-test fails, the launcher runs the validated +target with untouched stdout. An unavailable validator, failed trust check or empty +target exits 126 without executing the recorded binary; a missing bundled binary exits 127 instead (see below). A filter that passes the self-test and then exits mid-session closes the server's stdout pipe; the filter's passthrough mode limits this to an exit/crash case. @@ -99,7 +103,16 @@ The name only routes to verification. `src/codex/exec-boundary.ts` requires a ca pair with the binary strictly inside that bundle's `Contents/`, then applies the same synchronous or asynchronous OpenAI signature and POSIX filesystem policy before returning an invocation. Canonicalization or verification failure refuses execution. This covers environment/configured -commands and catalog, prompt, login, refresh and remote-workspace consumers; ordinary CLI and +commands and catalog, prompt, login, refresh and remote-workspace consumers. The +`src/cli/codex-launcher.ts` handoff validates the selected shim backing on routed, +passthrough and help/version fast paths, preserving the validated invocation's argv, +environment and Windows interpreter options. Generated app-server launchers use the +hidden validator in `src/cli/internal-command.ts` on each execution; this command skips +root shim auto-repair, its stdout is a single path, and newline-bearing input or +canonical output is refused. Ordinary CLI and third-party app paths retain their existing policy. Conventional automatic discovery remains limited to the two ChatGPT installation roots. Regression coverage is in -`tests/codex-integration/exec-known-app-bundles.test.ts`. +`tests/codex-integration/exec-known-app-bundles.test.ts`, +`tests/codex-integration/exec-launcher-boundary.test.ts` and +`tests/clients/desktop-app-server-exec-boundary.test.ts`. Portable injected-verifier and +shell tests do not establish native macOS signature or real Desktop respawn acceptance. diff --git a/tests/clients/desktop-app-server-exec-boundary.test.ts b/tests/clients/desktop-app-server-exec-boundary.test.ts new file mode 100644 index 00000000000..fb604e111b1 --- /dev/null +++ b/tests/clients/desktop-app-server-exec-boundary.test.ts @@ -0,0 +1,76 @@ +import { expect, test } from "bun:test"; +import { chmodSync, existsSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { buildChatgptShimLauncher } from "../../src/chatgpt/app-server-shim/launcher"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const shellTest = process.platform !== "win32" && spawnSync("/bin/bash", ["-c", "exit 0"]).status === 0 ? test : test.skip; +const quote = (s: string) => `'${s.replaceAll("'", "'\\''")}'`; +function executable(dir: string, name: string, body: string) { + const path = join(dir, name); writeFileSync(path, `#!/bin/bash\n${body}\n`); chmodSync(path, 0o755); return path; +} +function fixture(run: (dir: string) => void) { + const dir = mkdtempSync(join(tmpdir(), "ocx-app-exec-")); + try { run(dir); } finally { removeTreeWithRetry(dir); } +} + +shellTest("each respawn validates and executes the returned target with original argv, stdin and PID", () => fixture(dir => { + const trace = join(dir, "trace"); + const recorded = executable(dir, "recorded.sh", "exit 99"); + const verified = executable(dir, "canonical with 'quote.sh", 'printf "PID:%s ARGS:%s\\n" "$$" "$*"\ncat\nexit 7'); + const validator = executable(dir, "validate", `echo validate >> ${quote(trace)}\nprintf '%s\\n' ${quote(verified)}`); + const launcher = executable(dir, "launcher", buildChatgptShimLauncher([join(dir, "missing-filter")], recorded, [validator])); + for (let i = 0; i < 2; i++) { + const out = spawnSync("/bin/bash", [launcher, "app-server", "arg with spaces"], { encoding: "utf8", input: "stdin\n", timeout: 5000 }); + expect(out.error).toBeUndefined(); expect(out.status).toBe(7); + expect(out.stdout).toBe(`PID:${out.pid} ARGS:app-server arg with spaces\nstdin\n`); + } + expect(readFileSync(trace, "utf8")).toBe("validate\nvalidate\n"); +})); + +shellTest("revocation after launcher creation blocks later execution even when uname says Linux", () => fixture(dir => { + const marker = join(dir, "spawned"), allow = join(dir, "allowed"); + const real = executable(dir, "real", `echo ran >> ${quote(marker)}`); + const validator = executable(dir, "validate", `[ -e ${quote(allow)} ] || exit 1\nprintf '%s\\n' "$2"`); + executable(dir, "uname", "echo Linux"); + const launcher = executable(dir, "launcher", buildChatgptShimLauncher([join(dir, "missing-filter")], real, [validator])); + const run = () => spawnSync("/bin/bash", [launcher], { encoding: "utf8", env: { ...process.env, PATH: `${dir}:${process.env.PATH}` }, timeout: 5000 }); + writeFileSync(allow, "allowed"); + expect(run().status).toBe(0); + expect(readFileSync(marker, "utf8")).toBe("ran\n"); + unlinkSync(allow); + const out = run(); + expect(out.status).toBe(126); expect(readFileSync(marker, "utf8")).toBe("ran\n"); + expect(out.stderr).toContain("execution verification failed"); +})); + +for (const status of [0, 1]) shellTest(`filter preflight ${status} cannot bypass the last executable check`, () => fixture(dir => { + const trace = join(dir, "trace"), marker = join(dir, "spawned"); + executable(dir, "uname", "echo Darwin"); + const real = executable(dir, "real", `echo ran >> ${quote(marker)}`); + const filter = executable(dir, "filter", `echo filter >> ${quote(trace)}\nexit ${status}`); + const validator = executable(dir, "validate", `echo validate >> ${quote(trace)}\nexit 1`); + const launcher = executable(dir, "launcher", buildChatgptShimLauncher([filter], real, [validator])); + const out = spawnSync("/bin/bash", [launcher], { encoding: "utf8", env: { ...process.env, PATH: `${dir}:${process.env.PATH}` }, timeout: 5000 }); + expect(out.status).toBe(126); expect(existsSync(marker)).toBe(false); + expect(readFileSync(trace, "utf8")).toBe("filter\nvalidate\n"); +})); + +shellTest("missing validator refuses instead of falling back to the unchecked target", () => fixture(dir => { + const marker = join(dir, "spawned"); + const real = executable(dir, "real", `echo ran >> ${quote(marker)}`); + const launcher = executable(dir, "launcher", buildChatgptShimLauncher([join(dir, "filter")], real, [join(dir, "missing-validator")])); + const out = spawnSync("/bin/bash", [launcher], { encoding: "utf8", timeout: 5000 }); + expect(out.status).toBe(126); expect(existsSync(marker)).toBe(false); +})); + +shellTest("an empty successful validator result does not authorize the recorded target", () => fixture(dir => { + const marker = join(dir, "spawned"); + const real = executable(dir, "real", `echo ran >> ${quote(marker)}`); + const validator = executable(dir, "validate", "exit 0"); + const launcher = executable(dir, "launcher", buildChatgptShimLauncher([join(dir, "filter")], real, [validator])); + const out = spawnSync("/bin/bash", [launcher], { encoding: "utf8", timeout: 5000 }); + expect(out.status).toBe(126); expect(existsSync(marker)).toBe(false); +})); diff --git a/tests/clients/desktop-app-server-shim-launcher.test.ts b/tests/clients/desktop-app-server-shim-launcher.test.ts index db53fc7fd0b..a71fa031c6d 100644 --- a/tests/clients/desktop-app-server-shim-launcher.test.ts +++ b/tests/clients/desktop-app-server-shim-launcher.test.ts @@ -30,6 +30,9 @@ function withDir(run: (dir: string) => void) { try { run(dir); } finally { removeTreeWithRetry(dir); } } const internalArgs = ["internal", "chatgpt-app-server-filter"]; +const validatorArgv = [process.execPath, ...selfLaunchArgv(["internal", "codex-exec-target"], { + isStandaloneExecutable: false, sourceEntrypoint: repoPath("src/cli/index.ts"), +})]; describe("experimental app-server launcher", () => { test("source and compiled argv re-enter the CLI with shell-safe quoting", () => { @@ -37,8 +40,10 @@ describe("experimental app-server launcher", () => { const argv = ["/runtime with 'quote", ...selfLaunchArgv(internalArgs, { isStandaloneExecutable: standalone, sourceEntrypoint: "/source with space/index.ts", })]; - const launcher = buildChatgptShimLauncher(argv, "/real with space"); + const validator = [...argv.slice(0, -1), "codex-exec-target"]; + const launcher = buildChatgptShimLauncher(argv, "/real with space", validator); expect(launcher).toContain("'internal' 'chatgpt-app-server-filter'"); + expect(launcher).toContain("'internal' 'codex-exec-target'"); expect(launcher.includes("'/source with space/index.ts'")).toBe(!standalone); expect(launcher).toContain("'\\''"); expect(launcher).toContain('--self-test >/dev/null 2>&1'); @@ -103,13 +108,13 @@ describe("experimental app-server launcher", () => { executable(dir, "uname", 'echo Darwin'); return { ...process.env, PATH: `${dir}:${process.env.PATH ?? "/usr/bin:/bin"}` }; }; - for (const scenario of ["missing runtime", "failing self-test", "passing self-test"] as const) { + for (const scenario of ["missing filter runtime", "failing self-test", "passing self-test"] as const) { const scenarioTest = (scenario === "passing self-test" ? substitutionAvailable : bashAvailable) ? test : test.skip; scenarioTest(scenario, () => withDir(dir => { const real = executable(dir, "real with 'quote.sh", 'echo "PID:$$ ARGS:$*"\nread -r line\necho "STDIN:$line"\necho "to-stderr" >&2\nexit 7'); - const filter = scenario === "missing runtime" ? join(dir, "missing") : executable(dir, "filter.sh", + const filter = scenario === "missing filter runtime" ? join(dir, "missing") : executable(dir, "filter.sh", `if [ "$1" = --self-test ]; then exit ${scenario === "failing self-test" ? 1 : 0}; fi\nsed 's/STDIN/FILTERED/'`); - const launcher = executable(dir, "launcher.sh", buildChatgptShimLauncher([filter], real)); + const launcher = executable(dir, "launcher.sh", buildChatgptShimLauncher([filter], real, validatorArgv)); const out = spawnSync("/bin/bash", [launcher, "app-server", "--flag"], { encoding: "utf8", input: "hello\n", env: environment(dir), timeout: 5000, }); @@ -124,7 +129,7 @@ describe("experimental app-server launcher", () => { const argv = [process.execPath, ...selfLaunchArgv(internalArgs, { isStandaloneExecutable: false, sourceEntrypoint: repoPath("src/cli/index.ts"), })]; - const launcher = executable(dir, "launcher.sh", buildChatgptShimLauncher(argv, real)); + const launcher = executable(dir, "launcher.sh", buildChatgptShimLauncher(argv, real, validatorArgv)); const out = spawnSync("/bin/bash", [launcher, "app-server"], { encoding: "utf8", env: environment(dir), timeout: 10000 }); expect(out.status).toBe(0); const result = JSON.parse(out.stdout).result; @@ -135,7 +140,7 @@ describe("experimental app-server launcher", () => { executionTest("a filter exit after successful preflight closes the pipe and terminates a mock server", () => withDir(dir => { const real = executable(dir, "real.sh", 'trap "exit 23" PIPE\nwhile :; do printf "%s\\n" "mock server stdout" || exit 23; done'); const filter = executable(dir, "filter.sh", 'if [ "$1" = --self-test ]; then exit 0; fi\nexit 1'); - const launcher = executable(dir, "launcher.sh", buildChatgptShimLauncher([filter], real)); + const launcher = executable(dir, "launcher.sh", buildChatgptShimLauncher([filter], real, validatorArgv)); const out = spawnSync("/bin/bash", [launcher], { encoding: "utf8", env: environment(dir), timeout: 5000 }); expect(out.error).toBeUndefined(); expect(out.status).toBe(23); diff --git a/tests/codex-integration/exec-launcher-boundary.test.ts b/tests/codex-integration/exec-launcher-boundary.test.ts new file mode 100644 index 00000000000..27ee5f963a2 --- /dev/null +++ b/tests/codex-integration/exec-launcher-boundary.test.ts @@ -0,0 +1,133 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import type { spawn } from "node:child_process"; +import { spawnCodex, type CodexSpawnDeps } from "../../src/cli/codex-launcher"; +import { handleInternalCommand } from "../../src/cli/internal-command"; +import type { CodexExecBoundaryAsyncDeps } from "../../src/codex/exec-boundary"; +import { maybeAutoRestoreCodexShim, skipsCodexShimAutoRestore } from "../../src/cli/codex-shim-autorestore"; + +const bundle = "/Applications/ChatGPT.app"; +const binary = `${bundle}/Contents/Resources/codex`; +const alias = "/usr/local/bin/codex"; +let logs: ReturnType>; +let errors: ReturnType>; +beforeEach(() => { + logs = spyOn(console, "log").mockImplementation(() => {}); + errors = spyOn(console, "error").mockImplementation(() => {}); +}); +afterEach(() => { logs.mockRestore(); errors.mockRestore(); }); + +function harness(check: NonNullable) { + const calls: Array<{ file: string; args: string[]; options: Record }> = []; + const boundary: CodexExecBoundaryAsyncDeps = { + platform: "darwin", executable: () => true, + realpath: path => path === alias ? binary : path, + macBundleTrustReasonAsync: check, + }; + const deps: CodexSpawnDeps = { + findOnPath: () => "/owned/codex-wrapper", + inspectShim: () => ({ status: "matched", selectedRole: "wrapper", backingPath: alias }) as ReturnType>, + execBoundary: boundary, + spawn: ((file: string, args: string[], options: Record) => { + calls.push({ file, args, options }); + const child = new EventEmitter(); + queueMicrotask(() => child.emit("exit", 7, null)); + return child; + }) as typeof spawn, + }; + return { calls, boundary, deps }; +} + +test("the selected shim backing waits for trust, then spawns the canonical binary", async () => { + let finish!: (reason: string | null) => void; + let entered!: () => void; + const started = new Promise(resolve => { entered = resolve; }); + const trust = new Promise(resolve => { finish = resolve; }); + const h = harness(async (root, path) => { expect(root).toBe(bundle); expect(path).toBe(binary); entered(); return trust; }); + const env = { PATH: "/usr/bin:/bin", OCX_BOUNDARY_FIXTURE: "preserved" }; + const running = spawnCodex(["--version"], env, h.deps); + await started; + expect(h.calls).toHaveLength(0); + finish(null); + expect(await running).toBe(7); + expect(h.calls).toHaveLength(1); + expect(h.calls[0]).toEqual({ file: binary, args: ["--version"], options: { stdio: "inherit", env } }); +}); + +test("each invocation rechecks trust and a revoked signature starts no replacement", async () => { + let count = 0; + const h = harness(async () => ++count === 1 ? null : "signature changed"); + expect(await spawnCodex(["--help"], {}, h.deps)).toBe(7); + expect(await spawnCodex(["--help"], {}, h.deps)).toBe(1); + expect(count).toBe(2); + expect(h.calls).toHaveLength(1); + expect(errors.mock.calls.flat().join(" ")).toContain("signature changed"); +}); + +test("a failed or thrown trust check never reaches spawn", async () => { + for (const check of [async () => "foreign owner", async () => { throw new Error("inspection unavailable"); }]) { + const h = harness(check); + expect(await spawnCodex(["app-server"], {}, h.deps)).toBe(1); + expect(h.calls).toHaveLength(0); + } +}); + +test("non-bundle targets preserve arguments and do not invoke bundle verification", async () => { + const h = harness(async () => { throw new Error("must not inspect an ordinary CLI"); }); + h.deps.findOnPath = () => "/usr/bin/codex"; + h.deps.inspectShim = () => ({ status: "absent" }) as ReturnType>; + expect(await spawnCodex(["exec", "argument with spaces"], {}, h.deps)).toBe(7); + expect(h.calls[0].file).toBe("/usr/bin/codex"); + expect(h.calls[0].args).toEqual(["exec", "argument with spaces"]); +}); + +test("Windows CMD invocation keeps its interpreter options and launch environment", async () => { + const h = harness(async () => { throw new Error("not macOS"); }); + const command = "C:/test/codex.cmd"; + h.deps.findOnPath = () => command; + h.deps.inspectShim = () => ({ status: "absent" }) as ReturnType>; + h.deps.execBoundary = { platform: "win32", exists: () => true }; + const env = { ComSpec: "C:/Windows/System32/cmd.exe", PATH: "", PATHEXT: ".CMD;.EXE" }; + expect(await spawnCodex(["--version"], env, h.deps)).toBe(7); + expect(h.calls[0].file).toBe(env.ComSpec); + expect(h.calls[0].args.slice(0, 3)).toEqual(["/d", "/s", "/c"]); + expect(h.calls[0].options.windowsVerbatimArguments).toBe(true); +}); + +test("hidden validator emits only the trusted target and repeats the check", async () => { + let allowed = true, checks = 0; + const h = harness(async () => { checks++; return allowed ? null : "revoked"; }); + expect(await handleInternalCommand(["codex-exec-target", "--", alias], h.boundary)).toBe(0); + expect(logs.mock.calls).toEqual([[binary]]); + logs.mockClear(); allowed = false; + expect(await handleInternalCommand(["codex-exec-target", "--", alias], h.boundary)).toBe(1); + expect(logs.mock.calls).toHaveLength(0); + expect(checks).toBe(2); +}); + +test("hidden validator rejects malformed argv and line-oriented path ambiguity", async () => { + for (const args of [[], ["--"], ["--", ""], ["--", "a\nb"], ["--", "a\rb"], ["--", alias, "extra"]]) { + expect(await handleInternalCommand(["codex-exec-target", ...args])).toBe(2); + } + expect(logs.mock.calls).toHaveLength(0); +}); + +test("hidden validator refuses a canonical target containing a line break", async () => { + const h = harness(async () => null); + h.boundary.realpath = path => path === alias ? `${binary}\nextra` : path; + expect(await handleInternalCommand(["codex-exec-target", "--", alias], h.boundary)).toBe(1); + expect(logs.mock.calls).toHaveLength(0); +}); + +test("validator admission skips root shim repair even for malformed arguments", () => { + let reads = 0, restores = 0; + for (const suffix of [[], ["--", alias], ["--", alias, "extra"]]) { + maybeAutoRestoreCodexShim("internal", ["internal", "codex-exec-target", ...suffix], { + env: {}, warn: () => { throw new Error("unexpected repair warning"); }, + readConfig: () => { reads++; throw new Error("unexpected configuration read"); }, + restore: () => { restores++; throw new Error("unexpected shim repair"); }, + }); + } + expect(reads).toBe(0); expect(restores).toBe(0); + expect(skipsCodexShimAutoRestore("internal", ["internal", "chatgpt-app-server-filter"])).toBe(false); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 01626d51d39..f56e1f96bf8 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -586,6 +586,8 @@ "codex-exec-invocation.test.ts": "codex-integration", "exec-trust-boundary.test.ts": "codex-integration", "exec-boundary-launch.test.ts": "codex-integration", + "exec-launcher-boundary.test.ts": "codex-integration", + "desktop-app-server-exec-boundary.test.ts": "clients", "exec-known-app-bundles.test.ts": "codex-integration", "messaging-local-discovery.test.ts": "codex-integration", "messaging-local-cli.test.ts": "codex-integration", From 1e496e07338307c426321ac0859f0611571b380a Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Wed, 28 Oct 2026 05:51:46 +0000 Subject: [PATCH 2/8] docs(cli): describe internal command validation contract --- src/cli/internal-command.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/cli/internal-command.ts b/src/cli/internal-command.ts index b7b096ee771..c29f5d5f73c 100644 --- a/src/cli/internal-command.ts +++ b/src/cli/internal-command.ts @@ -25,6 +25,10 @@ function optionValue(args: readonly string[], name: string): string | undefined return index >= 0 ? args[index + 1] : undefined; } +/** + * Dispatch internal helpers with strict argument checks and command-specific exit codes. + * The executable-target helper writes one trusted path to stdout only after validation. + */ export async function handleInternalCommand(args: readonly string[], execBoundary: CodexExecBoundaryAsyncDeps = {}): Promise { const sub = args[0]; if (sub === "codex-exec-target") { From 31c1e0f867519e7188622a7cbf8b01e52ac84e98 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:14:33 +0900 Subject: [PATCH 3/8] fix(chatgpt): retain discovered bundle root at every launch --- scripts/test-layout/layout-exec-boundary.json | 1 + .../app-server-shim/bundle-trust-policy.ts | 5 + src/chatgpt/app-server-shim/launcher.ts | 28 ++- src/cli/chatgpt-command.ts | 2 +- src/cli/internal-command.ts | 11 +- src/codex/exec-boundary.ts | 9 +- structure/cli-management.md | 2 +- structure/clients/chatgpt-desktop.md | 16 +- .../desktop-app-server-shim-launcher.test.ts | 4 +- .../exec-renamed-app-bundle.test.ts | 164 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 11 files changed, 227 insertions(+), 16 deletions(-) create mode 100644 tests/codex-integration/exec-renamed-app-bundle.test.ts diff --git a/scripts/test-layout/layout-exec-boundary.json b/scripts/test-layout/layout-exec-boundary.json index e3cc45defa4..db38568e7b2 100644 --- a/scripts/test-layout/layout-exec-boundary.json +++ b/scripts/test-layout/layout-exec-boundary.json @@ -2,5 +2,6 @@ "exec-trust-boundary.test.ts": "codex-integration", "exec-boundary-launch.test.ts": "codex-integration", "exec-launcher-boundary.test.ts": "codex-integration", + "exec-renamed-app-bundle.test.ts": "codex-integration", "exec-known-app-bundles.test.ts": "codex-integration" } diff --git a/src/chatgpt/app-server-shim/bundle-trust-policy.ts b/src/chatgpt/app-server-shim/bundle-trust-policy.ts index 4bb2c1b93a6..b105b1b28b1 100644 --- a/src/chatgpt/app-server-shim/bundle-trust-policy.ts +++ b/src/chatgpt/app-server-shim/bundle-trust-policy.ts @@ -28,6 +28,11 @@ export function bundleTrustStat(path: string): ReturnType; +/** The inline bundle root is code: protect its file and directory entry like the bundle. */ +export function untrustedLauncherFilesystemReason(path: string, deps: FilesystemTrustDeps, existing = true): string | null { + return (existing ? ownedSafely(deps, path, "file") : null) ?? untrustedAncestorReason(path, deps); +} + function ownedSafely(deps: FilesystemTrustDeps, path: string, kind: "file" | "directory"): string | null { const s = deps.stat(path); if (!s) return "is missing"; diff --git a/src/chatgpt/app-server-shim/launcher.ts b/src/chatgpt/app-server-shim/launcher.ts index 7aa2d09cce5..6cddfd97c0f 100644 --- a/src/chatgpt/app-server-shim/launcher.ts +++ b/src/chatgpt/app-server-shim/launcher.ts @@ -3,6 +3,8 @@ import { randomBytes } from "node:crypto"; import { join, posix } from "node:path"; import { getConfigDir } from "../../config/paths"; import { selfLaunchArgv } from "../../lib/self-launch-argv"; +import { defaultBundleTrustDeps } from "./bundle-trust"; +import { untrustedLauncherFilesystemReason, type BundleTrustDeps } from "./bundle-trust-policy"; export const CHATGPT_APP_CODEX_BINARY = "/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex"; @@ -57,11 +59,12 @@ export function buildChatgptShimLauncher( argv: readonly string[], real = CHATGPT_APP_CODEX_BINARY, validator: readonly string[] = [process.execPath, ...selfLaunchArgv(["internal", "codex-exec-target"])], + bundleRoot = /^(.*?\.app)\/Contents\//i.exec(real)?.[1], ): string { return `#!/bin/bash # opencodex (experimental): ChatGPT app-server stdout passes through the quota-gate filter. REAL=${shellQuote(real)} -FILTER=(${argv.map(shellQuote).join(" ")}) +${bundleRoot === undefined ? "" : `BUNDLE_ROOT=${shellQuote(bundleRoot)}\n`}FILTER=(${argv.map(shellQuote).join(" ")}) VALIDATE=(${validator.map(shellQuote).join(" ")}) if [ ! -x "$REAL" ]; then echo "opencodex (experimental): the bundled app-server is no longer at $REAL; run 'ocx chatgpt launch' again or 'ocx chatgpt restore'." >&2 @@ -69,7 +72,7 @@ if [ ! -x "$REAL" ]; then fi validate_real() { local target - if [ ! -x "\${VALIDATE[0]}" ] || ! target=$("\${VALIDATE[@]}" -- "$REAL") || [ -z "$target" ]; then + if [ ! -x "\${VALIDATE[0]}" ] || ! target=$("\${VALIDATE[@]}" ${bundleRoot === undefined ? "" : '--bundle-root "$BUNDLE_ROOT" '}-- "$REAL") || [ -z "$target" ]; then echo "opencodex: app-server execution verification failed; run 'ocx chatgpt launch' again or 'ocx chatgpt restore'." >&2 return 126 fi @@ -90,17 +93,32 @@ exec "$REAL" "$@" * launcher, so an existing symbolic link is replaced rather than followed and a respawn during * the write never runs a half-written script. */ -export function writeChatgptShimLauncher(configDir = getConfigDir(), real = CHATGPT_APP_CODEX_BINARY): string { +export function writeChatgptShimLauncher( + configDir = getConfigDir(), real = CHATGPT_APP_CODEX_BINARY, + bundleRoot = /^(.*?\.app)\/Contents\//i.exec(real)?.[1], + filesystem: Pick | undefined = process.platform === "darwin" ? defaultBundleTrustDeps() : undefined, +): string { + if (!bundleRoot || !posix.isAbsolute(bundleRoot) || !real.startsWith(bundleRoot + "/Contents/")) { + throw new Error("the app-server launcher requires its discovered bundle root"); + } mkdirSync(configDir, { recursive: true }); const path = chatgptShimLauncherPath(configDir); const argv = [process.execPath, ...selfLaunchArgv(["internal", "chatgpt-app-server-filter"])]; const temp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`; + let created = false; + const assertFilesystem = (file: string, existing: boolean) => { + const reason = filesystem && untrustedLauncherFilesystemReason(file, filesystem, existing); + if (reason) throw new Error(`unsafe app-server launcher: ${reason}`); + }; try { - writeFileSync(temp, buildChatgptShimLauncher(argv, real), { mode: 0o755, flag: "wx" }); + assertFilesystem(temp, false); + writeFileSync(temp, buildChatgptShimLauncher(argv, real, undefined, bundleRoot), { mode: 0o755, flag: "wx" }); + created = true; chmodSync(temp, 0o755); + assertFilesystem(temp, true); renameSync(temp, path); } catch (error) { - try { unlinkSync(temp); } catch { /* the temp file was never created */ } + if (created) { try { unlinkSync(temp); } catch { /* preserve the publication error */ } } throw error; } return path; diff --git a/src/cli/chatgpt-command.ts b/src/cli/chatgpt-command.ts index 3006f44638c..0436b3033b1 100644 --- a/src/cli/chatgpt-command.ts +++ b/src/cli/chatgpt-command.ts @@ -164,7 +164,7 @@ CODEX_CLI_PATH launcher: ${app.shim === null ? "unknown" : app.shim ? "yes" : "n console.error("ChatGPT process inspection failed; no launcher was changed and no app was opened. Retry after resolving inspection."); return 1; } - if (binary) writeChatgptShimLauncher(undefined, binary); + if (binary) writeChatgptShimLauncher(undefined, binary, install.root); if (!(await quitApp(install, launcher))) { console.error("ChatGPT exit could not be confirmed; no new app was opened. Check the running app and retry."); return 1; diff --git a/src/cli/internal-command.ts b/src/cli/internal-command.ts index c29f5d5f73c..63e6a07776a 100644 --- a/src/cli/internal-command.ts +++ b/src/cli/internal-command.ts @@ -18,7 +18,7 @@ */ import type { CodexExecBoundaryAsyncDeps } from "../codex/exec-boundary"; -const USAGE = "Usage: ocx internal desktop-restart-handoff --plan | chatgpt-app-server-filter [--self-test] | codex-exec-target -- "; +const USAGE = "Usage: ocx internal desktop-restart-handoff --plan | chatgpt-app-server-filter [--self-test] | codex-exec-target [--bundle-root ] -- "; function optionValue(args: readonly string[], name: string): string | undefined { const index = args.indexOf(name); @@ -32,13 +32,18 @@ function optionValue(args: readonly string[], name: string): string | undefined export async function handleInternalCommand(args: readonly string[], execBoundary: CodexExecBoundaryAsyncDeps = {}): Promise { const sub = args[0]; if (sub === "codex-exec-target") { - if (args.length !== 3 || args[1] !== "--" || !args[2] || /[\r\n]/.test(args[2])) { + const bundled = args[1] === "--bundle-root"; + const root = bundled ? args[2] : undefined; + const separator = bundled ? 3 : 1; + const command = args[separator + 1]; + if (args.length !== separator + 2 || args[separator] !== "--" || !command || /[\r\n\0]/.test(command) + || (bundled && (!root || !root.startsWith("/") || /[\r\n\0]/.test(root)))) { console.error(USAGE); return 2; } try { const { assertCodexExecTargetTrustedAsync } = await import("../codex/exec-boundary"); - const target = await assertCodexExecTargetTrustedAsync(args[2], execBoundary); + const target = await assertCodexExecTargetTrustedAsync(command, bundled ? { ...execBoundary, expectedBundleRoot: root } : execBoundary); if (!target || /[\r\n]/.test(target)) throw new Error("invalid executable path"); console.log(target); return 0; diff --git a/src/codex/exec-boundary.ts b/src/codex/exec-boundary.ts index 26f869fdf27..503cc8ccaf2 100644 --- a/src/codex/exec-boundary.ts +++ b/src/codex/exec-boundary.ts @@ -53,6 +53,8 @@ export interface CodexExecBoundaryDeps { executable?: (path: string) => boolean; /** Canonicalizer; returns null when the path cannot be resolved. */ realpath?: (path: string) => string | null; + /** Discovered Desktop bundle identity, retained by its generated launcher across respawns. */ + expectedBundleRoot?: string; macBundleTrustReason?: (bundleRoot: string, binary: string) => string | null; } @@ -120,7 +122,10 @@ function resolveDarwinExecPath(command: string, deps: CodexExecBoundaryDeps): { */ function darwinBundleTarget(resolved: string, deps: CodexExecBoundaryDeps): { bundle: string; binary: string } | null { const realpath = deps.realpath ?? realpathOrNull; - const bundle = macCodexAppBundleFor(resolved, { env: deps.env, platform: deps.platform, realpath }); + if (deps.expectedBundleRoot !== undefined && !posix.isAbsolute(deps.expectedBundleRoot)) { + throw new CodexExecTrustError("the recorded app bundle must be an absolute path"); + } + const bundle = deps.expectedBundleRoot ?? macCodexAppBundleFor(resolved, { env: deps.env, platform: deps.platform, realpath }); if (!bundle) return null; const binary = realpath(resolved); if (binary === null) throw new CodexExecTrustError("cannot canonicalize the app-bundle exec target"); @@ -144,6 +149,7 @@ export function validatedCodexExecTarget( deps: CodexExecBoundaryDeps = {}, ): ValidatedCodexExecTarget { if ((deps.platform ?? process.platform) !== "darwin") { + if (deps.expectedBundleRoot !== undefined) throw new CodexExecTrustError("recorded app bundles require macOS verification"); return { invocation: codexExecInvocation(command, args, deps.platform ?? process.platform, { env: deps.env, exists: deps.exists }), macAppBundle: false }; } const resolved = resolveDarwinExecPath(command, deps); @@ -164,6 +170,7 @@ export async function validatedCodexExecTargetAsync( ): Promise { deps.signal?.throwIfAborted(); if ((deps.platform ?? process.platform) !== "darwin") { + if (deps.expectedBundleRoot !== undefined) throw new CodexExecTrustError("recorded app bundles require macOS verification"); return { invocation: codexExecInvocation(command, args, deps.platform ?? process.platform, { env: deps.env, exists: deps.exists }), macAppBundle: false }; } const resolved = resolveDarwinExecPath(command, deps); diff --git a/structure/cli-management.md b/structure/cli-management.md index c5d1577f132..9462598b528 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -34,7 +34,7 @@ For fresh credentials, the launcher resolves a native literal `CODEX_HOME` once `src/cli/codex-launcher.ts` implements `ocx codex` without rewriting routing. Native/foreign routes pass through; local/unknown routes attempt startup and warn on failure. Fast paths skip startup/token loading. A matched wrapper uses its backing directly. Unknown state, including a throwing inspector, requires a separate bypass check via `src/cli/codex-launch-command.ts`; managed Windows CMD wrappers require complete generated-template equality before the selected absolute path executes with bypass. Obsolete, unreadable or ambiguous wrappers and lookup errors refuse launch without exception contents. A missing executable instead reports the Codex install hint, including on help/version fast paths. This content check does not establish Windows backing provenance or prevent concurrent replacement. `src/cli/codex-launch-auth.ts` separately requires a parsed managed transport block matching this installation's loopback listener or persisted local client connection; names/comments alone grant no token. Profiles, distinct project layers scanned through the filesystem root, destination-changing argv and environment overrides prevent automatic loading. An existing ancestor config is excluded only when its realpath matches the home config; a different file or failed identity resolution refuses. Token-file overrides and management-token collisions remain refused; user exports are preserved. No token enters argv or diagnostics. -The `codex` namespace forwards all help arguments unchanged; `ocx help codex` retains wrapper help. It skips root shim repair so the routing-aware launcher owns the handoff. Every handoff, including help/version and passthrough, awaits the [execution trust contract](clients/chatgpt-desktop.md#codex-app-execution-trust) for the selected backing and spawns the returned invocation. Fast paths bypass startup and credential loading, not executable validation. The hidden `internal codex-exec-target` helper returns a single verified path for the generated app-server launcher, skips root shim auto-repair even on malformed arguments, and stays outside public capability discovery. +The `codex` namespace forwards all help arguments unchanged; `ocx help codex` retains wrapper help. It skips root shim repair so the routing-aware launcher owns the handoff. Every handoff, including help/version and passthrough, awaits the [execution trust contract](clients/chatgpt-desktop.md#codex-app-execution-trust) for the selected backing and spawns the returned invocation. Fast paths bypass startup and credential loading, not executable validation. The hidden `internal codex-exec-target` helper returns a single verified path for the generated app-server launcher; its strict `--bundle-root` mode retains the discovered root independent of bundle name and refuses malformed roots without falling back to generic classification. It skips root shim auto-repair even on malformed arguments, and stays outside public capability discovery. Forwarded `-c`/`--config` and `-p`/`--profile` overrides make effective routing unverified for automatic service-token injection. They retain original argv and user-exported credentials but never load a new token from disk. Single-word prompts require `--` so they cannot select an unknown native subcommand. Missing system files also require a protected namespace: the closest existing directory must prevent another user from creating the missing layer, and its ancestors must prevent replacement. A normal Windows ProgramData directory that grants Users directory creation cannot authorize an absent OpenAI/Codex namespace; the launcher withholds credentials without creating or changing that directory. macOS opens each validated entry without following its final link (O_SYMLINK for the link entry itself), keeps dev/ino as bigint and requires descriptor dev/ino/type to match its original lstat, and reads its ACL with acl_get_fd_np. Only NULL with immediately captured ENOENT on this descriptor query means no extended ACL; open/stat/identity/close failures and every other NULL/error refuse. Every opened descriptor closes in finally. For a present ACL it validates and enumerates every entry through the documented -1/EINVAL terminator. Deny-only and read-only allow entries are accepted; any allow granting write/add-file, append/add-subdirectory, delete, delete-child, attribute, extended-attribute, security or ownership mutation refuses regardless of earlier denies. Unknown tags and FFI/enumeration failures refuse. Forced managed-preference checks remain mandatory. Pure policy and Darwin-gated native ACL coverage lives in `tests/codex-integration/codex-launcher-darwin-acl.test.ts`; modeled IO regressions for all three walks live in `tests/codex-integration/codex-launcher-darwin-identity.test.ts`. Tests model a protected empty machine namespace inside their private fixture; they do not modify real system configuration. Windows fixtures probe ancestor ownership before positive assertions; an unavailable protected location requires a proved ownership refusal and an explicit skip reason. Negative mutations and no-token/zero-read assertions execute even with unsafe ancestors; credential-file negatives bypass destination verification independently. Native checks use production defaults. `src/cli/root.ts`, `src/cli/help.ts`, `src/cli/help-navigation.ts` diff --git a/structure/clients/chatgpt-desktop.md b/structure/clients/chatgpt-desktop.md index fd13ab10758..55293e7a081 100644 --- a/structure/clients/chatgpt-desktop.md +++ b/structure/clients/chatgpt-desktop.md @@ -14,8 +14,9 @@ internal command arguments. The launcher uses macOS and a successful filter self-test to select stdout filtering. Every invocation, including a Desktop respawn and the unfiltered branch, re-enters -`internal codex-exec-target -- ` immediately before shell exec. The helper applies -the execution trust contract below and returns the exact target to execute; the shell +`internal codex-exec-target --bundle-root -- ` immediately before shell exec. The helper applies +the execution trust contract below to the recorded discovered root, regardless of its name, +and returns the exact target to execute; the shell does not use `uname` as execution authority. Only stdout passes through the filter. Stdin, stderr, process identity and the real server's exit status retain the direct app/server relationship. @@ -63,6 +64,14 @@ OpenAI's. It writes the mode-0755 executable through an exclusive temp file and rename (never through a symbolic link), quits the bundle by id, waits for this user's instance to exit, then opens the same bundle path with the launcher in CODEX_CLI_PATH. The launcher itself exits 127 with a stderr hint when the recorded binary is gone. +The discovered root is a quoted literal in the launcher itself, with no metadata sidecar. +Before publication, the writer checks its parent/ancestor replacement permissions and the +new file's ownership, type and permissions using the shared POSIX filesystem policy. +An unsafe path refuses publication. The strict helper mode refuses a missing or empty root, +failed canonicalization or a target outside the recorded root's canonical `Contents/`. +It always verifies that root and binary, even if the bundle is renamed or its identifier changes. +Same-user processes, the installed CLI/runtime and the launcher code remain trusted; these +checks do not attest extended ACLs or make verification and exec an atomic operation. Restore uses the same trust policy for the bundle and its main app executable before quit or open. Both relaunch paths check ancestor ownership and POSIX replacement permissions through the filesystem root. Ancestors must be owned by this user or root; group/other write @@ -113,6 +122,7 @@ canonical output is refused. Ordinary CLI and third-party app paths retain their existing policy. Conventional automatic discovery remains limited to the two ChatGPT installation roots. Regression coverage is in `tests/codex-integration/exec-known-app-bundles.test.ts`, -`tests/codex-integration/exec-launcher-boundary.test.ts` and +`tests/codex-integration/exec-launcher-boundary.test.ts`, +`tests/codex-integration/exec-renamed-app-bundle.test.ts` and `tests/clients/desktop-app-server-exec-boundary.test.ts`. Portable injected-verifier and shell tests do not establish native macOS signature or real Desktop respawn acceptance. diff --git a/tests/clients/desktop-app-server-shim-launcher.test.ts b/tests/clients/desktop-app-server-shim-launcher.test.ts index a71fa031c6d..f26bd6354b1 100644 --- a/tests/clients/desktop-app-server-shim-launcher.test.ts +++ b/tests/clients/desktop-app-server-shim-launcher.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { chmodSync, lstatSync, mkdtempSync, readdirSync, readFileSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, lstatSync, mkdtempSync, readdirSync, readFileSync, realpathSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { OPENAI_TEAM_ID, untrustedChatgptBundleReason, type BundleTrustDeps } from "../../src/chatgpt/app-server-shim/bundle-trust"; import { tmpdir } from "node:os"; import { dirname, join, resolve, sep } from "node:path"; @@ -26,7 +26,7 @@ const executable = (dir: string, name: string, body: string) => { return path; }; function withDir(run: (dir: string) => void) { - const dir = mkdtempSync(join(tmpdir(), "ocx-shim-test-")); + const dir = realpathSync(mkdtempSync(join(tmpdir(), "ocx-shim-test-"))); try { run(dir); } finally { removeTreeWithRetry(dir); } } const internalArgs = ["internal", "chatgpt-app-server-filter"]; diff --git a/tests/codex-integration/exec-renamed-app-bundle.test.ts b/tests/codex-integration/exec-renamed-app-bundle.test.ts new file mode 100644 index 00000000000..1225a317937 --- /dev/null +++ b/tests/codex-integration/exec-renamed-app-bundle.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { handleInternalCommand } from "../../src/cli/internal-command"; +import { buildChatgptShimLauncher, writeChatgptShimLauncher, chatgptShimLauncherPath } from "../../src/chatgpt/app-server-shim/launcher"; +import { untrustedLauncherFilesystemReason, type BundleTrustDeps } from "../../src/chatgpt/app-server-shim/bundle-trust-policy"; +import { mkdtempSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { tmpdir } from "node:os"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const root = "/Volumes/Apps/ChatGPT Beta.app"; +const binary = `${root}/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex`; +let logs: ReturnType>; +let errors: ReturnType>; +beforeEach(() => { + logs = spyOn(console, "log").mockImplementation(() => {}); + errors = spyOn(console, "error").mockImplementation(() => {}); +}); +afterEach(() => { logs.mockRestore(); errors.mockRestore(); }); + +test("a recorded renamed bundle rechecks trust after revocation without a conventional alias", async () => { + let allowed = true; + const checked: string[][] = []; + const boundary = { + platform: "darwin" as const, executable: () => true, realpath: (path: string) => path, + expectedBundleRoot: root, + macBundleTrustReasonAsync: async (bundle: string, target: string) => { + checked.push([bundle, target]); + return allowed ? null : "signature revoked"; + }, + }; + expect(await handleInternalCommand(["codex-exec-target", "--", binary], boundary)).toBe(0); + expect(logs.mock.calls).toEqual([[binary]]); + logs.mockClear(); allowed = false; + expect(await handleInternalCommand(["codex-exec-target", "--", binary], boundary)).toBe(1); + expect(logs.mock.calls).toEqual([]); + expect(checked).toEqual([[root, binary], [root, binary]]); +}); + +test("launcher carries its outer renamed bundle root inline to the strict helper mode", () => { + const script = buildChatgptShimLauncher(["/filter"], binary, ["/ocx", "internal", "codex-exec-target"]); + expect(script).toContain(`BUNDLE_ROOT='${root}'`); + expect(script).toContain('--bundle-root "$BUNDLE_ROOT" -- "$REAL"'); +}); + +test("explicit bundle mode validates a renamed bundle and never accepts an empty root", async () => { + let checks = 0; + const boundary = { + platform: "darwin" as const, executable: () => true, realpath: (path: string) => path, + macBundleTrustReasonAsync: async (bundle: string, target: string) => { + checks++; expect(bundle).toBe(root); expect(target).toBe(binary); return null; + }, + }; + expect(await handleInternalCommand(["codex-exec-target", "--bundle-root", root, "--", binary], boundary)).toBe(0); + expect(checks).toBe(1); + for (const args of [ + ["--bundle-root", "", "--", binary], ["--bundle-root", root], + ["--bundle-root", root, "--", binary, "extra"], ["--bundle-root", `${root}\nextra`, "--", binary], + ]) expect(await handleInternalCommand(["codex-exec-target", ...args], boundary)).toBe(2); + expect(checks).toBe(1); +}); + +test("launcher helper argv rechecks the recorded root on a later respawn", async () => { + const args = ["codex-exec-target", "--bundle-root", root, "--", binary]; + let allowed = true, checks = 0; + const boundary = { + platform: "darwin" as const, executable: () => true, realpath: (path: string) => path, + macBundleTrustReasonAsync: async (bundle: string, target: string) => { + checks++; expect(bundle).toBe(root); expect(target).toBe(binary); + return allowed ? null : "signature revoked"; + }, + }; + expect(await handleInternalCommand(args, boundary)).toBe(0); + logs.mockClear(); allowed = false; + expect(await handleInternalCommand(args, boundary)).toBe(1); + expect(checks).toBe(2); expect(logs.mock.calls).toEqual([]); +}); + +for (const defect of ["missing root", "missing binary", "escape", "prefix sibling", "outside Contents"] as const) { + test(`recorded bundle refuses ${defect} before invoking trust`, async () => { + let checks = 0; + const boundary = { + platform: "darwin" as const, executable: () => true, + realpath: (path: string) => { + if (path === root && defect === "missing root" || path === binary && defect === "missing binary") return null; + if (path !== binary) return path; + return defect === "escape" ? "/opt/codex" : defect === "prefix sibling" ? `${root}-copy/Contents/codex` + : defect === "outside Contents" ? `${root}/codex` : path; + }, + macBundleTrustReasonAsync: async () => { checks++; return null; }, + }; + expect(await handleInternalCommand(["codex-exec-target", "--bundle-root", root, "--", binary], boundary)).toBe(1); + expect(checks).toBe(0); expect(logs.mock.calls).toEqual([]); + }); +} + +test("ordinary executable helper calls retain passthrough", async () => { + const target = "/opt/bin/codex"; + expect(await handleInternalCommand(["codex-exec-target", "--", target], { + platform: "darwin", executable: () => true, realpath: path => path, + macBundleTrustReasonAsync: async () => { throw new Error("ordinary CLI must not be checked"); }, + })).toBe(0); + expect(logs.mock.calls).toEqual([[target]]); +}); + +test("recorded bundle validation uses a consistent canonical root and target", async () => { + const canonicalRoot = "/private/Apps/ChatGPT Beta.app"; + const target = binary.replace(root, canonicalRoot); + expect(await handleInternalCommand(["codex-exec-target", "--bundle-root", root, "--", binary], { + platform: "darwin", executable: () => true, realpath: path => path.replace(root, canonicalRoot), + macBundleTrustReasonAsync: async (bundle, executable) => { + expect(bundle).toBe(canonicalRoot); expect(executable).toBe(target); return null; + }, + })).toBe(0); + expect(logs.mock.calls).toEqual([[target]]); +}); + +test("recorded bundle mode refuses on a host without macOS verification", async () => { + expect(await handleInternalCommand(["codex-exec-target", "--bundle-root", root, "--", binary], { platform: "linux" })).toBe(1); + expect(logs.mock.calls).toEqual([]); +}); + +for (const defect of ["none", "foreign file", "writable file", "link file", "foreign parent", "writable parent", "link parent"] as const) { + test(`inline root's launcher filesystem policy: ${defect}`, () => { + const path = "/owned/config/launcher.sh"; + const deps: Omit = { + uid: 501, + stat: entry => { + const bad = entry === (defect.endsWith("file") ? path : dirname(path)); + return { uid: bad && defect.startsWith("foreign") ? 502 : 501, + mode: bad && defect.startsWith("writable") ? 0o777 : 0o755, + isFile: entry === path, isDirectory: entry !== path, + isSymbolicLink: bad && defect.startsWith("link") }; + }, + }; + expect(untrustedLauncherFilesystemReason(path, deps) === null).toBe(defect === "none"); + }); +} + +for (const defect of ["none", "parent", "file"] as const) { + test(`writer protects recorded root before publication: ${defect}`, () => { + const dir = realpathSync(mkdtempSync(join(tmpdir(), "ocx-recorded-root-"))); + const launcher = chatgptShimLauncherPath(dir); + try { + writeFileSync(launcher, "original"); + const deps: Omit = { + uid: 501, + stat: entry => ({ uid: 501, + mode: defect === "parent" && entry === dir || defect === "file" && entry.endsWith(".tmp") ? 0o777 : 0o755, + isFile: entry.endsWith(".tmp"), isDirectory: !entry.endsWith(".tmp"), isSymbolicLink: false }), + }; + const write = () => writeChatgptShimLauncher(dir, binary, root, deps); + if (defect === "none") { + expect(write()).toBe(launcher); + const text = readFileSync(launcher, "utf8"); + expect(text).toContain(`BUNDLE_ROOT='${root}'`); + expect(text).toContain('--bundle-root "$BUNDLE_ROOT" -- "$REAL"'); + } else { + expect(write).toThrow("unsafe app-server launcher"); + expect(readFileSync(launcher, "utf8")).toBe("original"); + } + expect(readdirSync(dir)).toEqual(["chatgpt-codex-shim.sh"]); + } finally { removeTreeWithRetry(dir); } + }); +} diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index f56e1f96bf8..876f3f7d36a 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -587,6 +587,7 @@ "exec-trust-boundary.test.ts": "codex-integration", "exec-boundary-launch.test.ts": "codex-integration", "exec-launcher-boundary.test.ts": "codex-integration", + "exec-renamed-app-bundle.test.ts": "codex-integration", "desktop-app-server-exec-boundary.test.ts": "clients", "exec-known-app-bundles.test.ts": "codex-integration", "messaging-local-discovery.test.ts": "codex-integration", From 2143ac2ff972fe854bb843dc12f1f54e23da3569 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:02:01 +0900 Subject: [PATCH 4/8] fix(cli): validate renamed app bundles before Codex launch Check the canonical enclosing app bundle for every ocx codex handoff, including PATH aliases and recorded shim backings. Verify nested layouts against their outer root while preserving ordinary non-bundle launches and the Desktop launcher's strict recorded-root path. Add portable CLI revocation, renamed-bundle and alias regressions and document the launch policy in the shared contract and CLI references. Co-authored-by: Epinephrine --- .../content/docs/ko/reference/cli/agents.md | 2 + .../src/content/docs/reference/cli/agents.md | 2 + scripts/test-layout/layout-exec-boundary.json | 1 + src/cli/codex-launcher.ts | 2 +- src/codex/exec-boundary.ts | 16 ++-- structure/cli-management.md | 2 +- structure/clients/chatgpt-desktop.md | 10 ++- .../exec-cli-renamed-app-bundle.test.ts | 76 +++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 9 files changed, 102 insertions(+), 10 deletions(-) create mode 100644 tests/codex-integration/exec-cli-renamed-app-bundle.test.ts diff --git a/docs-site/src/content/docs/ko/reference/cli/agents.md b/docs-site/src/content/docs/ko/reference/cli/agents.md index e6186944fb4..da0380df1e5 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -193,6 +193,8 @@ family는 `opus`, `fable`, `sonnet`, `haiku`이며, 새 route는 `opus`에서 일치가 확인된(`matched`) autostart wrapper만 backing Codex를 직접 실행합니다. 검사 예외를 포함한 알 수 없는 상태는 검증된 bypass가 필요하며, 관리되는 Windows CMD wrapper는 현재 생성 템플릿 전체와 일치해야 `OCX_SHIM_BYPASS=1`로 실행합니다. 일반 npm 런처는 지원합니다. 검증할 수 없거나 오래된 wrapper는 종료 코드 1로 거부하며 Codex를 시작하지 않습니다. `ocx codex-shim install`로 갱신하세요. 이 실행은 shim을 수리하지 않으며, 내용 검사는 Windows backing 파일 출처나 동시 경로 교체를 보장하지 않습니다. +macOS에서는 도움말과 버전을 포함한 매 실행 시, 선택한 실행 파일의 정규 경로가 `.app` 번들 내부에 있으면 검증합니다. 이름을 바꾼 번들과 번들 내부를 가리키는 심볼릭 링크도 번들 및 실행 파일의 안전한 소유권, 권한, OpenAI 서명을 요구합니다. 검증 실패 시 Codex를 시작하지 않고 종료 코드 1을 반환합니다. npm 설치를 포함한 일반 번들 외부 실행 파일의 동작은 유지됩니다. + 런처 도움말은 `ocx help codex`, Codex 도움말은 `ocx codex --help`입니다. 이후 plain `codex`의 동작은 기존과 같습니다. ### `ocx opencode [opencode args...]` diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 0e2d9082cbc..8ea423fad35 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -994,6 +994,8 @@ A missing `OPENCODEX_API_AUTH_TOKEN` is read from this installation's `service-a Only a matched autostart wrapper launches its backing Codex directly. Unknown state, including an inspection exception, requires a verified bypass; Windows CMD managed wrappers must match the complete current generated template before the selected absolute command runs with `OCX_SHIM_BYPASS=1`. Ordinary npm launchers remain supported. Unverifiable or obsolete wrappers refuse with exit 1 and start no Codex; run `ocx codex-shim install` to refresh them. This launch does not repair shims. The content check does not establish Windows backing-file provenance or prevent concurrent pathname replacement. +On macOS, every launch, including help/version, verifies the selected executable when its canonical location is inside any `.app` bundle. Renamed bundles and symlinks into bundles require the same safe ownership, permissions and OpenAI signatures for the bundle and executable. Failed verification exits 1 without starting Codex. Ordinary non-bundle executables, including npm installations, retain their behavior. + Use `ocx help codex` for launcher help and `ocx codex --help` for Codex help. Launching plain `codex` later retains its existing behavior. ### `ocx opencode [opencode args...]` diff --git a/scripts/test-layout/layout-exec-boundary.json b/scripts/test-layout/layout-exec-boundary.json index db38568e7b2..f04babff65e 100644 --- a/scripts/test-layout/layout-exec-boundary.json +++ b/scripts/test-layout/layout-exec-boundary.json @@ -2,6 +2,7 @@ "exec-trust-boundary.test.ts": "codex-integration", "exec-boundary-launch.test.ts": "codex-integration", "exec-launcher-boundary.test.ts": "codex-integration", + "exec-cli-renamed-app-bundle.test.ts": "codex-integration", "exec-renamed-app-bundle.test.ts": "codex-integration", "exec-known-app-bundles.test.ts": "codex-integration" } diff --git a/src/cli/codex-launcher.ts b/src/cli/codex-launcher.ts index 287266ef967..f9550050296 100644 --- a/src/cli/codex-launcher.ts +++ b/src/cli/codex-launcher.ts @@ -275,7 +275,7 @@ export async function spawnCodex(args: readonly string[], env: CodexLaunchEnv, d let launch: CodexLaunchCommand, inv: Awaited>; try { launch = resolveCodexLaunchCommand(env, deps); - inv = await validatedCodexExecInvocationAsync(launch.command, args, { ...deps.execBoundary, env: launch.env as NodeJS.ProcessEnv }); + inv = await validatedCodexExecInvocationAsync(launch.command, args, { ...deps.execBoundary, verifyAnyAppBundle: true, env: launch.env as NodeJS.ProcessEnv }); } catch (error) { console.error(error instanceof CodexNotFoundError ? CODEX_INSTALL_HINT : `❌ Cannot launch codex: ${error instanceof Error ? error.message : "executable verification failed"}`); return 1; diff --git a/src/codex/exec-boundary.ts b/src/codex/exec-boundary.ts index 503cc8ccaf2..0c6532d5eaf 100644 --- a/src/codex/exec-boundary.ts +++ b/src/codex/exec-boundary.ts @@ -20,8 +20,9 @@ import { * known-Codex-bundle classifier, and — when the lexical or canonical path sits * inside a ChatGPT.app or Codex.app bundle at any location — checked by the shared * signature/ownership policy before the same canonical target is returned for - * execution. Anything else, including binaries inside third-party app bundles, - * is passed through to the shared launcher unchanged. + * execution. CLI handoffs additionally classify any enclosing canonical .app, + * independent of bundle name or identifier. Other callers retain the known-app + * policy; non-bundle executables pass through to the shared launcher unchanged. * * The check is one-shot per invocation and deliberately not memoized: * directory-service group membership, signature state, and directory-entry @@ -53,6 +54,8 @@ export interface CodexExecBoundaryDeps { executable?: (path: string) => boolean; /** Canonicalizer; returns null when the path cannot be resolved. */ realpath?: (path: string) => string | null; + /** CLI handoffs verify any enclosing app, without trusting mutable bundle identity metadata. */ + verifyAnyAppBundle?: boolean; /** Discovered Desktop bundle identity, retained by its generated launcher across respawns. */ expectedBundleRoot?: string; macBundleTrustReason?: (bundleRoot: string, binary: string) => string | null; @@ -115,7 +118,7 @@ function resolveDarwinExecPath(command: string, deps: CodexExecBoundaryDeps): { /** * Canonical (bundleRoot, binary) pair when the resolved path classifies into a - * known Codex desktop bundle, else null. The pair is always consistent: + * known Codex desktop bundle (or any enclosing app for CLI handoffs), else null. The pair is always consistent: * both halves are canonicalized together so a symlinked HOME or a PATH alias * cannot split the containment check, and a bundle member that cannot be * canonicalized refuses rather than escaping the known-bundle classification. @@ -125,9 +128,12 @@ function darwinBundleTarget(resolved: string, deps: CodexExecBoundaryDeps): { bu if (deps.expectedBundleRoot !== undefined && !posix.isAbsolute(deps.expectedBundleRoot)) { throw new CodexExecTrustError("the recorded app bundle must be an absolute path"); } - const bundle = deps.expectedBundleRoot ?? macCodexAppBundleFor(resolved, { env: deps.env, platform: deps.platform, realpath }); - if (!bundle) return null; const binary = realpath(resolved); + // Use the outer canonical app for nested layouts, protecting its signature too. + // A lexical app whose target cannot be canonicalized still enters the refusal path. + const enclosing = deps.verifyAnyAppBundle ? /^(.*?\/[^/]*\.app)(?:\/|$)/i.exec(binary ?? resolved)?.[1] : undefined; + const bundle = deps.expectedBundleRoot ?? enclosing ?? macCodexAppBundleFor(resolved, { env: deps.env, platform: deps.platform, realpath }); + if (!bundle) return null; if (binary === null) throw new CodexExecTrustError("cannot canonicalize the app-bundle exec target"); const canonicalBundle = realpath(bundle); if (canonicalBundle === null) throw new CodexExecTrustError("cannot canonicalize the app bundle"); diff --git a/structure/cli-management.md b/structure/cli-management.md index 9462598b528..67bbeddc362 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -34,7 +34,7 @@ For fresh credentials, the launcher resolves a native literal `CODEX_HOME` once `src/cli/codex-launcher.ts` implements `ocx codex` without rewriting routing. Native/foreign routes pass through; local/unknown routes attempt startup and warn on failure. Fast paths skip startup/token loading. A matched wrapper uses its backing directly. Unknown state, including a throwing inspector, requires a separate bypass check via `src/cli/codex-launch-command.ts`; managed Windows CMD wrappers require complete generated-template equality before the selected absolute path executes with bypass. Obsolete, unreadable or ambiguous wrappers and lookup errors refuse launch without exception contents. A missing executable instead reports the Codex install hint, including on help/version fast paths. This content check does not establish Windows backing provenance or prevent concurrent replacement. `src/cli/codex-launch-auth.ts` separately requires a parsed managed transport block matching this installation's loopback listener or persisted local client connection; names/comments alone grant no token. Profiles, distinct project layers scanned through the filesystem root, destination-changing argv and environment overrides prevent automatic loading. An existing ancestor config is excluded only when its realpath matches the home config; a different file or failed identity resolution refuses. Token-file overrides and management-token collisions remain refused; user exports are preserved. No token enters argv or diagnostics. -The `codex` namespace forwards all help arguments unchanged; `ocx help codex` retains wrapper help. It skips root shim repair so the routing-aware launcher owns the handoff. Every handoff, including help/version and passthrough, awaits the [execution trust contract](clients/chatgpt-desktop.md#codex-app-execution-trust) for the selected backing and spawns the returned invocation. Fast paths bypass startup and credential loading, not executable validation. The hidden `internal codex-exec-target` helper returns a single verified path for the generated app-server launcher; its strict `--bundle-root` mode retains the discovered root independent of bundle name and refuses malformed roots without falling back to generic classification. It skips root shim auto-repair even on malformed arguments, and stays outside public capability discovery. +The `codex` namespace forwards all help arguments unchanged; `ocx help codex` retains wrapper help. It skips root shim repair so the routing-aware launcher owns the handoff. Every handoff, including help/version and passthrough, awaits the [execution trust contract](clients/chatgpt-desktop.md#codex-app-execution-trust) for the selected backing and spawns the returned invocation. On macOS, CLI handoffs verify any enclosing `.app` at the canonical target location, including renamed bundles and symlink targets; nested apps retain the outer bundle root. Fast paths bypass startup and credential loading, not executable validation. The hidden `internal codex-exec-target` helper returns a single verified path for the generated app-server launcher; its strict `--bundle-root` mode retains the discovered root independent of bundle name and refuses malformed roots without falling back to generic classification. It skips root shim auto-repair even on malformed arguments, and stays outside public capability discovery. Forwarded `-c`/`--config` and `-p`/`--profile` overrides make effective routing unverified for automatic service-token injection. They retain original argv and user-exported credentials but never load a new token from disk. Single-word prompts require `--` so they cannot select an unknown native subcommand. Missing system files also require a protected namespace: the closest existing directory must prevent another user from creating the missing layer, and its ancestors must prevent replacement. A normal Windows ProgramData directory that grants Users directory creation cannot authorize an absent OpenAI/Codex namespace; the launcher withholds credentials without creating or changing that directory. macOS opens each validated entry without following its final link (O_SYMLINK for the link entry itself), keeps dev/ino as bigint and requires descriptor dev/ino/type to match its original lstat, and reads its ACL with acl_get_fd_np. Only NULL with immediately captured ENOENT on this descriptor query means no extended ACL; open/stat/identity/close failures and every other NULL/error refuse. Every opened descriptor closes in finally. For a present ACL it validates and enumerates every entry through the documented -1/EINVAL terminator. Deny-only and read-only allow entries are accepted; any allow granting write/add-file, append/add-subdirectory, delete, delete-child, attribute, extended-attribute, security or ownership mutation refuses regardless of earlier denies. Unknown tags and FFI/enumeration failures refuse. Forced managed-preference checks remain mandatory. Pure policy and Darwin-gated native ACL coverage lives in `tests/codex-integration/codex-launcher-darwin-acl.test.ts`; modeled IO regressions for all three walks live in `tests/codex-integration/codex-launcher-darwin-identity.test.ts`. Tests model a protected empty machine namespace inside their private fixture; they do not modify real system configuration. Windows fixtures probe ancestor ownership before positive assertions; an unavailable protected location requires a proved ownership refusal and an explicit skip reason. Negative mutations and no-token/zero-read assertions execute even with unsafe ancestors; credential-file negatives bypass destination verification independently. Native checks use production defaults. `src/cli/root.ts`, `src/cli/help.ts`, `src/cli/help-navigation.ts` diff --git a/structure/clients/chatgpt-desktop.md b/structure/clients/chatgpt-desktop.md index 55293e7a081..5cad12f947e 100644 --- a/structure/clients/chatgpt-desktop.md +++ b/structure/clients/chatgpt-desktop.md @@ -115,14 +115,18 @@ Canonicalization or verification failure refuses execution. This covers environm commands and catalog, prompt, login, refresh and remote-workspace consumers. The `src/cli/codex-launcher.ts` handoff validates the selected shim backing on routed, passthrough and help/version fast paths, preserving the validated invocation's argv, -environment and Windows interpreter options. Generated app-server launchers use the +environment and Windows interpreter options. This CLI handoff additionally verifies every +enclosing `.app` at the canonical executable location, independent of its name or mutable +bundle identifier. Nested apps use the outer bundle root so its signature is checked too; +a lexical app member that cannot be canonicalized refuses. Generated app-server launchers use the hidden validator in `src/cli/internal-command.ts` on each execution; this command skips root shim auto-repair, its stdout is a single path, and newline-bearing input or -canonical output is refused. Ordinary CLI and -third-party app paths retain their existing policy. Conventional automatic discovery remains +canonical output is refused. Ordinary non-bundle CLI paths retain their existing policy; +third-party app paths retain their policy outside the stricter CLI handoff. Conventional automatic discovery remains limited to the two ChatGPT installation roots. Regression coverage is in `tests/codex-integration/exec-known-app-bundles.test.ts`, `tests/codex-integration/exec-launcher-boundary.test.ts`, +`tests/codex-integration/exec-cli-renamed-app-bundle.test.ts`, `tests/codex-integration/exec-renamed-app-bundle.test.ts` and `tests/clients/desktop-app-server-exec-boundary.test.ts`. Portable injected-verifier and shell tests do not establish native macOS signature or real Desktop respawn acceptance. diff --git a/tests/codex-integration/exec-cli-renamed-app-bundle.test.ts b/tests/codex-integration/exec-cli-renamed-app-bundle.test.ts new file mode 100644 index 00000000000..6e3db0ac335 --- /dev/null +++ b/tests/codex-integration/exec-cli-renamed-app-bundle.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import type { spawn } from "node:child_process"; +import { spawnCodex, type CodexSpawnDeps } from "../../src/cli/codex-launcher"; + +const root = "/opt/OpenAI/ChatGPT Beta.app"; +const binary = `${root}/Contents/Resources/codex`; +const alias = "/usr/local/bin/codex"; +let errors: ReturnType>; +beforeEach(() => { errors = spyOn(console, "error").mockImplementation(() => {}); }); +afterEach(() => { errors.mockRestore(); }); + +function harness(command: string, backing: boolean, target = binary) { + let allowed = true; + const checks: string[][] = [], spawns: string[] = []; + const deps: CodexSpawnDeps = { + findOnPath: () => backing ? "/owned/codex-wrapper" : command, + inspectShim: () => (backing + ? { status: "matched", selectedRole: "wrapper", backingPath: command } + : { status: "absent" }) as ReturnType>, + execBoundary: { + platform: "darwin", executable: () => true, + // Model a PATH symlink to the canonical member; no conventional bundle alias exists. + realpath: path => path === command ? target : path, + macBundleTrustReasonAsync: async (bundle, executable) => { + checks.push([bundle, executable]); + return allowed ? null : "signature revoked"; + }, + }, + spawn: ((file: string) => { + spawns.push(file); + const child = new EventEmitter(); + queueMicrotask(() => child.emit("exit", 0, null)); + return child; + }) as typeof spawn, + }; + return { deps, checks, spawns, revoke: () => { allowed = false; } }; +} + +for (const backing of [false, true]) for (const symlink of [false, true]) { + test(`CLI renamed bundle rechecks revocation: shim backing=${backing}, symlink=${symlink}`, async () => { + const h = harness(symlink ? alias : binary, backing); + expect(await spawnCodex(["--version"], {}, h.deps)).toBe(0); + h.revoke(); + expect(await spawnCodex(["--version"], {}, h.deps)).toBe(1); + expect(h.checks).toEqual([[root, binary], [root, binary]]); + expect(h.spawns).toEqual([binary]); + expect(errors.mock.calls.flat().join(" ")).toContain("signature revoked"); + }); +} + +test("CLI validates the outer renamed root for a nested app executable", async () => { + const nested = `${root}/Contents/Resources/CodexCLI.app/Contents/MacOS/codex`; + const h = harness(alias, true, nested); + h.revoke(); + expect(await spawnCodex(["app-server"], {}, h.deps)).toBe(1); + expect(h.checks).toEqual([[root, nested]]); + expect(h.spawns).toEqual([]); +}); + +test("CLI refuses an unresolved renamed bundle member before trust or spawn", async () => { + const h = harness(binary, false); + h.deps.execBoundary!.realpath = path => path === binary ? null : path; + expect(await spawnCodex(["--help"], {}, h.deps)).toBe(1); + expect(h.checks).toEqual([]); + expect(h.spawns).toEqual([]); +}); + +test("CLI ordinary executable aliases still bypass bundle verification", async () => { + const target = "/opt/npm/bin/codex"; + const h = harness(alias, true, target); + h.revoke(); + expect(await spawnCodex(["--help"], {}, h.deps)).toBe(0); + expect(h.checks).toEqual([]); + expect(h.spawns).toEqual([alias]); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 876f3f7d36a..aec281aa027 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -587,6 +587,7 @@ "exec-trust-boundary.test.ts": "codex-integration", "exec-boundary-launch.test.ts": "codex-integration", "exec-launcher-boundary.test.ts": "codex-integration", + "exec-cli-renamed-app-bundle.test.ts": "codex-integration", "exec-renamed-app-bundle.test.ts": "codex-integration", "desktop-app-server-exec-boundary.test.ts": "clients", "exec-known-app-bundles.test.ts": "codex-integration", From a0dc31dd6d41fb8b4afc7e4163a015f482a46d1a Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:20:48 +0900 Subject: [PATCH 5/8] fix(codex): retain lexical app bundle containment --- .../content/docs/ko/reference/cli/agents.md | 2 +- .../src/content/docs/reference/cli/agents.md | 2 +- scripts/test-layout/layout-exec-boundary.json | 1 + src/codex/exec-boundary.ts | 18 ++- structure/clients/chatgpt-desktop.md | 8 +- .../exec-cli-bundle-containment.test.ts | 105 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 7 files changed, 127 insertions(+), 10 deletions(-) create mode 100644 tests/codex-integration/exec-cli-bundle-containment.test.ts diff --git a/docs-site/src/content/docs/ko/reference/cli/agents.md b/docs-site/src/content/docs/ko/reference/cli/agents.md index da0380df1e5..102cff039a0 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -193,7 +193,7 @@ family는 `opus`, `fable`, `sonnet`, `haiku`이며, 새 route는 `opus`에서 일치가 확인된(`matched`) autostart wrapper만 backing Codex를 직접 실행합니다. 검사 예외를 포함한 알 수 없는 상태는 검증된 bypass가 필요하며, 관리되는 Windows CMD wrapper는 현재 생성 템플릿 전체와 일치해야 `OCX_SHIM_BYPASS=1`로 실행합니다. 일반 npm 런처는 지원합니다. 검증할 수 없거나 오래된 wrapper는 종료 코드 1로 거부하며 Codex를 시작하지 않습니다. `ocx codex-shim install`로 갱신하세요. 이 실행은 shim을 수리하지 않으며, 내용 검사는 Windows backing 파일 출처나 동시 경로 교체를 보장하지 않습니다. -macOS에서는 도움말과 버전을 포함한 매 실행 시, 선택한 실행 파일의 정규 경로가 `.app` 번들 내부에 있으면 검증합니다. 이름을 바꾼 번들과 번들 내부를 가리키는 심볼릭 링크도 번들 및 실행 파일의 안전한 소유권, 권한, OpenAI 서명을 요구합니다. 검증 실패 시 Codex를 시작하지 않고 종료 코드 1을 반환합니다. npm 설치를 포함한 일반 번들 외부 실행 파일의 동작은 유지됩니다. +macOS에서는 도움말과 버전을 포함한 매 실행 시, 선택한 실행 파일의 경로나 정규 경로가 `.app` 번들 내부에 있으면 검증합니다. 이름을 바꾼 번들과 번들 내부를 가리키는 심볼릭 링크도 번들 및 실행 파일의 안전한 소유권, 권한, OpenAI 서명을 요구합니다. 선택한 번들 구성원은 실행 파일, 중간 디렉터리 또는 번들 전체의 심볼릭 링크를 거쳐도 해당 번들의 정규 `Contents/` 내부에 있어야 합니다. 검증 실패 시 Codex를 시작하지 않고 종료 코드 1을 반환합니다. npm 설치를 포함한 일반 번들 외부 실행 파일의 동작은 유지됩니다. 런처 도움말은 `ocx help codex`, Codex 도움말은 `ocx codex --help`입니다. 이후 plain `codex`의 동작은 기존과 같습니다. diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 8ea423fad35..293a88485fa 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -994,7 +994,7 @@ A missing `OPENCODEX_API_AUTH_TOKEN` is read from this installation's `service-a Only a matched autostart wrapper launches its backing Codex directly. Unknown state, including an inspection exception, requires a verified bypass; Windows CMD managed wrappers must match the complete current generated template before the selected absolute command runs with `OCX_SHIM_BYPASS=1`. Ordinary npm launchers remain supported. Unverifiable or obsolete wrappers refuse with exit 1 and start no Codex; run `ocx codex-shim install` to refresh them. This launch does not repair shims. The content check does not establish Windows backing-file provenance or prevent concurrent pathname replacement. -On macOS, every launch, including help/version, verifies the selected executable when its canonical location is inside any `.app` bundle. Renamed bundles and symlinks into bundles require the same safe ownership, permissions and OpenAI signatures for the bundle and executable. Failed verification exits 1 without starting Codex. Ordinary non-bundle executables, including npm installations, retain their behavior. +On macOS, every launch, including help/version, verifies the selected executable when its selected path or canonical location is inside any `.app` bundle. Renamed bundles and symlinks into bundles require the same safe ownership, permissions and OpenAI signatures for the bundle and executable. A selected bundle member must stay inside that bundle's canonical `Contents/`, including through executable, directory or whole-bundle symlinks. Failed verification exits 1 without starting Codex. Ordinary non-bundle executables, including npm installations, retain their behavior. Use `ocx help codex` for launcher help and `ocx codex --help` for Codex help. Launching plain `codex` later retains its existing behavior. diff --git a/scripts/test-layout/layout-exec-boundary.json b/scripts/test-layout/layout-exec-boundary.json index f04babff65e..7ca28c34045 100644 --- a/scripts/test-layout/layout-exec-boundary.json +++ b/scripts/test-layout/layout-exec-boundary.json @@ -3,6 +3,7 @@ "exec-boundary-launch.test.ts": "codex-integration", "exec-launcher-boundary.test.ts": "codex-integration", "exec-cli-renamed-app-bundle.test.ts": "codex-integration", + "exec-cli-bundle-containment.test.ts": "codex-integration", "exec-renamed-app-bundle.test.ts": "codex-integration", "exec-known-app-bundles.test.ts": "codex-integration" } diff --git a/src/codex/exec-boundary.ts b/src/codex/exec-boundary.ts index 0c6532d5eaf..9db1b9291e8 100644 --- a/src/codex/exec-boundary.ts +++ b/src/codex/exec-boundary.ts @@ -20,7 +20,7 @@ import { * known-Codex-bundle classifier, and — when the lexical or canonical path sits * inside a ChatGPT.app or Codex.app bundle at any location — checked by the shared * signature/ownership policy before the same canonical target is returned for - * execution. CLI handoffs additionally classify any enclosing canonical .app, + * execution. CLI handoffs additionally classify any enclosing lexical or canonical .app, * independent of bundle name or identifier. Other callers retain the known-app * policy; non-bundle executables pass through to the shared launcher unchanged. * @@ -129,10 +129,11 @@ function darwinBundleTarget(resolved: string, deps: CodexExecBoundaryDeps): { bu throw new CodexExecTrustError("the recorded app bundle must be an absolute path"); } const binary = realpath(resolved); - // Use the outer canonical app for nested layouts, protecting its signature too. - // A lexical app whose target cannot be canonicalized still enters the refusal path. - const enclosing = deps.verifyAnyAppBundle ? /^(.*?\/[^/]*\.app)(?:\/|$)/i.exec(binary ?? resolved)?.[1] : undefined; - const bundle = deps.expectedBundleRoot ?? enclosing ?? macCodexAppBundleFor(resolved, { env: deps.env, platform: deps.platform, realpath }); + const enclosingApp = (path: string) => /^(.*?\/[^/]*\.app)(?:\/|$)/i.exec(path)?.[1]; + const lexicalBundle = deps.verifyAnyAppBundle && deps.expectedBundleRoot === undefined ? enclosingApp(resolved) : undefined; + // Verify the outer canonical app for nested layouts, but retain lexical containment. + const enclosing = deps.verifyAnyAppBundle && binary !== null ? enclosingApp(binary) : undefined; + const bundle = deps.expectedBundleRoot ?? enclosing ?? lexicalBundle ?? macCodexAppBundleFor(resolved, { env: deps.env, platform: deps.platform, realpath }); if (!bundle) return null; if (binary === null) throw new CodexExecTrustError("cannot canonicalize the app-bundle exec target"); const canonicalBundle = realpath(bundle); @@ -140,6 +141,13 @@ function darwinBundleTarget(resolved: string, deps: CodexExecBoundaryDeps): { bu if (!binary.startsWith(canonicalBundle + "/Contents/")) { throw new CodexExecTrustError("the app-bundle exec target is outside bundle Contents"); } + if (lexicalBundle && lexicalBundle !== bundle) { + const canonicalLexicalBundle = realpath(lexicalBundle); + if (canonicalLexicalBundle === null) throw new CodexExecTrustError("cannot canonicalize the app bundle"); + if (!binary.startsWith(canonicalLexicalBundle + "/Contents/")) { + throw new CodexExecTrustError("the app-bundle exec target is outside bundle Contents"); + } + } return { bundle: canonicalBundle, binary }; } diff --git a/structure/clients/chatgpt-desktop.md b/structure/clients/chatgpt-desktop.md index 5cad12f947e..3038abdafd1 100644 --- a/structure/clients/chatgpt-desktop.md +++ b/structure/clients/chatgpt-desktop.md @@ -116,9 +116,10 @@ commands and catalog, prompt, login, refresh and remote-workspace consumers. The `src/cli/codex-launcher.ts` handoff validates the selected shim backing on routed, passthrough and help/version fast paths, preserving the validated invocation's argv, environment and Windows interpreter options. This CLI handoff additionally verifies every -enclosing `.app` at the canonical executable location, independent of its name or mutable -bundle identifier. Nested apps use the outer bundle root so its signature is checked too; -a lexical app member that cannot be canonicalized refuses. Generated app-server launchers use the +enclosing `.app` at the lexical or canonical executable location, independent of its name or mutable +bundle identifier. A lexical app member must remain inside that root's canonical `Contents/`, +including through executable, directory or whole-bundle aliases. Nested apps use the outer canonical +bundle root so its signature is checked too; failed canonicalization refuses. Generated app-server launchers use the hidden validator in `src/cli/internal-command.ts` on each execution; this command skips root shim auto-repair, its stdout is a single path, and newline-bearing input or canonical output is refused. Ordinary non-bundle CLI paths retain their existing policy; @@ -127,6 +128,7 @@ limited to the two ChatGPT installation roots. Regression coverage is in `tests/codex-integration/exec-known-app-bundles.test.ts`, `tests/codex-integration/exec-launcher-boundary.test.ts`, `tests/codex-integration/exec-cli-renamed-app-bundle.test.ts`, +`tests/codex-integration/exec-cli-bundle-containment.test.ts`, `tests/codex-integration/exec-renamed-app-bundle.test.ts` and `tests/clients/desktop-app-server-exec-boundary.test.ts`. Portable injected-verifier and shell tests do not establish native macOS signature or real Desktop respawn acceptance. diff --git a/tests/codex-integration/exec-cli-bundle-containment.test.ts b/tests/codex-integration/exec-cli-bundle-containment.test.ts new file mode 100644 index 00000000000..f106aac4ab2 --- /dev/null +++ b/tests/codex-integration/exec-cli-bundle-containment.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import type { spawn } from "node:child_process"; +import { spawnCodex, type CodexSpawnDeps } from "../../src/cli/codex-launcher"; +import { validatedCodexExecInvocation } from "../../src/codex/exec-boundary"; + +const root = "/opt/ChatGPT Beta.app"; +const member = `${root}/Contents/Resources/codex`; +type Link = readonly [string, string | null]; +let errors: ReturnType>; +beforeEach(() => { errors = spyOn(console, "error").mockImplementation(() => {}); }); +afterEach(() => { errors.mockRestore(); }); + +// Model realpath at its existing seam, including redirects of ancestor directories. +function harness(command: string, backing: boolean, links: readonly Link[], refusal: string | null = null) { + const checks: string[][] = [], spawns: string[] = []; + const deps: CodexSpawnDeps = { + findOnPath: () => backing ? "/owned/codex-wrapper" : command, + inspectShim: () => (backing + ? { status: "matched", selectedRole: "wrapper", backingPath: command } + : { status: "absent" }) as ReturnType>, + execBoundary: { + platform: "darwin", executable: () => true, + realpath: path => { + const link = links.find(([from]) => path === from || path.startsWith(from + "/")); + return link ? link[1] === null ? null : link[1] + path.slice(link[0].length) : path; + }, + macBundleTrustReasonAsync: async (bundle, binary) => { checks.push([bundle, binary]); return refusal; }, + }, + spawn: ((file: string) => { + spawns.push(file); + const child = new EventEmitter(); + queueMicrotask(() => child.emit("exit", 0, null)); + return child; + }) as typeof spawn, + }; + return { deps, checks, spawns }; +} + +const escapes: Array<{ name: string; links: Link[] }> = [ + { name: "executable symlink", links: [[member, "/outside/codex"]] }, + { name: "Resources symlink", links: [[`${root}/Contents/Resources`, "/outside"]] }, + { name: "Contents symlink", links: [[`${root}/Contents`, "/outside"]] }, + { name: "member in another signed app", links: [[member, "/opt/Other.app/Contents/Resources/codex"]] }, + { name: "prefix sibling", links: [[member, `${root}-sibling/Contents/Resources/codex`]] }, + { name: "outside Contents", links: [[member, `${root}/MacOS/codex`]] }, + { name: "unresolvable lexical root", links: [[member, "/opt/Other.app/Contents/Resources/codex"], [root, null]] }, + { name: "aliased root with an escaped member", links: [[member, "/outside/codex"], [root, "/real/Bundle"]] }, +]; + +for (const backing of [false, true]) for (const scenario of escapes) { + test(`CLI refuses lexical bundle escape before an accepting verifier: ${scenario.name}, backing=${backing}`, async () => { + const h = harness(member, backing, scenario.links); + expect(await spawnCodex(["--version"], {}, h.deps)).toBe(1); + expect(h.checks).toEqual([]); + expect(h.spawns).toEqual([]); + expect(errors.mock.calls.flat().join(" ")).toContain( + scenario.name === "unresolvable lexical root" ? "cannot canonicalize the app bundle" : "outside bundle Contents", + ); + }); +} + +for (const backing of [false, true]) for (const canonicalRoot of ["/real/Bundle", "/real/Renamed.APP"]) { + test(`CLI verifies a whole-bundle symlink: ${canonicalRoot}, backing=${backing}`, async () => { + const binary = `${canonicalRoot}/Contents/Resources/codex`; + const h = harness(member, backing, [[root, canonicalRoot]], "signature revoked"); + expect(await spawnCodex(["--help"], {}, h.deps)).toBe(1); + expect(h.checks).toEqual([[canonicalRoot, binary]]); + expect(h.spawns).toEqual([]); + const trusted = harness(member, backing, [[root, canonicalRoot]]); + expect(await spawnCodex(["--help"], {}, trusted.deps)).toBe(0); + expect(trusted.checks).toEqual([[canonicalRoot, binary]]); + expect(trusted.spawns).toEqual([binary]); + }); +} + +for (const suffix of ["app", "APP", "ApP"]) { + test(`CLI retains lexical .${suffix} classification when the canonical member escapes`, async () => { + const binary = `/opt/Unrelated.${suffix}/Contents/Resources/codex`; + const h = harness(binary, false, [[binary, "/outside/codex"]]); + expect(await spawnCodex(["app-server"], {}, h.deps)).toBe(1); + expect(h.checks).toEqual([]); + expect(h.spawns).toEqual([]); + }); +} + +test("CLI checks the outer canonical app when the lexical bundle root is an inner app alias", async () => { + const outer = "/real/Outer.app"; + const inner = `${outer}/Contents/Resources/Inner.app`; + const binary = `${inner}/Contents/Resources/codex`; + const h = harness(member, true, [[root, inner]], "signature revoked"); + expect(await spawnCodex(["--help"], {}, h.deps)).toBe(1); + expect(h.checks).toEqual([[outer, binary]]); + expect(h.spawns).toEqual([]); +}); + +test("synchronous CLI validation applies the same lexical containment refusal", () => { + let checks = 0; + expect(() => validatedCodexExecInvocation(member, [], { + platform: "darwin", verifyAnyAppBundle: true, executable: () => true, + realpath: path => path === member ? "/outside/codex" : path, + macBundleTrustReason: () => { checks++; return null; }, + })).toThrow("outside bundle Contents"); + expect(checks).toBe(0); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index aec281aa027..2d5274ef486 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -588,6 +588,7 @@ "exec-boundary-launch.test.ts": "codex-integration", "exec-launcher-boundary.test.ts": "codex-integration", "exec-cli-renamed-app-bundle.test.ts": "codex-integration", + "exec-cli-bundle-containment.test.ts": "codex-integration", "exec-renamed-app-bundle.test.ts": "codex-integration", "desktop-app-server-exec-boundary.test.ts": "clients", "exec-known-app-bundles.test.ts": "codex-integration", From 9d96e4533b9692a09f562f25e32394a402fa2fbf Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:45:26 +0900 Subject: [PATCH 6/8] test(codex): cover nested bundle aliases and canonical handoff --- .../exec-cli-bundle-containment.test.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/codex-integration/exec-cli-bundle-containment.test.ts b/tests/codex-integration/exec-cli-bundle-containment.test.ts index f106aac4ab2..65ecd399ef9 100644 --- a/tests/codex-integration/exec-cli-bundle-containment.test.ts +++ b/tests/codex-integration/exec-cli-bundle-containment.test.ts @@ -46,6 +46,10 @@ const escapes: Array<{ name: string; links: Link[] }> = [ { name: "outside Contents", links: [[member, `${root}/MacOS/codex`]] }, { name: "unresolvable lexical root", links: [[member, "/opt/Other.app/Contents/Resources/codex"], [root, null]] }, { name: "aliased root with an escaped member", links: [[member, "/outside/codex"], [root, "/real/Bundle"]] }, + { name: "aliased inner root escaped within the outer app", links: [ + [member, "/real/Outer.app/Contents/Resources/codex"], + [root, "/real/Outer.app/Contents/Resources/Inner.app"], + ] }, ]; for (const backing of [false, true]) for (const scenario of escapes) { @@ -74,6 +78,22 @@ for (const backing of [false, true]) for (const canonicalRoot of ["/real/Bundle" }); } +for (const backing of [false, true]) for (const from of [member, `${root}/Contents/Resources`]) { + test(`CLI validates an in-bundle member alias: ${from}, backing=${backing}`, async () => { + const target = from === member ? `${root}/Contents/MacOS/codex` : `${root}/Contents/MacOS`; + const links: Link[] = [[from, target]]; + const canonical = `${root}/Contents/MacOS/codex`; + const trusted = harness(member, backing, links); + expect(await spawnCodex(["--version"], {}, trusted.deps)).toBe(0); + expect(trusted.checks).toEqual([[root, canonical]]); + expect(trusted.spawns).toEqual([canonical]); + const rejected = harness(member, backing, links, "signature revoked"); + expect(await spawnCodex(["--version"], {}, rejected.deps)).toBe(1); + expect(rejected.checks).toEqual([[root, canonical]]); + expect(rejected.spawns).toEqual([]); + }); +} + for (const suffix of ["app", "APP", "ApP"]) { test(`CLI retains lexical .${suffix} classification when the canonical member escapes`, async () => { const binary = `/opt/Unrelated.${suffix}/Contents/Resources/codex`; @@ -84,6 +104,29 @@ for (const suffix of ["app", "APP", "ApP"]) { }); } +for (const backing of [false, true]) { + test(`CLI spawns the captured canonical member after lexical alias replacement, backing=${backing}`, async () => { + const canonical = `${root}/Contents/MacOS/codex`; + const links: Link[] = [[member, canonical]]; + const h = harness(member, backing, links); + let entered!: () => void, release!: () => void; + const checking = new Promise(resolve => { entered = resolve; }); + const pending = new Promise(resolve => { release = resolve; }); + h.deps.execBoundary!.macBundleTrustReasonAsync = async (bundle, binary) => { + h.checks.push([bundle, binary]); entered(); + await pending; + return null; + }; + const launch = spawnCodex(["--help"], {}, h.deps); + await checking; + links[0] = [member, "/outside/replaced-codex"]; + release(); + expect(await launch).toBe(0); + expect(h.checks).toEqual([[root, canonical]]); + expect(h.spawns).toEqual([canonical]); + }); +} + test("CLI checks the outer canonical app when the lexical bundle root is an inner app alias", async () => { const outer = "/real/Outer.app"; const inner = `${outer}/Contents/Resources/Inner.app`; From a01ec302625c7b10f03054ecc5a2c1fa837f11b9 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Thu, 29 Oct 2026 00:44:23 +0000 Subject: [PATCH 7/8] docs(cli): clarify internal helper argument checks --- src/cli/internal-command.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/internal-command.ts b/src/cli/internal-command.ts index 63e6a07776a..d9d7c2ae9c7 100644 --- a/src/cli/internal-command.ts +++ b/src/cli/internal-command.ts @@ -26,7 +26,7 @@ function optionValue(args: readonly string[], name: string): string | undefined } /** - * Dispatch internal helpers with strict argument checks and command-specific exit codes. + * Dispatch internal helpers with command-specific argument checks and exit codes. * The executable-target helper writes one trusted path to stdout only after validation. */ export async function handleInternalCommand(args: readonly string[], execBoundary: CodexExecBoundaryAsyncDeps = {}): Promise { From f2a1421133648d8d3c88d6a31c9864394136fbb2 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:20:15 +0900 Subject: [PATCH 8/8] fix(codex): keep the install hint when the selected CLI disappears Pre-spawn verification now resolves the executable, so a target that vanished after selection surfaced as a raw ENOENT instead of the install hint the spawn path prints. --- src/cli/codex-launcher.ts | 5 ++++- tests/codex-integration/exec-launcher-boundary.test.ts | 10 ++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/cli/codex-launcher.ts b/src/cli/codex-launcher.ts index f9550050296..d8fce9489db 100644 --- a/src/cli/codex-launcher.ts +++ b/src/cli/codex-launcher.ts @@ -277,7 +277,10 @@ export async function spawnCodex(args: readonly string[], env: CodexLaunchEnv, d launch = resolveCodexLaunchCommand(env, deps); inv = await validatedCodexExecInvocationAsync(launch.command, args, { ...deps.execBoundary, verifyAnyAppBundle: true, env: launch.env as NodeJS.ProcessEnv }); } catch (error) { - console.error(error instanceof CodexNotFoundError ? CODEX_INSTALL_HINT : `❌ Cannot launch codex: ${error instanceof Error ? error.message : "executable verification failed"}`); + // A selected executable that vanished before verification is the same missing CLI + // the spawn-time ENOENT branch below reports, not a trust refusal. + const missing = error instanceof CodexNotFoundError || (error as NodeJS.ErrnoException | null)?.code === "ENOENT"; + console.error(missing ? CODEX_INSTALL_HINT : `❌ Cannot launch codex: ${error instanceof Error ? error.message : "executable verification failed"}`); return 1; } return new Promise(resolve => { diff --git a/tests/codex-integration/exec-launcher-boundary.test.ts b/tests/codex-integration/exec-launcher-boundary.test.ts index 27ee5f963a2..ac61f3d0b2f 100644 --- a/tests/codex-integration/exec-launcher-boundary.test.ts +++ b/tests/codex-integration/exec-launcher-boundary.test.ts @@ -72,6 +72,16 @@ test("a failed or thrown trust check never reaches spawn", async () => { } }); +test("a selected executable that disappears before verification keeps the install hint", async () => { + const h = harness(async () => { throw new Error("must not inspect a missing target"); }); + h.deps.findOnPath = () => "/usr/local/bin/codex"; + h.deps.inspectShim = () => ({ status: "absent" }) as ReturnType>; + h.deps.execBoundary = { ...h.boundary, executable: () => false, exists: () => false }; + expect(await spawnCodex(["--version"], {}, h.deps)).toBe(1); + expect(h.calls).toHaveLength(0); + expect(errors.mock.calls.flat().join(" ")).toContain("codex` CLI not found"); +}); + test("non-bundle targets preserve arguments and do not invoke bundle verification", async () => { const h = harness(async () => { throw new Error("must not inspect an ordinary CLI"); }); h.deps.findOnPath = () => "/usr/bin/codex";