Rules / CFG064
CFG064 Codex CLI sandbox disabled or widened
Severity: error (sandbox disabled, network_access, credential/system/root writable_roots) · warn (other outside-workspace writable_roots)
OWASP: LLM03:2026 – Excessive Agency
What cfgaudit checks
OpenAI Codex CLI's sandbox_mode (in .codex/config.toml) controls how tool execution is confined. The value danger-full-access disables the sandbox entirely — tools run with full filesystem and network access — the analog of weakening Claude Code's sandbox (CFG022). Combined with approval_policy = "never" (CFG063) it is a fully unattended, unsandboxed agent.
The safe values are read-only and workspace-write.
Short of disabling it: the [sandbox_workspace_write] table
workspace-write is not one posture but a configurable one. The table that tunes it is consulted whenever the effective sandbox mode is workspace-write, and cfgaudit reports the two fields that widen it:
| Field | Finding |
|---|---|
network_access = true |
error — the workspace-write sandbox otherwise blocks outbound network, so this re-opens an egress path for anything the agent can read |
writable_roots naming a credential dir, system path, home or / |
error |
writable_roots naming anything else outside the workspace |
warn |
writable_roots is judged exactly like Cursor's additionalReadwritePaths in CFG095, including the workspace root being derived from the config file's own path, so a grant that stays inside the workspace is silent.
When the table is skipped
Codex reads [sandbox_workspace_write] only under workspace-write. So:
sandbox_mode = "read-only"→ the table is inert, nothing is reported from it.sandbox_mode = "danger-full-access"→ the sandbox is gone entirely; that error is strictly worse than anything in the table, so cfgaudit reports it alone rather than adding noise about settings Codex never reaches.sandbox_modeunset → still reported. Codex resolves an unset mode to workspace-write for a directory carrying a trust decision (codex-rs/config/src/config_toml.rs), which is the ordinary project case.
What is deliberately not flagged
exclude_tmpdir_env_var and exclude_slash_tmp harden the sandbox. Codex builds the writable set as workdir, then /tmp unless exclude_slash_tmp, then $TMPDIR unless exclude_tmpdir_env_var (codex-rs/utils/sandbox-summary), so setting either to true removes a writable location. The issue that requested this coverage listed both as looseners to bundle; the source says the opposite, and reporting hardening as a weakness would be a false positive — the same trap Cursor's disableTmpWrite set in CFG095.
The second mechanism: named permission profiles
Codex has a richer permission system alongside approval_policy and sandbox_mode. Two top-level keys drive it: [permissions] defines named profiles, and default_permissions selects one. Per the schema, a name starting with : refers to a built-in profile, any other name is resolved from the [permissions] table.
A profile carries filesystem, network, workspace_roots and extends. cfgaudit reads the network block, which is the sharp end:
| Field | Reported as |
|---|---|
proxy_url, socks_url |
error: routes the agent's traffic through a host the repository chose, which is CFG021's threat model reached through Codex's own config |
dangerously_allow_all_unix_sockets |
error: reaches the container and service sockets that are equivalent to a root shell |
dangerously_allow_non_loopback_proxy |
error alongside a proxy URL (it is what makes the remote proxy usable), warn on its own |
enabled = true with no domains allowlist |
error: open egress |
enabled = true with a domains allowlist containing a bare * or ** |
error: the allowlist scopes nothing |
enabled = true scoped by a named-host domains allowlist |
warn: bounded egress, for review |
Worth stating plainly: the project-layer denylist exists so a repository cannot "choose where a user's credentials are sent", and permissions.<name>.network.proxy_url does exactly that through a key the list does not cover.
What was measured, and against what
The premise was verified at the consumer rather than inferred from the loader, using codex doctor's effective-sandbox line in a trusted project directory (codex-cli 0.147.0):
Project .codex/config.toml |
Effective sandbox |
|---|---|
| empty | restricted fs + restricted network |
default_permissions = "p" with [permissions.p.network] enabled = true |
restricted fs + **enabled network** |
the same with enabled = false |
restricted fs + restricted network |
default_permissions naming a profile nobody defines |
the whole config fails to load |
a profile whose only content is extends of a permissive parent |
restricted fs + **enabled network** |
Three things follow, and each shapes the rule:
enabledis the field that decides.mode(limited/full) refines what an enabled network may reach; both values report as enabled, somodealone is not the trigger.enabled = falseis a denial, not an absent field, which is why the parser keeps it as a pointer.extendsgenuinely inherits, so a profile cannot be judged from its own table. cfgaudit walks the chain and names the inherited profile in the finding.
How far the opened network reaches decides the severity
enabled decides whether the network opens. The [.network.domains] table decides how far it reaches, and it is written as a map of host pattern to decision rather than as an array:
[permissions.p.network]
enabled = true
[permissions.p.network.domains]
"github.com" = "allow"
"*.githubusercontent.com" = "allow"
A profile that names its hosts has bounded egress. Reporting it as though it opened everything is the overreach this project avoids elsewhere: CFG048 says nothing about a terminal auto-approve map that names specific commands, only about a catch-all. So the finding splits three ways, and the wording follows:
- No allowlist:
error, and the message keeps the "egress path for anything the agent can read" framing, which is earned here. - An allowlist containing a bare
*or**:error. It looks like a restriction and is not one. A suffix pattern such as*.github.comnames a real domain and is ordinary scoping, not a catch-all. - A named-host allowlist:
warn, quoting the hosts so the reviewer can judge them. Bounded egress is for review, not removal.
A deny entry does not count as scoping. It narrows an allowlist some other entry opened, so a table of nothing but denials leaves the egress unbounded and reports as error.
Why this was worth a correction. The rule shipped reporting every opened network at error, and the FP run behind it called all 22 findings true positives without asking whether they were scoped. Re-measured across the same 69 real configs:
| Before | After | |
|---|---|---|
error |
22 | 5 (3 unscoped, 2 catch-all) |
warn |
0 | 17 |
Every one of the 22 really does open the network, so nothing shipped was unsound. The blast radius was overstated on 17 of them, which is the kind of ratio that teaches people to skim past a rule.
The filesystem block
A profile's filesystem block is the most used part of the whole mechanism: 39 of 69 real .codex/config.toml files carry one, against 20 for the network domains table. It mixes three value kinds under one table:
[permissions.p.filesystem]
glob_scan_max_depth = 3 # a scalar knob
":root" = "deny" # a scope key -> decision
"~/.ssh" = "deny" # a path -> decision
[permissions.p.filesystem.":workspace_roots"]
"**/*.env" = "deny" # a nested scope table of glob -> decision
Decisions are exactly four: deny (177 across the corpus), write (72), read (53), none (51). An unrecognised value is treated as not granting, because reporting a decision whose meaning is unknown would be a guess.
Real-world use is overwhelmingly hardening. Denials of .env, *.pem and ~/.ssh far outnumber grants, and the most common grant is "." = "write", which is the workspace itself. So the block's presence is never a finding; only the granting direction on a sensitive target is:
| Grant | Finding |
|---|---|
":root" set to read or write |
error: every path the process can reach |
a credential path, home or /, read or write |
error: a read is enough to exfiltrate |
a system path with write |
error |
| anything else outside the workspace | not reported, see below |
Read and write are kept apart because they matter in opposite places. A read of a credential path is already an exfiltration channel, which is why CFG095 reports a read of one. A read of a system path is how the agent reaches its own toolchain: every system-path grant in the corpus is a read of /bin, /usr/bin or the command line tools.
Two exclusions earned by the false-positive run:
- Public keys. An
id_*.pubis meant to be handed out, and the corpus contains a profile granting read to~/.ssh/id_ed25519.pub. Reporting that as credential exposure is a false positive. - The plain outside-the-workspace class. It is what people name their build caches with:
~/.cargo,~/.rustup,~/.cache/go-build,go/pkg/mod,~/Library/Caches, an application's own support directory. It accounted for 9 of the 10 files this check would otherwise flag. #459 removed CFG095's build-cache warn for the same reason at a comparable rate.writable_rootskeeps its outside-warn, because a sandbox writable root is a narrower thing than a profile's read map.
Nested scope tables are walked only under ":root". The bounded scopes (:workspace_roots, :project_roots, :minimal, :tmpdir, :slash_tmp) resolve against the workspace, so a granting glob inside one stays inside the project.
Corpus result: 10 files before the two exclusions, 2 after. Both survivors are genuine: one profile grants ":root" = "read", and one grants read to ~/.ssh/id_ed25519 alongside .ssh/config and known_hosts.
A note on workspace_roots: the schema documents it as a profile-level array, but across those 69 files it appears zero times in that form, while the nested filesystem.":workspace_roots" table appears 37 times. A count of the string is not a count of the field.
Only a profile that is actually reached
cfgaudit reports the profile default_permissions selects, plus whatever that one inherits. A dormant profile definition nobody selects is not a finding, the same restraint that keeps [sandbox_workspace_write] inert under an explicit read-only mode. A :-prefixed built-in is not resolved from the file and is not reported either.
Under danger-full-access the network finding is suppressed as redundant: the sandbox is already off, and saying so twice is noise. The proxy and unix-socket findings still stand, because where traffic is routed is a different statement from whether it is allowed.
The network.mitm sub-table (actions, hooks) is not covered here. It carries command content and belongs in the command-content family, which needs its own plumbing and its own probe.
Where cfgaudit looks
Codex config is project-merged, not user-global. Besides the user file it loads a repo-local .codex/config.toml — upstream resolves it both at the git root and by walking parent directories (codex-rs/config/src/loader/mod.rs, layer order cwd → tree → repo).
cfgaudit therefore scans:
| Path | Scope | Flag |
|---|---|---|
<repo>/.codex/config.toml |
project | always |
~/.codex/config.toml ($CODEX_HOME) |
user | --user |
A committed .codex/config.toml is the case this rule exists for, and it is the one cfgaudit missed until #388 — the rule was correct but only ever looked at the user file.
The bare ${PWD}/config.toml layer is deliberately not scanned: the filename is too generic (Hugo, Zola and others use it), so decoding every repo's config.toml as Codex config would turn an unrelated file into a scan error for no coverage gain.
Keys Codex refuses from a project layer
Upstream guards a subset against repo contents (PROJECT_LOCAL_CONFIG_DENYLIST), commented "Project-local config comes from repository contents, so it should not get to choose where a user's credentials are sent or which local commands are run." It covers openai_base_url, chatgpt_base_url, apps_mcp_product_sku, responses_api_metadata, model_provider(s), notify, profile(s), the two experimental realtime URLs, and otel, plus a few keys stripped from inside [features] (always respect_system_proxy, system_proxy_fallback, credential_masking and multi_agent_v2.message_board_remote; shell_snapshot and parts of network_proxy once a credential broker is configured), none of which cfgaudit reads.
cfgaudit drops those from a project file so it never reports configuration the CLI ignores — this is why a committed notify is not treated as a command site and a committed chatgpt_base_url does not trigger CFG071.
approval_policy, sandbox_mode, mcp_servers, default_permissions and permissions are not on that denylist, which is exactly why they are worth flagging in a committed file.
Trust gate
Project layers are "loaded but disabled when the directory is untrusted". That is a real mitigation, and the same one CFG086 records for Cursor's workspace trust — and the same reasoning applies: trust is granted once, often reflexively, and thereafter every clone is covered. It caps the blast radius; it is not a reason to leave the setting in the repository.
The gate was measured rather than assumed, and it applies to the whole project layer rather than to permission profiles specifically: a deliberately malformed project config.toml stays silent until the directory carries trust_level = "trusted", at which point its parse error surfaces. So permission profiles sit behind exactly the same precondition sandbox_mode and approval_policy already do, and no extra gate of the kind Codex applies to hooks was found in front of them.
Trigger example
sandbox_mode = "danger-full-access"
→ error. And, under workspace-write:
[sandbox_workspace_write]
network_access = true
writable_roots = ["/home/dev/.ssh", "/etc", "/srv/artifacts"]
→ error (network), error (credential path), error (system path), warn (/srv/artifacts).
And through the profile mechanism, which needs no sandbox_mode at all:
default_permissions = "ci"
[permissions.ci]
extends = "base"
[permissions.base.network]
enabled = true
mode = "full"
proxy_url = "http://proxy.internal.example:8080"
dangerously_allow_non_loopback_proxy = true
→ three errors, each naming base as the profile ci inherits from.
Safe alternative
sandbox_mode = "workspace-write"
[sandbox_workspace_write]
writable_roots = ["./build-cache"]
References
- CFG022 (Claude Code
sandboxweakening), CFG061 (Gemini sandbox), CFG095 (Cursor.cursor/sandbox.json) — equivalent rules for other agents - CFG063 (Codex
approval_policy) — the approval half of the same risk - OpenAI Codex CLI
Documentation for cfgaudit 1.18.1. Source of this page · Rule implementation