openai / openai/codex

codex-security: _locked_parent locks every path ancestor, so scans under the Windows user profile fail with ERROR_ACCESS_DENIED

Open
#38,654 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

app bug sandbox skills windows-os
Dominant language
Rust
Stars
125k
Forks
19.5k
PR merge metrics
PR metrics pending

Description

Oggetto
codex-security: _locked_parent locks every path ancestor, so scans under the Windows user profile fail with ERROR_ACCESS_DENIED
Testo

Summary

codex-security plugin scans fail on Windows whenever the scan directory lives anywhere under C:\Users\<user>, which is the default. scripts/windows_scan_local_files.py::_locked_parent opens a directory handle on every ancestor of the scan root up to the volume root. Under [windows] sandbox = "elevated" the dedicated sandbox user has no ACL on the interactive user's profile directory, so CreateFileW("C:\Users\<user>") returns ERROR_ACCESS_DENIED (5) and the whole scan-local file operation aborts.

The failure is not fixable by relocating the scan directory: the default scan root is $CODEX_HOME/scans (deep_scan_workbench.py:759state_dir()), i.e. C:\Users\<user>\.codex\..., and the workspace itself is normally under the profile too. Every candidate path traverses the denied ancestor.

Environment
  • Codex desktop app, Windows (x64)
  • codex-security plugin 0.1.19 (openai-curated-remote)
  • ~/.codex/config.toml: [windows] sandbox = "elevated"
  • Python 3.12 (per scripts/__pycache__/*.cpython-312.pyc)
Root cause

scripts/windows_scan_local_files.py, lines 409–415:

python
        # Absolute-path Win32 calls remain safe only while every ancestor is
        # fixed in place. Otherwise an attacker could rename an ancestor of the
        # scan root and substitute a different tree at the stored path.
        for directory_path in (*reversed(root_path.parents), root_path):
            directory_handle = _open_directory(directory_path)
            assert directory_handle is not None
            handles.append(directory_handle)

_open_directory_create_file with FILE_READ_ATTRIBUTES | FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT. _create_file only tolerates ERROR_FILE_NOT_FOUND / ERROR_PATH_NOT_FOUND (and only when missing_ok=True); every other failure goes to _raise_last_error.

The walked chain for a default scan root is:

C:\  →  C:\Users  →  C:\Users\<user>  →  C:\Users\<user>\.codex  →  ...  →  <scan root>
                     ^^^^^^^^^^^^^^^^ ERROR_ACCESS_DENIED under the elevated sandbox

Note that the deeper, explicitly-granted directories open fine — Windows bypass-traverse-checking (SeChangeNotifyPrivilege, granted to Everyone by default) means an absolute open of a descendant does not require rights on that ancestor. Only the plugin's explicit per-ancestor open fails.

Why the current behaviour buys nothing

The stated goal is to prevent an attacker from renaming an ancestor of the scan root mid-operation. But a directory this process cannot open is also a directory it cannot lock. Hard-failing on ERROR_ACCESS_DENIED therefore adds no guarantee over skipping it — it only makes the scan impossible on the default install layout. An attacker who can rename C:\Users\<user> already owns the interactive account, which is strictly outside the scanner's threat model.

Proposed fix

Tolerate ERROR_ACCESS_DENIED for ancestors only, and keep the scan root itself mandatory. All existing guarantees are preserved: reparse points still fail hard anywhere, _verify_directory / _verify_handle_path still run on every handle actually obtained, the st_dev/st_ino identity re-check on the scan root is untouched, and any denied directory at or below the scan root still fails hard.

diff
--- a/scripts/windows_scan_local_files.py
+++ b/scripts/windows_scan_local_files.py
@@ -63,10 +63,12 @@
 _FILE_NAME_OPENED = 0x00000008
 _ERROR_FILE_NOT_FOUND = 2
 _ERROR_PATH_NOT_FOUND = 3
+_ERROR_ACCESS_DENIED = 5
 _ERROR_FILE_EXISTS = 80
 _ERROR_ALREADY_EXISTS = 183
 _MISSING_ERRORS = {_ERROR_FILE_NOT_FOUND, _ERROR_PATH_NOT_FOUND}
 _COLLISION_ERRORS = {_ERROR_FILE_EXISTS, _ERROR_ALREADY_EXISTS}
+_DENIED_ERRORS = {_ERROR_ACCESS_DENIED}
 _INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value
 _MAX_WRITE_CHUNK = 1024 * 1024

@@ -285,6 +287,7 @@
disposition: int,
flags: int,
missing_ok: bool = False,
+ denied_ok: bool = False,
) -> _OwnedHandle | None:
_require_windows()
handle = _CreateFileW(
@@ -300,6 +303,8 @@
error = ctypes.get_last_error()
if missing_ok and error in _MISSING_ERRORS:
return None
+ if denied_ok and error in _DENIED_ERRORS:
+ return None
_raise_last_error("CreateFileW", path)
return _OwnedHandle(int(handle))

@@ -365,7 +370,12 @@
return canonical, (expected.st_dev, expected.st_ino)

-def _open_directory(path: Path, *, missing_ok: bool = False) -> _OwnedHandle | None:
+def _open_directory(
+ path: Path,
+ *,
+ missing_ok: bool = False,
+ denied_ok: bool = False,
+) -> _OwnedHandle | None:
handle = _create_file(
path,
access=_FILE_READ_ATTRIBUTES,
@@ -373,6 +383,7 @@
disposition=_OPEN_EXISTING,
flags=_FILE_FLAG_BACKUP_SEMANTICS | _FILE_FLAG_OPEN_REPARSE_POINT,
missing_ok=missing_ok,
+ denied_ok=denied_ok,
)
if handle is not None:
try:
@@ -409,10 +420,30 @@
# Absolute-path Win32 calls remain safe only while every ancestor is
# fixed in place. Otherwise an attacker could rename an ancestor of the
# scan root and substitute a different tree at the stored path.
- for directory_path in (*reversed(root_path.parents), root_path):
- directory_handle = _open_directory(directory_path)
- assert directory_handle is not None
+ #
+ # Ancestors above the trusted scan root are frequently outside the
+ # process's granted read set. Under the Codex Windows sandbox the
+ # dedicated sandbox user has no ACL on the interactive user's profile
+ # directory, so CreateFileW on C:\Users&lt;name> fails with
+ # ERROR_ACCESS_DENIED even though every path under the granted scan
+ # root opens normally (Windows bypass-traverse-checking makes the
+ # absolute open succeed without rights on that ancestor).
+ #
+ # A directory this process cannot open is also a directory it cannot
+ # lock, under any configuration. Failing the whole operation there buys
+ # no additional guarantee and blocks every scan whose root lives under
+ # the user profile. Skip an ancestor that denies access and keep
+ # locking the reachable ones; a reparse point or an identity mismatch
+ # anywhere still fails hard, and the scan root itself must always be
+ # lockable.
+ for directory_path in reversed(root_path.parents):
+ directory_handle = _open_directory(directory_path, denied_ok=True)
+ if directory_handle is None:
+ continue
handles.append(directory_handle)
+ root_handle = _open_directory(root_path)
+ assert root_handle is not None
+ handles.append(root_handle)
current_root = root_path.lstat()
if (current_root.st_dev, current_root.st_ino) != expected_root_identity:
raise _invalid_path(scan_dir, "scan directory changed while it was being opened")

Test

A standalone regression test is attached (test_locked_parent_ancestors.py). It stubs the Win32 layer so it runs on any platform and asserts:

# | Case | Expected -- | -- | -- 1 | No denial | every ancestor and the root are locked (unchanged behaviour) 2 | Profile ancestor denied | denied ancestor skipped, operation completes, reachable ancestors below it still locked 3 | Multiple ancestors denied | all skipped, operation completes 4 | Scan root itself denied | still raises WindowsScanLocalFileError(errno=5) 5 | Child under the scan root denied | still raises WindowsScanLocalFileError(errno=5)

Result: 7/7 assertions pass on the patched module. Against the unpatched module the run aborts at case 2 with

wslf.WindowsScanLocalFileError: [Errno 5] CreateFileW: Access is denied: '/u/profile'

which is the reported failure.

Alternatives considered
  • /sandbox-add-read-dir C:\Users\<user> — works, but grants the sandbox user read access to the entire user profile (.ssh, .aws, .codex\auth.json) for the session. A poor trade when the whole point of the process is scanning potentially hostile repository content.
  • Relocating the scan root off the profile (e.g. --scan-root C:\codex-scans) — avoids the denied ancestor, but the flag is not reachable from normal desktop usage and the default remains broken.
  • Stopping the walk unconditionally at the scan root — simpler, but discards ancestor locking even where it is achievable. The patch above is strictly stronger.
Notes
  • Not verified on a live Windows host: the attached test stubs CreateFileW and the sandbox ACLs. The Win32 error code was inferred from the sandbox ACL model, not captured from a debugger. If the observed code is not 5, extending _DENIED_ERRORS should be sufficient.
  • The plugin ships under plugins\cache\..., so a local patch is overwritten on the next plugin update.

0001-stop-ancestor-lock-at-trusted-scan-root.patch

TestLockedParentAncentors.py

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in scripts/windows_scan_local_files.py, especially _locked_parent around lines 409–415, then trace _open_directory and _create_file error handling. Reproduce the default C:\Users scan scenario under the elevated Windows sandbox and verify that scans under the user profile complete without ERROR_ACCESS_DENIED while the intended ancestor-locking behavior remains covered.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
security
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
64/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.