codex-security: _locked_parent locks every path ancestor, so scans under the Windows user profile fail with ERROR_ACCESS_DENIED
Nobody has claimed this yet.
- 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:759 → state_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-securityplugin0.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:
# 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.
--- 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<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:
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
CreateFileWand the sandbox ACLs. The Win32 error code was inferred from the sandbox ACL model, not captured from a debugger. If the observed code is not5, extending_DENIED_ERRORSshould be sufficient. - The plugin ships under
plugins\cache\..., so a local patch is overwritten on the next plugin update.
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 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