fix(kernel): close verify_evidence TOCTOU via fd-pinned open-then-verify (#67) - #69
Merged
Merged
Conversation
…ify (#67) Replace the containment-check-then-read-by-name flow with os.open(resolved, O_RDONLY|O_NOFOLLOW|O_NONBLOCK) -> fstat -> S_ISREG -> fdopen hashing. A leaf symlink swap between check and read now fails ELOOP (missing_evidence_path) instead of hashing attacker-controlled content outside the workspace; an already-open fd is immune to later directory-entry changes. O_NONBLOCK stops a FIFO swap from hanging the open. Flags degrade via getattr on platforms without them. The strict xfail pinning the vulnerability flips to a normal passing test. Known residuals (by design, documented in the PR): intermediate-directory component races (needs non-stdlib openat2) and hardlink swaps (regular files, O_NOFOLLOW inert).
There was a problem hiding this comment.
Pull request overview
This PR hardens verify_evidence() against a workspace-escape TOCTOU (symlink swap between containment check and read) by switching to an fd-pinned open-then-verify flow, and updates tests to validate the new behavior (including flipping the prior strict xfail into a normal passing test).
Changes:
- Replace post-containment name-based checks/reads with
os.open(...O_NOFOLLOW...)+os.fstat+os.fdopento make verification immune to later directory-entry swaps. - Update evidence I/O-failure test to monkeypatch
os.open(now the relevant syscall boundary). - Update the adversarial symlink-swap test to target
os.openand assert the safe failure mode.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
loop/evidence.py |
Switches evidence verification to an fd-pinned open/fstat/read flow to close the TOCTOU gap. |
scripts/test_evidence.py |
Retargets the I/O-failure monkeypatch from Path.open to os.open to match the new implementation. |
scripts/test_adversarial_process.py |
Removes the strict xfail and updates the symlink-swap adversarial test to validate the fd-pinned mitigation. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
176
to
180
| digest = hashlib.sha256() | ||
| try: | ||
| with resolved.open("rb") as source: | ||
| with os.fdopen(fd, "rb") as source: | ||
| while chunk := source.read(64 * 1024): | ||
| digest.update(chunk) |
Comment on lines
182
to
186
| def test_symlink_swap_between_containment_check_and_hash_read_escapes_workspace(tmp_path, monkeypatch) -> None: | ||
| inside = tmp_path / "proof.txt" | ||
| outside = tmp_path.parent / f"{tmp_path.name}-outside-proof.txt" | ||
| inside.write_bytes(b"inside") | ||
| outside.write_bytes(b"outside") |
This was referenced Jul 15, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #67.
phase3-run S1 — shipped via the governed Claudex lane (worker
gpt-5.6-terra/medium, codex session019f6591-0623-77a1-b078-56c85e1e4e70, receiptcx_s1_toctou_a1, attempt 1 accepted).What
verify_evidence()resolved the evidence path, checked workspace containment, then re-looked the path up by name foris_file()andopen("rb")— a symlink swap between check and read escaped the workspace (pinned by the strict xfail from #66). The post-containment block is now fd-pinned:os.open(resolved, O_RDONLY | O_NOFOLLOW | O_NONBLOCK)→os.fstat/S_ISREGon the raw fd →os.fdopenhash read.O_NOFOLLOWfails a swapped leaf with ELOOP (→missing_evidence_path); an open fd is immune to later directory-entry changes, so no second name lookup exists to race.O_NONBLOCKkeeps a FIFO swap from hanging the open. Flags compose viagetattr(os, flag, 0)so platforms without them degrade to today's semantics instead of crashing.Path.is_file→os.open); the io-failure test retargetsPath.open→os.open.Known residuals (scoped out, by design)
openat2(RESOLVE_NO_SYMLINKS)).O_NOFOLLOWis inert. Distinct class from the symlink escape this closes.Evidence