Windows: codex sandbox passes empty deny-read overrides despite a named deny profile (0.153.4)
Nobody has claimed this yet.
- Dominant language
- Rust
- Stars
- 125k
- Forks
- 19.4k
- PR merge metrics
- PR metrics pending
Description
Summary
On native Windows with codex-cli 0.153.4, a synthetic private file remained readable
through codex sandbox -P b01 even though the requested profile denied its directory.
Write restrictions worked. The version-tagged debug CLI source passes an empty
deny-read override list to Windows setup, providing a concrete explanation to investigate.
This report concerns the standalone codex sandbox route. A separate app-server
experiment did enforce a deny on a fresh fixture; it also exposed a distinct cleanup
issue. That later experiment should not be interpreted as a complete isolation pass.
Environment and configuration caveat
- Observed CLI version: 0.153.4, installed through npm.
- Native Windows; earlier baseline reported build 10.0.26200.9168.
- Windows sandbox configured as elevated; tests launched from the normal Windows user,
outside a parent restricted-token sandbox, while retaining the inner Codex sandbox. - Fresh fixture owner verified as the normal user; no reparse points.
- Loaded user settings also contained legacy
sandbox_mode="workspace-write".
The test did not eliminate legacy/profile interaction.config/readretains the
named deny definition but is not proof of the debug CLI's final dispatched policy.
Reproduction structure
Use a new disposable, normal-user-owned directory. Below, FIXTURE denotes its absolute
path with forward slashes. Paths are anonymized; this recipe is an adaptation of the
executed fixture and has not been rerun verbatim at the illustrative location.
Use synthetic files only; native sandbox setup may leave filesystem ACL changes.
Create these nonempty synthetic files before launch:
FIXTURE/application.txt
FIXTURE/handoffs/record.txt
FIXTURE/handoffs-other/record.txt
FIXTURE/private/marker.txt
The executed reviewer launch supplied the following argument structure to codex.exe:
sandbox -P b01 -C FIXTURE
-c permissions.b01.filesystem={":minimal"="read","FIXTURE"="read","FIXTURE/private"="deny"}
-c permissions.b01.network.enabled=false
-- NODE_EXE PROBE_FILE reviewer
These are argv elements, not a paste-ready shell command. Replace FIXTURE, NODE_EXE
and PROBE_FILE with actual absolute paths and preserve each -c value as one argument.
The child used Node fs.readFileSync on application/private markers and appendFileSync
on the four listed write targets. It printed only operation results, not file contents.
The root variant added "FIXTURE/handoffs"="write" to the same filesystem table.
For a minimal private-read-only reproduction, save this adapted Node wrapper as
read-only-repro.cjs and run node read-only-repro.cjs ABS_CODEX_EXE ABS_FIXTURE.
The two application/private markers must already exist. This wrapper was not executed;
the matrix below records the original six-case fixture, not this shortened wrapper.
It assumes an already-configured elevated Windows sandbox; the wrapper does not
select or enforce that backend. Verify the backend before interpreting a run.
const fs = require('node:fs');
const path = require('node:path');
const {spawnSync} = require('node:child_process');
const [exe, input] = process.argv.slice(2);
if (!exe || !input || !path.isAbsolute(exe) || !path.isAbsolute(input))
throw Error('Supply absolute codex.exe and disposable fixture paths');
const fixture = fs.realpathSync(input).replaceAll('\\', '/');
for (const name of ['application.txt', 'private/marker.txt'])
if (!fs.statSync(path.join(fixture, name)).isFile()) throw Error('Missing marker');
const rules = `permissions.b01.filesystem={":minimal"="read",${JSON.stringify(fixture)}="read",${JSON.stringify(fixture+'/private')}="deny"}`;
const code = `const fs=require('node:fs');for(const name of ['application.txt','private/marker.txt']){try{fs.readFileSync(${JSON.stringify(fixture)}+'/'+name);console.log(JSON.stringify({name,readable:true}));}catch(e){console.log(JSON.stringify({name,readable:false,error:e.code}));}}`;
const keys = new Set(['SYSTEMROOT','WINDIR','COMSPEC','PATH','PATHEXT','TEMP','TMP',
'USERPROFILE','APPDATA','LOCALAPPDATA','PROGRAMFILES','PROGRAMFILES(X86)',
'PROGRAMDATA','HOMEDRIVE','HOMEPATH','USERNAME','USERDOMAIN']);
const env = Object.fromEntries(Object.entries(process.env).filter(([k])=>keys.has(k.toUpperCase())));
const result = spawnSync(exe, ['sandbox','-P','b01','-C',fixture,'-c',rules,
'-c','permissions.b01.network.enabled=false','--',process.execPath,'-e',code],
{cwd:fixture,env,encoding:'utf8',timeout:15000,windowsHide:true});
console.log(JSON.stringify({status:result.status,signal:result.signal,
error:result.error?.message,stdout:result.stdout,stderr:result.stderr},null,2));
Expected: application readable, private denied with EPERM/EACCES. A missing-file or
launch error is not a successful denial. This script's exit alone is not acceptance.
The wrapper's 15-second timeout is INCONCLUSIVE, especially if sandbox setup needs
more time; it is neither evidence of correct denial nor a reproduced readability bug.
Expected and actual results
| Operation | Root actual | Reviewer actual | Expected |
|---|---|---|---|
| Read application marker | allowed | allowed | allow both |
| Append application marker | EPERM | EPERM | deny both |
| Append handoff marker | allowed | EPERM | root only |
| Append sibling-prefix marker | EPERM | EPERM | deny both |
| Append resolved handoffs/../application.txt | EPERM | EPERM | deny both |
| Read private marker | allowed | allowed | deny both |
Both children returned status 2 (assertion failure), signal null, empty stderr.
Exactly one permitted root handoff append was verified; other marker hashes matched.
The traversal target was normalized before the filesystem call, so this is not a
literal traversal-parser test. A prior private-read-only CLI control with an explicit
default_permissions="b01" override also left the marker readable.
Version-tagged source lead
In debug_sandbox.rs,
run_command_under_windows_session supplies empty deny-read and deny-write overrides.
The elevated chain forwards the supplied read-deny list through
elevated.rs,
spawn_prep.rs,
and identity.rs.
setup.rs
does not derive missing read denies in build_payload_deny_read_paths. By contrast,
the Windows resolver
explicitly resolves deny-read paths for elevated overrides.
This is source analysis, not instrumentation of the installed binary. The actual
setup payload has not been captured, and installed helper/source equivalence is
not independently established. Please check propagation on the debug CLI route
and add a Windows regression test that verifies an explicit denied synthetic read.
Related reports: #31265 and
#42184, with different versions/policies.
They are related symptoms, not asserted duplicates.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with run_command_under_windows_session in codex-rs/cli/src/debug_sandbox.rs, then trace the deny-read list through windows-sandbox-rs/unified_exec/backends/elevated.rs, spawn_prep.rs, identity.rs, and setup.rs. Compare that path with sandboxing/src/windows.rs, add a Windows regression test using the synthetic fixture, and verify that the explicit private read is denied while the allowed marker remains readable.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- node.js, rust
- Domain
- cli, operating-systems, security, testing
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 52/100