fix(security): resolve symlink write-guard bypass on Python 3.10#1256
Conversation
`is_write_blocked()` chained `Path(os.path.realpath(path)).resolve()`, but on Python <3.12 `Path.resolve()` uses a separate code path that can disagree with `os.path.realpath()` — symlinks pointing into blocked directories were not detected as blocked. Drop the redundant `.resolve()` call so `os.path.realpath` is the single source of truth for symlink resolution across all supported Python versions.
SummaryThis is a correct and minimal security fix. Chaining Issues Found🟢 Minor — Two sibling call sites still chain
|
itomek
left a comment
There was a problem hiding this comment.
Verified against PR-head security.py: is_write_blocked now resolves symlinks solely via os.path.realpath, and the fail-closed except still returns (True, ...), so the test swap from patching Path.resolve to patching os.path.normpath correctly keeps coverage of the blocked-on-error branch. One addition to the bot's sibling-site note inline. Approving.
Generated by Claude Code
Path.resolve() chained after os.path.realpath() re-resolves via a different code path on Python <3.12, causing symlinks to blocked directories to slip through. Use os.path.realpath exclusively. Cherry-picked from fix/symlink-security-3.10 (PR #1256).
Symlinks pointing into blocked directories (
.ssh,C:\Windows,/etc, …) were not detected byis_write_blocked()on Python <3.12 — the write guardrail returnedFalsewhen it should have returnedTrue. The Python 3.10/3.11 version matrix (#1247) surfaced this viatest_symlink_to_blocked_directory_is_blocked. On 3.12+ the test passed by accident becausePath.resolve()internally delegates toos.path.realpath; on older versions it uses a separate resolver that can disagree after symlink traversal.The fix drops the redundant
.resolve()call soos.path.realpathis the single source of truth for symlink resolution across all supported Python versions.Test plan
python -m pytest tests/unit/test_security_edge_cases.py tests/unit/test_file_write_guardrails.py -xvs— all 132 pass, 6 skipped (platform-specific)