Problems with WSL Sandbox

Open 💬 2 comments Opened Jul 16, 2026 by roaspeci

What issue are you seeing?

codex-sandbox-process-isolation-support-report-2026-07-15.md

Latest version on WSL with ubuntu

What steps can reproduce the bug?

see md above

What is the expected behavior?

_No response_

Additional information

_No response_

View original on GitHub ↗

2 Comments

agent-pipe-shared · 1 month ago

## Retest on Codex CLI 0.144.6

I retested the problem on the latest Codex CLI release, 0.144.6. The nested-process/stdio problem still reproduces.

Support case: 11586908

### Updated environment

Component Value
━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Codex CLI 0.144.6
────────────── ───────────────────────────────────
Installation Standalone CLI
────────────── ───────────────────────────────────
Ubuntu 26.04 LTS
────────────── ───────────────────────────────────
WSL 2.7.3.0
────────────── ───────────────────────────────────
WSL kernel 6.6.114.1-microsoft-standard-WSL2
────────────── ───────────────────────────────────
Windows 10.0.26200.8875
────────────── ───────────────────────────────────
Architecture x86_64
────────────── ───────────────────────────────────
Node.js v24.15.0
────────────── ───────────────────────────────────
Git 2.53.0
────────────── ───────────────────────────────────
bubblewrap 0.11.1

The original report used Codex CLI 0.144.4.

## Independent one-file reproducer

This reproducer does not use our pipeline, TAC configuration, hooks, guard scripts, or private repository code.

Save as codex-wsl-stdio-probe.mjs:

#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";

if (process.argv[2] === "child") {
const input = readFileSync(0, "utf8");
process.stdout.write(JSON.stringify({ child: "ok", input }));
process.exit(0);
}

const script = fileURLToPath(import.meta.url);
const payload = JSON.stringify({
probe: "codex-wsl-sandbox",
value: 42,
});

const startedAt = Date.now();
const result = spawnSync(process.execPath, [script, "child"], {
encoding: "utf8",
input: payload,
timeout: 5000,
});

console.log(JSON.stringify({
durationMs: Date.now() - startedAt,
status: result.status,
signal: result.signal,
errorCode: result.error?.code ?? null,
errorErrno: result.error?.errno ?? null,
errorSyscall: result.error?.syscall ?? null,
errorMessage: result.error?.message ?? null,
stdout: result.stdout ?? "",
stderr: result.stderr ?? "",
}, null, 2));

## Control run outside the sandbox

Run from a normal WSL shell:

node codex-wsl-stdio-probe.mjs

Observed:

{
"durationMs": 27,
"status": 0,
"signal": null,
"errorCode": null,
"errorErrno": null,
"errorSyscall": null,
"errorMessage": null,
"stdout": "{\"child\":\"ok\",\"input\":\"{\\\"probe\\\":\\\"codex-wsl-sandbox\\\",\\\"value\\\":42}\"}",
"stderr": ""
}

## Run under the built-in workspace profile

From the same normal WSL shell:

codex sandbox \
-P :workspace \
-C "$PWD" \
node "$PWD/codex-wsl-stdio-probe.mjs"

Observed:

{
"durationMs": 5008,
"status": null,
"signal": "SIGTERM",
"errorCode": "ETIMEDOUT",
"errorErrno": -110,
"errorSyscall": "spawnSync /path/to/node",
"errorMessage": "spawnSync /path/to/node ETIMEDOUT",
"stdout": "",
"stderr": ""
}

The direct child command succeeds immediately with the same JSON on stdin. The failure only appears when the child is launched by
the parent process inside the Codex sandbox.

## Isolation matrix

I repeated the test with the following variations:

Test Result
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━
Outside Codex sandbox Pass
──────────────────────────────────────────────────────────────────────── ────────
Built-in :workspace Fail
──────────────────────────────────────────────────────────────────────── ────────
Built-in :read-only Fail
──────────────────────────────────────────────────────────────────────── ────────
DrvFS workspace under /mnt/d Fail
──────────────────────────────────────────────────────────────────────── ────────
Identical files under native WSL /tmp Fail
──────────────────────────────────────────────────────────────────────── ────────
Standalone codex sandbox launched outside our TAC/host-managed session Fail
──────────────────────────────────────────────────────────────────────── ────────
Built-in :danger-full-access Pass
──────────────────────────────────────────────────────────────────────── ────────
Custom workspace profile with network.enabled=true Pass

This rules out the following as the primary cause:

  • our private repository;
  • our verification harness;
  • our guard implementations;
  • TAC-specific hardening;
  • DrvFS or the Windows-mounted workspace path.

## Expanded process/stdio observations

A slightly larger matrix tested synchronous and asynchronous Node children.

Under :workspace with restricted networking:

  • A child that reads stdin remains blocked until timeout.
  • This also happens when spawnSync is called without the input option.
  • A child that does not read stdin can exit with status: 0, while spawnSync simultaneously reports EPERM.
  • Child stdout and stderr are empty.
  • Asynchronous spawn() can report exit code 0 while also losing the complete child output.

Representative result:

{
"status": 0,
"signal": null,
"errorCode": "EPERM",
"errorErrno": -1,
"stdout": "",
"stderr": ""
}

A successful exit code is therefore not sufficient to establish that the sandbox returned the child result correctly.

## Strong localization result

A temporary custom profile extending :workspace, but enabling network access, makes all tested child-process cases pass:

codex sandbox \
-c 'permissions.workspace_network.extends=":workspace"' \
-c permissions.workspace_network.network.enabled=true \
-P workspace_network \
-C "$PWD" \
node "$PWD/codex-wsl-stdio-probe.mjs"

Observed:

  • normal exit code 0;
  • intact stdout;
  • intact stdin payload;
  • no EPERM;
  • no timeout;
  • runtime approximately 23–30 ms.

This is a diagnostic result, not an acceptable permanent workaround. It weakens the intended isolation boundary.

The result strongly localizes the failure to the restricted-network enforcement path rather than the bubblewrap filesystem-
isolation path.

In the 0.144.6 source, restricted networking installs an additional seccomp filter in:

codex-rs/linux-sandbox/src/landlock.rs

That filter returns EPERM for several denied operations. I have not identified the exact syscall responsible for the Node
behavior, so this is an evidence-backed localization, not a proven line-level root cause.

Relevant source:

## Legacy fallback observation

I also tried the documented legacy fallback:

codex sandbox \
--enable use_legacy_landlock \
-P :workspace \
-C "$PWD" \
node "$PWD/codex-wsl-stdio-probe.mjs"

Codex terminated with exit code 101 and a Rust panic:

thread 'main' panicked at linux-sandbox/src/linux_run_main.rs:
permission profiles requiring direct runtime enforcement are incompatible
with --use-legacy-landlock

Even if this combination is intentionally unsupported, it would be preferable to return a typed CLI configuration error instead
of panicking.

## Impact on isolated Critic workflows

Our intended use case is an independently isolated, read-only Critic:

  1. Run an access-denial preflight.
  2. Provide only the spec, diff, guardrails, and selected evidence.
  3. Start a bounded codex exec --json Critic.
  4. Consume a schema-valid verdict or a typed failure.

The current behavior makes this unreliable:

  • nested verification processes can block;
  • stdout/stderr can disappear;
  • status 0 can be reported without the expected output;
  • previous codex exec --json attempts stopped after thread.started and turn.started until coordinator timeout.

The codex exec --json lifecycle case has not yet been rerun on 0.144.6; the independent nested-process failure has been
reconfirmed on 0.144.6.

## Expected behavior

Nested processes and stdio forwarding should work under a restricted-network workspace or read-only profile.

If an operation is unsupported, Codex should return a typed and machine-readable failure instead of:

  • waiting indefinitely for stdin or EOF;
  • returning status 0 together with EPERM;
  • silently discarding stdout/stderr;
  • requiring an external coordinator timeout.

danger-full-access and permanently enabling network access are not acceptable fixes for a Critic whose purpose is to run inside a
genuine least-privilege boundary.

agent-pipe-shared · 1 month ago

Additional reproducible evidence from WSL2 with Codex CLI 0.145.0

I completed a controlled diagnostic that isolates this issue from the WSL filesystem, DNS, AppArmor, user namespaces, and the system Bubblewrap installation.

Environment

  • Windows with WSL2
  • Ubuntu 26.04 LTS
  • Kernel: 6.18.33.2-microsoft-standard-WSL2
  • Codex CLI: 0.145.0
  • Codex executable: user-local installation
  • Bubblewrap: /usr/bin/bwrap, version 0.11.1
  • Workspace: native WSL ext4 under /home
  • /tmp: tmpfs, mode 1777, writable
  • AppArmor kernel enforcement: disabled
  • User namespaces: working
  • Direct Bubblewrap test: working
  • DNS resolution outside Codex: working
  • HTTPS outside Codex: working

The local CLI exposes the following syntax:

-P, --permission-profile <NAME>
    Named permissions profile to apply from the active configuration stack

Control tests outside Codex

The following all pass outside the Codex sandbox:

Host /tmp write: PASS
Create/rename/chmod/delete: PASS
Execute script from /tmp: PASS
Python tempfile: PASS
AF_UNIX socket bind under /tmp: PASS
User namespace creation: PASS
Direct Bubblewrap test: PASS
DNS lookup: PASS
HTTPS connection: PASS

/tmp is mounted as:

/tmp tmpfs tmpfs rw,nosuid,nodev

Minimal reproduction

Create socket-probe.py:

import os
import socket
import tempfile

fd, path = tempfile.mkstemp(prefix="codex-python-")
os.close(fd)
os.unlink(path)
print("PYTHON_TEMPFILE=PASS")

socket_path = f"/tmp/codex-socket-{os.getpid()}.sock"
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)

try:
    sock.bind(socket_path)
    print("UNIX_SOCKET=PASS")
finally:
    sock.close()
    if os.path.exists(socket_path):
        os.unlink(socket_path)

Outside Codex:

python3 ./socket-probe.py

Result:

PYTHON_TEMPFILE=PASS
UNIX_SOCKET=PASS

Inside the built-in workspace profile:

codex sandbox \
  -P ':workspace' \
  -- python3 ./socket-probe.py

Result:

PYTHON_TEMPFILE=PASS

Traceback (most recent call last):
  ...
  sock.bind(socket_path)
PermissionError: [Errno 1] Operation not permitted

Normal filesystem operations in /tmp still work in the same :workspace sandbox:

FILE_OPS=PASS
TEMP_EXEC=PASS
PYTHON_TEMPFILE=PASS

Only the Unix-socket bind fails.

Additional observation

Running the same diagnostic without an explicit permission profile:

codex sandbox -- bash ./tmp-probe.sh

mounts /tmp read-only in this context:

/tmp tmpfs tmpfs ro,nosuid,nodev
mktemp: Read-only file system

There are no relevant sandbox_mode, default_permissions, writable_roots, or network settings in my user config.toml.

Analysis

This does not appear to be:

  • incorrect /tmp permissions
  • a read-only WSL filesystem
  • missing Bubblewrap
  • unavailable user namespaces
  • AppArmor blocking Bubblewrap
  • broken WSL DNS
  • a general WSL network failure

The failure is specific to the Codex sandbox policy applied to AF_UNIX bind().

This appears to be the same underlying class as:

openai/codex#8343
tsx CLI fails in Codex sandbox with EPERM when creating IPC pipe

That issue reproduces listen EPERM for a dynamically generated IPC pipe under /tmp.

Why the current workaround is insufficient

The available permission escape hatch appears to be:

[permissions.wsl-dev.network]
enabled = true
dangerously_allow_all_unix_sockets = true

This is too broad as a general solution. It may expose unrelated user-owned local services, including Docker, SSH-agent, credential-agent, database, or other Unix sockets.

Exact socket-path allowlisting is also not practical for common development tools that create dynamically named paths such as:

/tmp/tsx-<uid>/<random>.pipe
/tmp/<tool>-<pid>.sock

Users therefore have to choose between:

  1. common development tools failing with EPERM, or
  2. broadly weakening Unix-socket isolation through a manually created custom profile.

Expected behavior

One of the following would provide a safer and more usable result:

  1. The built-in :workspace profile permits creating and binding new Unix sockets inside writable sandbox-owned temporary directories while continuing to block connections to pre-existing host sockets.
  1. Codex provides a narrower permission for creating local IPC sockets without allowing arbitrary access to existing Unix sockets.
  1. Unix-socket rules support safe path patterns or sandbox-owned temporary namespaces.
  1. Codex detects the blocked AF_UNIX bind() and returns an actionable error instead of a generic EPERM.
  1. Codex provides a documented development-compatible profile for common tools such as tsx, test workers, browser drivers, and local build daemons.

Security distinction

Creating a new socket owned by the sandboxed process under a writable temporary directory is not equivalent to connecting to an existing privileged host socket such as:

/var/run/docker.sock
/run/user/.../ssh-agent.sock

A permission model that distinguishes these operations would avoid requiring dangerously_allow_all_unix_sockets.

Relevance

This affects more than one specific tool. Many normal SDLC tools use temporary IPC sockets for:

  • process coordination
  • worker pools
  • test execution
  • development servers
  • TypeScript execution
  • browser automation
  • language servers
  • build daemons

I can provide the full diagnostic logs and probe script if required.