cat secret.env under codex sandbox --permission-profile env-deny exited 1. The error was Permission denied. A write in the same repository under :read-only failed with a different sentence: Read-only file system. Those are not two wordings of one policy. One is a path rule. The other is a read-only mount. A domain allowlist has a third failure, and a raw socket does not produce it.
On Codex CLI 0.163.0, a profile with network.enabled = true and the network proxy feature left off opened a direct TCP connection to example.com port 80. The first bytes back were HTTP/1.1. The same binary, with features.network_proxy = true and an allow entry only for example.com, refused http://info.cern.ch/ through the proxy client. The body was JSON, and the message was Domain not in allowlist. A Python socket connect in that second configuration never reached the allowlist check. Name lookup failed, and a connect to the address resolved outside the sandbox was Network is unreachable.
If you copy a domain table into config.toml and then test it with a raw socket, you will either see a connection that ignores the table or a failure that does not name the table. Neither result tells you the allowlist is doing what the permissions page describes.
What this run used
The binary was Codex CLI 0.163.0 on Linux x86_64. codex doctor on the disposable home said 0.166.0 was available. Nothing in this article describes 0.166.0. The permissions page and the configuration reference were read on 30 October 2026. A newer CLI can resolve the same keys differently.
The run used a disposable CODEX_HOME and a disposable Git repository. Neither was the normal Codex home. The repository held marker.txt, secret.env, and nested/app.env. The env files held short markers, not credentials. codex doctor --summary on that home reported that no Codex credentials were found. No codex login was run, and no model turn was started. The checks below are codex sandbox commands, plus one doctor comparison. They do not show what a signed-in agent session will ask approval for.
The host PATH had no bwrap. The Codex package still contained codex-resources/bwrap, and that helper printed bubblewrap built for Codex. The read-only profile mounted / with ro,nosuid,nodev,relatime anyway. Do not treat a missing system bubblewrap package as proof that the sandbox cannot start. Look at the package helper, then look at the error from a write.
CODEX_HOME under the process temporary directory was a bad place for this binary. Codex warned that it was refusing to create helper binaries there. Put the disposable home on a normal directory, export CODEX_HOME to that directory, and pass --cd as the repository root. The sandbox subcommand requires both --permission-profile and --cd. Omitting the profile exits 2 before any command runs.
Built-in profiles
:read-only could read marker.txt. A shell redirect that tried to create marker-written.txt exited 2:
sh: 1: cannot create marker-written.txt: Read-only file system
:workspace created that file. The same profile could not create a file in a sibling directory that was not a temporary path, and it could not create .git/PROBE_NOTE. Both failures used the read-only filesystem wording. A write to the process temporary directory succeeded. On this binary, :workspace is not “the repository and nowhere else.” Temporary directories are writable, and .git inside the repository is not.
:workspace could also read secret.env. The built-in profile does not deny environment files. If that is the boundary you want, you have to add it.
An unknown profile name is reported as a default_permissions problem, not as an unknown flag. With an empty home the error was default_permissions requires a [permissions] table. After a permissions table existed, the same name failed with default_permissions refers to undefined profile. The flag selects a profile. A typo does not fall back to :workspace.
A deny rule is a different error
The custom profile used for the file check was small. It extends :workspace and denies a glob under the workspace roots:
default_permissions = "env-deny"
[permissions.env-deny]
description = "Workspace write with env files denied."
extends = ":workspace"
[permissions.env-deny.filesystem.":workspace_roots"]
"**/*.env" = "deny"
extends = ":workspace" is the part that keeps the built-in carve-outs. The permissions page says an extension of :workspace keeps .git, .agents, .codex, and .aws read-only unless you override those rules. This run did not need the extension to protect .git: the built-in :workspace profile already refused .git/PROBE_NOTE. The extension is still the shape to copy, because a profile written from scratch can drop protections you did not mean to drop. The page also says a profile cannot extend :danger-full-access. That parent was not tested here.
The glob is the part that was measured. cat secret.env and cat nested/app.env both exited 1 with Permission denied. cat marker.txt still printed the marker. A write of a new text file in the repository root succeeded. An append to secret.env failed with cannot create secret.env: Permission denied, and the file on disk was unchanged.
That is the check. Permission denied means the path rule matched. Read-only file system means the write never got as far as a per-file deny, because the mount itself is read-only, or because the path is outside the writable roots. If you only run the read-only profile, you will not see whether **/*.env matches a nested file. If you only run :workspace, you will not see the deny at all.
The configuration reference says deny denies access and wins over an equally specific read or write entry. This run saw both the read and the write fail, for a root file and for nested/app.env. It did not try a more specific allow beside the glob, so it does not show which rule wins when two entries match the same path.
The domain table is a proxy rule
The permissions page is explicit about network, and the run agreed with the warning rather than with a hopeful reading of the table.
A profile’s network.enabled = true permits command network access. It does not start the network proxy. Domain rules are enforced when features.network_proxy is true, or when an administrator-managed network requirement starts the proxy. Without that proxy, the page says commands can connect directly and the domain table does not restrict them.
The direct case was a profile named net-open, extending :workspace, with network enabled and no domain table. features.network_proxy was not set. A TCP connection to example.com port 80 completed, and the response began HTTP/1.1. In an earlier pass, a profile that did list an allow domain still completed a connect to a literal address while the proxy feature was off. The table was present in the file. It was not the control on that socket.
:read-only and :workspace, which do not enable network, failed earlier than a timeout. socket() raised Operation not permitted. That is the closed case. It is not an allowlist decision.
Turning the proxy feature on changed the path for every profile loaded from that home, including a profile that enabled network and listed no domains. Direct name lookup then failed with Temporary failure in name resolution. A connect to an address resolved outside the sandbox failed with Network is unreachable. Those errors do not mention an allowlist. They are what a raw socket sees when the sandbox has taken the direct path away.
The allowlist showed up only for a client that used the proxy environment Codex injected. In the proxy run, HTTP_PROXY and ALL_PROXY were set, and CODEX_NETWORK_PROXY_ACTIVE was 1. The listeners were loopback ports chosen for that process. They were not the configuration reference defaults of port 3128 and port 8081. Python’s urllib honored the proxy variables. http://example.com/ returned status 200. http://info.cern.ch/, http://neverssl.com/, and http://iana.org/ each returned HTTP 403 with this body:
{"status":"blocked","host":"info.cern.ch","reason":"not_allowed","decision":"deny","source":"baseline_policy","protocol":"http","port":80,"message":"Domain not in allowlist."}
Those three hosts returned status 200 from the same machine outside the sandbox, so the 403 is not the origin server refusing this network. example.org also returned 403 outside the sandbox, which is why it was not used as the control. A 403 with no JSON body is not enough. Read the body. reason was not_allowed, decision was deny, and source was baseline_policy.
The profile under test allowed only example.com. The blocked host in the sample above is the host that was requested, not a second rule you have to write. An omitted host is denied once the proxy is the path. That matches the configuration reference: if the active proxy has no allow entry for the destination, the request is blocked, and a deny entry would override an allow entry. This run did not add a deny beside an allow for the same host, so the override order was not measured. What was measured is that an omitted public host, one that answers outside the sandbox, is refused with that JSON when the client goes through the injected proxy.
A raw socket is the wrong instrument for that question. It never sends the request the proxy can classify. Use an HTTP client that honors the proxy variables, request a host you have confirmed returns 200 outside the sandbox, and require the JSON deny before you call the allowlist enforced.
What doctor does not settle
The permissions page says permission profiles do not compose with the older sandbox settings. If sandbox_mode appears in any loaded config file, if you pass --sandbox, or if the selected config profile sets sandbox_mode, Codex uses those older settings instead of default_permissions. Managed allowed_permission_profiles is the exception named on that page. This run had no managed requirements file.
codex doctor --summary did not expose that choice. Three disposable homes were compared: sandbox_mode = "read-only" alone, default_permissions = ":workspace" alone, and both keys together. The sandbox row was the same in all three: restricted fs + restricted network ยท approval OnRequest. Doctor is not the command that tells you which of those keys won.
codex sandbox --permission-profile :workspace still created a file when the home also contained sandbox_mode = "read-only". The explicit sandbox subcommand applied the named profile. It did not silently become the read-only sandbox mode from the file. That does not refute the page. The page is about which setting a session uses for default_permissions. This binary was not signed in, so no agent session was started, and --sandbox on an agent command was not compared with default_permissions. If you need that interaction, sign in on a disposable home and read the session’s sandbox choice. Do not infer it from codex sandbox -P, and do not infer it from the doctor row above.
:danger-full-access was not used. It removes local sandbox restrictions. There is no useful “did the deny stick” result from a profile that is defined as unrestricted, and this run had no reason to grant that access to a command.
Verify the profile
Use a disposable home and a disposable repository. Do not point CODEX_HOME at your normal Codex directory, and do not put it under the process temporary directory. Export it, then run the sandbox command with the profile name you intend to trust.
export CODEX_HOME=<codex-home>
codex sandbox --permission-profile :read-only --cd <probe-repo> -- cat marker.txt
codex sandbox --permission-profile :read-only --cd <probe-repo> -- sh -c 'printf wrote > marker-written.txt'
codex sandbox --permission-profile :workspace --cd <probe-repo> -- sh -c 'printf wrote > marker-written.txt'
codex sandbox --permission-profile env-deny --cd <probe-repo> -- cat secret.env
codex sandbox --permission-profile env-deny --cd <probe-repo> -- cat nested/app.env
Read the error text. Read-only file system is the mount or the writable-root boundary. Permission denied is the glob. Then confirm the env file’s contents did not change after a failed append. A successful write of an ordinary text file in the same directory is what shows that the profile can still edit the repository.
For network, keep the two configurations separate. With the proxy feature off and network.enabled = true, expect a direct connect to succeed even if a domain table is sitting in the file. With features.network_proxy = true and a single allow host, use a client that reads the proxy environment. example.com should succeed if that is the allow entry. A second public host that returns 200 outside the sandbox should fail with Domain not in allowlist. If the second host already fails outside the sandbox, pick a different one. example.org was a bad control on this network for that reason.
codex sandbox --permission-profile net-allow --cd <probe-repo> -- python3 -c 'import urllib.request; print(urllib.request.urlopen("http://info.cern.ch/", timeout=8).status)'
The status line is not the whole result. If the call raises, read the response body. You want decision equal to deny and message equal to Domain not in allowlist. A DNS error, Operation not permitted, or Network is unreachable means you are not looking at the allowlist yet.
This check does not sign you in, does not start an agent, and does not prove which of sandbox_mode and default_permissions a future chat will use. It does prove, on the binary you just ran, whether the named profile can read a marker, whether it can write one, whether an env glob denies a nested file, and whether the domain table is actually on the proxy path. Run it against the profile you are about to leave in a config file. The file is not the enforcement.
Sources
- Permissions, read 30 October 2026: profiles do not compose with
sandbox_mode;network.enableddoes not start the proxy; domain rules needfeatures.network_proxyor a managed network requirement;denywins over an equally specific allow; extending:workspacekeeps.gitread-only unless overridden - Configuration reference, read 30 October 2026: network.enabled under a named profile, domain rules, and the documented proxy listener defaults
- ChatGPT and Codex changelog, read 30 October 2026: Codex CLI 0.166.0 is listed above the 0.163.0 binary used here
codex sandbox --permission-profileon Codex CLI 0.163.0, 30 October 2026, against the disposable home and repository described above