You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
uv hosted → vendored takeover (scan / get --mode vendored) restores the package to PyPI before the uv vendored refusals run, so an inline [tool.uv] sources = {…} table (or a #928 marker split) leaves it unpatched in both modes, while --dry-run previews would_vendor #944
[agent] Found by the scheduled uv bug-hunt routine (ledger #310).
Summary
In a uv project that hosted mode has already patched, scan --mode vendored and get --mode vendored first restore the hosted pin to its PyPI entry (vendor_takeover_reverted_redirect), and only then run the uv vendored backend. When that backend refuses a shape that hosted mode accepts, the run exits 1 with the restore already committed. The package ends up neither hosted nor vendored, so the next uv sync installs the unpatched release. --dry-run doesn't predict any of this: it reports would_vendor with exit 0.
Two triggers I confirmed:
uv.lock project with an inline sources table, [tool.uv] sources = { attrs = { index = "pypi" } }. Hosted mode grows the inline table and patches six. The vendored backend refuses it with apply_failed / pypi_uv_lock_parse_failed: pyproject.toml [tool.uv.sources] is not a standard table. That refusal is intentional, but here it fires after the restore.
Any other uv vendored-only refusal reached after the restore probably behaves the same way. The cause is that no PyPI vendored preflight runs before restore_upstream.
Impact
A user who switches a patched project from hosted to vendored silently loses the security patch. The run does exit 1, but its events say six "was hosted; restored its upstream registry entry", and the committed files are byte-identical to the pre-patch originals. Re-running hosted mode is the only way back. Standalone socket-patch vendor on the same project fails without restoring, so six stays hosted there; only the scan / get takeover path loses it.
Repro (uv 0.12.23, Linux)
Patch data came from a local mock patch server, the same one used on the ledger's earlier probe branches. It serves a patched six 1.16.0 wheel that adds SOCKET_PATCHED = 1.
skipped vendor_takeover_reverted_redirect pkg:pypi/six@1.16.0 was hosted; restored its upstream registry entry (pyproject.toml, uv.lock) before vendoring (mode takeover)
failed apply_failed pypi_uv_lock_parse_failed: pyproject.toml [tool.uv.sources] is not a standard table
skipped vendor_prebuilt_downloaded …
socket-patch get pkg:pypi/six@1.16.0 --mode vendored --yes --json gives the same events and leaves the same state (exit 1, 0 hosted refs).
Expected vs actual
Expected (CLI_CONTRACT.md, "Takeover reconciliation"): a purl the takeover can't carry through fails with "nothing vendored for it, the hosted wiring left in place". The contract's Bun and npm v1 preflights state the goal directly: the refusal is checked "BEFORE the upstream restore … so the package stays hosted-patched instead of being un-hosted and then refused". The dry run should also predict the wet run's failed <code> (exit-code parity), as it does for Bun.
Actual: the restore is committed, the vendor refusal follows, and six is unpatched in both modes. The dry run says would_vendor and exits 0.
All runs were on Linux. I didn't probe macOS or Windows: the failure is in the order of the takeover planning (no file-system specifics). On 0.5.31 and 0.8.17 a warm .venv keeps the hosted bytes after uv sync --locked, but a fresh venv installs the unpatched release.
Bisect
Not bisected. The hosted → vendored takeover is v5 (main) behaviour; I tested main 9c43dfc.
Suspect code
crates/socket-patch-cli/src/commands/vendor.rs:2609: the pre-restore preflight block covers only pkg:npm/ (berry / npm lock / vlt / bun). Nothing checks pkg:pypi/ before restore_upstream at vendor.rs:2667.
crates/socket-patch-core/src/vendor/pypi_uv.rs:1439: the inline-table refusal that fires after the restore.
The same class has been reported for other managers: #853 (pnpm), #775 (gem), #688 (npm), #612 (pipenv's sibling requirements.txt). This is the uv / PyPI instance.
[agent] Found by the scheduled uv bug-hunt routine (ledger #310).
Summary
In a uv project that hosted mode has already patched,
scan --mode vendoredandget --mode vendoredfirst restore the hosted pin to its PyPI entry (vendor_takeover_reverted_redirect), and only then run the uv vendored backend. When that backend refuses a shape that hosted mode accepts, the run exits 1 with the restore already committed. The package ends up neither hosted nor vendored, so the nextuv syncinstalls the unpatched release.--dry-rundoesn't predict any of this: it reportswould_vendorwith exit 0.Two triggers I confirmed:
[tool.uv] sources = { attrs = { index = "pypi" } }. Hosted mode grows the inline table and patches six. The vendored backend refuses it withapply_failed/pypi_uv_lock_parse_failed: pyproject.toml [tool.uv.sources] is not a standard table. That refusal is intentional, but here it fires after the restore.uv pip compile --universalrequirements.txt with a marker split (Vendored requirements.txt fromuv pip compile --universalrefuses a marker-split package (six==1.16.0 ; python < 3.12+six==1.17.0 ; python >= 3.12) as "not pinned to ==1.16.0", while --dry-run previews would_vendor and hosted / vendored pylock handle the same split #928):six==1.16.0 ; python_full_version < '3.12'plussix==1.17.0 ; …>= '3.12'. Hosted mode rewrites the 1.16.0 line. The takeover puts backsix==1.16.0 ; …, then refuses withpypi_requirement_not_pinned, and the hosted line is gone.Any other uv vendored-only refusal reached after the restore probably behaves the same way. The cause is that no PyPI vendored preflight runs before
restore_upstream.Impact
A user who switches a patched project from hosted to vendored silently loses the security patch. The run does exit 1, but its events say six "was hosted; restored its upstream registry entry", and the committed files are byte-identical to the pre-patch originals. Re-running hosted mode is the only way back. Standalone
socket-patch vendoron the same project fails without restoring, so six stays hosted there; only the scan / get takeover path loses it.Repro (uv 0.12.23, Linux)
Patch data came from a local mock patch server, the same one used on the ledger's earlier probe branches. It serves a patched six 1.16.0 wheel that adds
SOCKET_PATCHED = 1.six events in the wet run:
socket-patch get pkg:pypi/six@1.16.0 --mode vendored --yes --jsongives the same events and leaves the same state (exit 1, 0 hosted refs).Expected vs actual
failed <code>(exit-code parity), as it does for Bun.would_vendorand exits 0.OS × version
[tool.uv] sources = {…},scan --mode vendoredget --mode vendoredvendoruv pip compile --universalrequirements.txt split (#928),scan --mode vendoredAll runs were on Linux. I didn't probe macOS or Windows: the failure is in the order of the takeover planning (no file-system specifics). On 0.5.31 and 0.8.17 a warm
.venvkeeps the hosted bytes afteruv sync --locked, but a fresh venv installs the unpatched release.Bisect
Not bisected. The hosted → vendored takeover is v5 (main) behaviour; I tested main
9c43dfc.Suspect code
crates/socket-patch-cli/src/commands/vendor.rs:2609: the pre-restore preflight block covers onlypkg:npm/(berry / npm lock / vlt / bun). Nothing checkspkg:pypi/beforerestore_upstreamatvendor.rs:2667.crates/socket-patch-core/src/vendor/pypi_uv.rs:1439: the inline-table refusal that fires after the restore.crates/socket-patch-core/src/vendor/pypi_requirements.rs:540: thepypi_requirement_not_pinnedrefusal (Vendored requirements.txt fromuv pip compile --universalrefuses a marker-split package (six==1.16.0 ; python < 3.12+six==1.17.0 ; python >= 3.12) as "not pinned to ==1.16.0", while --dry-run previews would_vendor and hosted / vendored pylock handle the same split #928 shape).The same class has been reported for other managers: #853 (pnpm), #775 (gem), #688 (npm), #612 (pipenv's sibling requirements.txt). This is the uv / PyPI instance.