fix(papyrus): keep the located Tectonic binary inside the unpacked tree - #9057
fix(papyrus): keep the located Tectonic binary inside the unpacked tree#9057leonlaiyc wants to merge 2 commits into
Conversation
`_locate_binary` filters its walk with `candidate.is_file() and not candidate.is_symlink()`, so refusing a link-mediated escape is already the site's own stated intent. That filter is blind to the escape that costs the most: `rglob` DESCENDS through a directory link, and the executable it finds on the far side is an ordinary file — `is_file()` true, `is_symlink()` false — so it passes unchanged while resolving outside the tree. On Windows the link is typically a junction, which `is_symlink` does not report at all. What follows the return is not a read. `_provision_once` hands the path to `_install_binary`, which `shutil.move`s it (the file leaves its original location), applies `_BINARY_MODE`, and `os.replace`s it onto `binary_path()` — the compiler papyrus then executes. The `direct = tree / wanted` fast path is worse: `is_file()` follows a link, so that branch never had even the walk's own `is_symlink` filter. Both branches now require the candidate to resolve inside the tree. Resolving is what makes one check hold for both shapes: it follows every reparse point on the way down and answers where the bytes actually live. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Design Review (Fable 5.1, fork) — ✅ PASSDesign-level review of Design-Verdict: PASS Canonical TOCTOU fix: containment is witnessed on a held descriptor and the install copies from that fd, so the checked inode and the installed inode are the same — fails closed on every unknowable-path route. [DESIGN-REVIEWED] 776a080 |
Opus 4.8 Review (fork) — 🔴 changes requested (blocking)Reviewed BLOCKING — the destination staging write now follows a planted symlink, an arbitrary-file overwrite the old rename did not have. [BLOCK-MERGE] 776a080 BLOCKING — src/kiro_crew/apps/builtins/papyrus/backend/tectonic.py ( The PR's own accepted actor — a local writer in the vendor directory — plants a symlink at the predictable staging name [OPUS-REVIEWED] 776a080 |
First Principles Review (Fable 5.1, fork) — ✅ PASSPremise-level review of This is a well-scoped security fix. Every changed symbol is consumed only within First-Principles-Verdict: PASS Closes a local-writer race where the located Tectonic binary was validated by name then reopened for install; every item serves that one fix. What this change shipsIntent: stop an arbitrary local file from being promoted to the executed Tectonic compiler via a junction/symlink swapped into the per-process unpack tree between validation and install. This is a FIX.
Root cause ("check and use are two lookups of one name") is named and fixed at cause level, reusing [FIRST-PRINCIPLES-REVIEWED] 776a080 |
GPT 5.6 Review (fork) — 🔴 changes requested (blocking)Reviewed 1 of 1 blocking finding(s) are security-class and were withheld from adjudication, so the blocking verdict stands. BLOCKING -- src/kiro_crew/apps/builtins/papyrus/backend/tectonic.py:833 -- Predictable staging path follows planted links Adjudication (Fable 5.1) — is blocking on each finding proportionate?API Error: 400 Claude Code 2.1.240 does not support this model; version 2.1.255 or newer is required. Run 'claude update', or update the Claude desktop app, then try again. |
|
Addressing the exact-head F1 — TOCTOU at Reproduced against the base commit before fixing it:
FINDING — Also branch-caused, and fixed here:
|
|
On the exact-head The finding is right. The prescribed fix is "hold a descriptor/handle for So there is no directory descriptor to hold. And this module already has a stated posture for exactly this situation, which I do not think I get to choose on my own. So the two honest routes both belong to a repository writer:
What is already fixed on this head stands on its own and is not affected by the above: the file-level check-to-use race is closed (open once, Not touching the SHA, not rerunning, not rebasing. Happy to implement either route on a maintainer's word. |
The containment check added in the first commit resolves a path and then hands the path to `_install_binary`, which opens it again. The inode that was checked and the inode that is installed are two separate lookups, so a local writer who retargets the per-process `.provision.<pid>` tree in the window between them still has an arbitrary file chmodded 0o755 and moved onto the path papyrus executes. Measured on the base commit: the by-name check accepts the candidate, the link is retargeted, and the planted bytes are what land at `binary_path()`. `_open_inside` inverts the order — open with `O_NOFOLLOW` where it exists, then ask the kernel where the open descriptor actually is via `pinned_fs.fd_real_path` — and `_install_binary` now copies from that descriptor instead of moving a name. The unpacked root is captured in `_provision_once` before the archive is written into it, because resolving it later would reopen the same window one level up. Every failure route fails closed. The by-name filter stays as an early refusal with a useful message, and says in its own docstring that it is not the containment witness. Two test corrections, both from the same reading error. The fast-path case built a directory link, so `is_file()` was false and it silently exercised the walk instead; it needs a file symlink and is skipped where one cannot be created. The walk-descent case asserted that `rglob` descends the link, which is true for a Windows junction and false for a POSIX directory symlink — measured on both, and the Linux shard failed on exactly that assertion. It is now marked for the platform whose shape it describes, keeping the guard-the-guard so a behaviour change fails loudly rather than passing vacuously. `tectonic.py` became black-clean as a result, so its baseline entry is pruned; the gate fails otherwise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3b4bcaf to
776a080
Compare
|
Two CI corrections at Branch-caused, mine, fixed. Detaching the link is not the same call on both platforms and I only checked one. A POSIX directory symlink is a link entry, so Not branch-caused: The run merged this branch into One thing worth a maintainer's eye: |
|
@leonlaiyc Thanks for this one. I audited #9057 against Nothing of this fix has landed yet. On What is still needed before this can merge:
I will add Posted from the 2026-09-08 open-PR relationship audit (read-only, one auditor per PR); reply here if any of this is wrong. |
Problem / Motivation
tectonic._locate_binarypicks the executable out of an extracted archive and_provision_onceinstalls it onto the path papyrus later executes. The walkfiltered candidates with
candidate.is_file() and not candidate.is_symlink(), sorefusing a link-mediated escape was already the site's own intent — but the filter
cannot see the escape that matters.
Path.rglobdescends a directory link, and the executable on the far side is anordinary file:
is_file()true,is_symlink()false. It passed the filter unchangedwhile living outside the tree. On Windows the link is a junction, which
os.path.islinkdoes not report at all — measured on an unelevated box:The
tree / wantedfast path was weaker still: it took the candidate onis_file()alone, which follows a link, so it had no filter at all.
A resolve-and-compare is not enough, and the first commit here only did that.
Reviewers held it, correctly: the check resolves a path and then
_install_binaryopens the same path again. The inode that was checked and the inode that is
installed are two separate lookups, and a writer who retargets the unpack directory
in the window between them wins. Measured against the base commit:
Why it matters
The located path is not read — it is installed.
_install_binarymoves it, applies_BINARY_MODE(0o755) andos.replaces it ontobinary_path(), andbinary_installedre-checks only size and the exec bit, so a plausibly-sized plantedfile passes. An arbitrary local file is promoted to an executed compiler with no
recovery once it is in place.
Stated plainly and not inflated: the asset is pinned and fetched over TLS, and
neither tar nor zip can create a Windows junction, so the archive cannot plant this.
It needs a local writer racing the per-process
.provision.<pid>unpack directory.That is the containment layer this walk was already reaching for — a defence that
was inert on the platform its own link shape belongs to, not a remote-archive escape.
What changed (motivation → approach → change)
Root cause: the check and the use are two lookups of one name, and the by-name
check is additionally blind to a junction.
Approach: resolve once, open once, address everything downstream through the
descriptor. That is the discipline
pinned_fsalready exists for in this repo, andthis change consumes its
fd_real_pathrather than adding a second mechanism. Theheavier
stage_tree_pinnedwas not used: it is gated onsupports_pinned_walk(),which is false on Windows — the platform this defect lives on.
_open_inside(candidate, root_real)— opens withO_NOFOLLOWwhere it exists,rejects a non-regular file on the descriptor's own
fstat, then asks the kernelwhere the open descriptor actually is (
/proc/self/fd,F_GETPATH, orGetFinalPathNameByHandleW) and compares that against the root. A descriptorcannot be re-pointed, so the answer stays true while it is held. Every failure
route — unreadable, non-regular, unknowable real path — returns
None, never afallback to the pathname.
_install_binarytakes a descriptor, not a path, and copies from it. Theatomic-publish shape is unchanged: staged in the target directory, mode applied
before the rename,
os.replaceonto the final path. The cost is a copy instead ofa rename, once, for an artifact that was just fetched over the network.
_provision_once, immediately afterunpacked.mkdir()and before the archive is written into it. Resolving it inside the locator would
reopen the same window one level up, since the directory whose containment is
being asserted is exactly the one an attacker would swap.
error message, and its docstring now says it is not the containment witness.
os.fstat(fd)rather thanbinary.stat(), so the sizethat gates the install is the size of the file that gets installed.
Tests
TestTheInstalledBytesAreTheValidatedBytes— the property is not "the check isstricter" but "the check and the use address one object":
test_retargeting_the_link_after_validation_cannot_change_what_is_installed— theregression for the race. The link points inside the tree at validation, so a
by-name check accepts it; it is retargeted outside before the install. Asserts the
installed bytes are the validated ones. Deterministic: the swap is placed where a
racing writer would land rather than run concurrently and hoped for.
test_a_file_reached_through_a_directory_link_gets_no_descriptor— guards itselfby asserting the lexical path is inside the tree, so a name-only check would
have accepted it.
test_a_real_file_inside_the_tree_gets_a_descriptor_on_its_own_bytes— positivecontrol, and the one that catches an over-tight comparison:
fd_real_pathandrealpathreach the same name by different kernel routes.test_containment_fails_closed_when_the_real_path_is_unknowable,test_a_directory_never_yields_a_descriptor— the refusal routes.TestLocateBinaryStaysInsideTheUnpackedTreekeeps the by-name filter's own coverage,with two corrections a reviewer was right about:
is_file()was false and itsilently exercised the walk instead. It now uses a file symlink — the only
shape that reaches that branch — and is skipped where one cannot be created.
rglobdescends the link. That is true for aWindows junction and false for a POSIX directory symlink: pathlib's
**deliberately does not descend one, and the Linux shard failed on exactly that
guard-the-guard assertion (
rglob never descended the link, so nothing was under test). Measured on both platforms; the test is now marked for the platform whoseshape it describes, and keeps the guard-the-guard so a behaviour change fails
loudly rather than passing vacuously.
Negative control run against the base commit (output quoted above): the unpatched
install writes the planted bytes, the patched install writes the genuine ones.
.github/black-baseline.txt:tectonic.pybecame black-clean as a result of thischange, and the gate fails on a graduated entry that is still listed, so its row is
pruned. No file was reformatted.
Manual verification
N/A — unit coverage sufficient: the whole defect is the relationship between the
validation and the install, and the tests drive both against a real junction on the
affected platform rather than a mock.
Related Issues
None — found by inspection while auditing
is_symlink-based containment guards forthe Windows junction blind spot, the same family as #7881.
Pattern harvest
Rule candidate:
review-promptPattern: a containment guard that validates a path and then reopens it. Two
distinct failures ride together — the guard tests the candidate's own node type
(
is_symlink/islink) rather than where it resolves, and a Windows junction isinvisible to both
islinkandDirEntry.is_symlink, so a recursive walk descendsit. Where the walk is
rglob/os.walkand the result is later written, moved orexecuted,
resolve()-then-reopen is not a fix: open once and address the descriptor.Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)Contribution License Agreement
🤖 Generated with Claude Code