Skip to content

fix(kernel): close verify_evidence TOCTOU via fd-pinned open-then-verify (#67) - #69

Merged
SollanSystems merged 1 commit into
mainfrom
fix/s1-verify-evidence-toctou
Jul 15, 2026
Merged

SollanSystems merged 1 commit into
mainfrom
fix/s1-verify-evidence-toctou

Conversation

@SollanSystems

Copy link
Copy Markdown
Owner

Closes #67.

phase3-run S1 — shipped via the governed Claudex lane (worker gpt-5.6-terra/medium, codex session 019f6591-0623-77a1-b078-56c85e1e4e70, receipt cx_s1_toctou_a1, attempt 1 accepted).

What

verify_evidence() resolved the evidence path, checked workspace containment, then re-looked the path up by name for is_file() and open("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_ISREG on the raw fd → os.fdopen hash read. O_NOFOLLOW fails 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_NONBLOCK keeps a FIFO swap from hanging the open. Flags compose via getattr(os, flag, 0) so platforms without them degrade to today's semantics instead of crashing.

  • Issue-code vocabulary unchanged; fd closed exactly once on every branch (reviewer-audited).
  • The strict xfail flips to a normal passing test (same adversarial mechanics, monkeypatch retargeted Path.is_fileos.open); the io-failure test retargets Path.openos.open.

Known residuals (scoped out, by design)

  • Intermediate-directory-component races mid-walk (closing them needs non-stdlib openat2(RESOLVE_NO_SYMLINKS)).
  • Hardlink swaps — hardlinks are regular files; O_NOFOLLOW is inert. Distinct class from the symlink escape this closes.

Evidence

  • Deterministic gates 4/4 exit 0 in a fresh worktree; suite totals exact: extras 694 passed / 16 skipped / 0 xfail (was 693/16/1x), pyyaml-only 644 / 66 / 0 xfail (was 643/66/1x) — the xfail flip only.
  • Fresh sonnet review: PASS, 0 blockers (per-statement exception enumeration + fd-lifecycle audit of all 4 post-open paths).
  • Governor holdout gate (5 probes the worker never saw): Succeeded 5/5, false_completion: false — incl. fstat-time swap reading the pinned inode (fd-pinning proof), FIFO no-hang, chmod-000 EACCES, hardlink-honesty (residual confirmed, not over-claimed), and pre-existing direct-symlink rejection unchanged.

…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).
Copilot AI review requested due to automatic review settings July 15, 2026 11:54
@SollanSystems
SollanSystems enabled auto-merge (squash) July 15, 2026 11:54
@SollanSystems
SollanSystems merged commit 0f0acf1 into main Jul 15, 2026
11 checks passed
@SollanSystems
SollanSystems deleted the fix/s1-verify-evidence-toctou branch July 15, 2026 11:55

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.fdopen to 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.open and 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 thread loop/evidence.py
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")
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

verify_evidence() TOCTOU: re-check workspace containment before the hash read (symlink-swap escape)

2 participants