Filesystem sandbox preparation scales quadratically with permission entries

Open 💬 0 comments Opened Jul 14, 2026 by yiteng-guo

What version of Codex CLI is running?

codex-cli 0.144.4

What subscription do you have?

API

Which model were you using?

gpt-5.6-sol

What platform is your computer?

_No response_

What terminal emulator and version are you using (if applicable)?

_No response_

Codex doctor report

What issue are you seeing?

Description

On Linux, filesystem RPCs through codex exec-server become very slow as the number of filesystem permission entries grows. The slowdown happens before the filesystem operation itself and appears to be superlinear in the size of the permission profile.

This is especially noticeable for apply_patch, because one patch may perform several sandboxed filesystem RPCs for verification, metadata, reads, and writes. A profile with a few dozen writable roots can therefore turn a tiny patch into a tens-of-seconds or minutes-long operation.

The reproduction below is self-contained:

  • It sets CODEX_HOME to an empty temporary directory, so it does not read the user's config.toml.
  • It creates all writable roots in a temporary directory.
  • It calls the exec-server filesystem API directly, so model and API latency are excluded.

Environment

  • Codex: codex-cli
  • Python: 3.9.2
  • Bubblewrap: 0.11.2

Reproduction

Save this as repro.py:

#!/usr/bin/env python3
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import time

codex = sys.argv[1]

with tempfile.TemporaryDirectory(prefix="codex-fs-repro-") as tmp:
    base = Path(tmp)
    home = base / "home"
    home.mkdir()
    roots = [base / f"root-{i}" for i in range(30)]
    for root in roots:
        root.mkdir()
    target = roots[0] / "target.txt"
    target.write_text("hello\n")

    entries = [{
        "path": {"type": "special", "value": {"kind": "root"}},
        "access": "read",
    }]
    for root in roots:
        entries.append({
            "path": {"type": "path", "path": root.as_uri()},
            "access": "write",
        })
        for name in (".git", ".agents", ".codex"):
            entries.append({
                "path": {"type": "path", "path": (root / name).as_uri()},
                "access": "read",
            })

    sandbox = {
        "permissions": {
            "type": "managed",
            "file_system": {"type": "restricted", "entries": entries},
            "network": "restricted",
        },
        "cwd": roots[0].as_uri(),
        "workspaceRoots": [roots[0].as_uri()],
        "windowsSandboxLevel": "disabled",
        "windowsSandboxPrivateDesktop": False,
        "useLegacyLandlock": False,
    }

    env = os.environ.copy()
    env["CODEX_HOME"] = str(home)  # Do not read the user's config.toml.
    process = subprocess.Popen(
        [codex, "exec-server", "--listen", "stdio"],
        cwd=base,
        env=env,
        stdin=subprocess.PIPE,
        stdout=subprocess.PIPE,
        stderr=subprocess.DEVNULL,
        text=True,
    )

    def rpc(message):
        process.stdin.write(json.dumps(message, separators=(",", ":")) + "\n")
        process.stdin.flush()
        return json.loads(process.stdout.readline())

    try:
        rpc({
            "jsonrpc": "2.0", "id": 1, "method": "initialize",
            "params": {"clientName": "fs-repro", "resumeSessionId": None},
        })
        process.stdin.write('{"jsonrpc":"2.0","method":"initialized","params":{}}\n')
        process.stdin.flush()
        start = time.perf_counter()
        response = rpc({
            "jsonrpc": "2.0", "id": 2, "method": "fs/getMetadata",
            "params": {"path": target.as_uri(), "sandbox": sandbox},
        })
        assert "result" in response, response
        print(f"entries={len(entries)} elapsed={time.perf_counter() - start:.3f}s")
    finally:
        process.kill()
        process.wait()

Run:

$ /path/to/codex --version
codex-cli 0.144.4
$ python3 repro.py /path/to/codex
entries=121 elapsed=10.027s

Scaling observed on v0.144.4

| Writable roots | Permission entries | Elapsed time |
|---:|---:|---:|
| 1 | 5 | 0.121s |
| 5 | 21 | 0.515s |
| 10 | 41 | 1.432s |
| 15 | 61 | 2.898s |
| 20 | 81 | 4.775s |
| 25 | 101 | 7.294s |
| 30 | 121 | 10.185s |

Expected behavior

Sandbox preparation should scale approximately linearly with the number of permission entries. A metadata lookup on a local temporary file should not spend roughly 10 seconds preparing a 121-entry policy.

Likely cause and high-level fix

The writable-root calculation repeatedly resolves and scans the same permission entries from nested root-by-entry loops. In particular, effective path normalization and write-access checks are recomputed many times for identical paths, and each write-access check reconstructs/scans the resolved policy again.

A local fix that preserves the existing permission semantics does the following:

  1. Resolve the permission entries once for each writable-root calculation.
  2. Precompute each resolved path's normalized effective path and writability once.
  3. Reuse that resolved data for access-precedence and protected-metadata checks instead of rebuilding and rescanning it inside nested loops.

With that change, the same self-contained reproduction in an unoptimized debug build produced:

| Writable roots | Permission entries | v0.144.4 | Local build with fix |
|---:|---:|---:|---:|
| 1 | 5 | 0.121s | 0.145s |
| 10 | 41 | 1.432s | 0.343s |
| 20 | 81 | 4.775s | 0.596s |
| 30 | 121 | 10.185s | 0.804s |

No protocol or sandbox-policy behavior change is intended; this only removes repeated derivation of the same resolved policy data.

What steps can reproduce the bug?

See above

What is the expected behavior?

_No response_

Additional information

Codex generates the description above. I have a fix locally and happy to send a PR if you need.

View original on GitHub ↗