Repository navigation
Stop attesting sbt pins a build reassignment or uncommitted wiring bypasses - #2030
Mikola Lysenko (mikolalysenko) wants to merge 4 commits into
Conversation
…passes Hosted sbt: a pin the resolution evidence does not verify keeps its ref and loses only the lockfile basis, so `vex` still attested it from the suffixed copy sbt downloaded into `.socket/sbt-hosted`. That copy proves the hosted file loaded once, not that the build resolves it. Discovery now also marks the ref unattested (new `UnattestedKind::SbtPinBypassed`, VEX note `vex_sbt_pin_bypassed`) when the build provably does not apply the pin: - a build source reassigns `dependencyOverrides` (the generated override is replaced, so the build resolves its own version) or `resolvers`, or sets `sbt.override.build.repos=true` (the generated resolver is dropped). These are read from the build sources whether or not any evidence exists; - evidence newer than every build source and the generated file records another version of the GA, or an artifact of the pinned version whose bytes are not pinned. Stale or partial evidence, project definitions that cannot be read statically, and a `build.sbt.lock` still only withhold the lockfile basis: none of them stops the pin from applying. Vendored sbt / scala-cli liveness: neither re-plans, so no pending write masked missing wiring, but `entry_wired_checked` (the VEX liveness test) judged only the wiring rows and owned files. It now also requires that the committed files route the build to the vendored copy, failing with a reason (`vendor_jvm_shape_unsupported`, "cannot establish JVM wiring"); `vendor --revert` and `scan --prune` keep the plain wiring test, and `vendor --check` is unchanged: - `sbt_pin_conflict`: the committed hosted `socket-patch.sbt` pins the same GA under another patch (the load-time verifier holds the GA to the hosted row), or no longer parses strictly; - `scala_cli_tree_uncommitted`: the index has no row for the patch's jar or pom, or the committed file is missing or does not hash to its row (without the tree's pom, Coursier reads the module from Central); - `scala_cli_repository_shadowed`: an input declares a repository or a direct `url=` for the patched group, which `vendor` refuses but nothing re-checked once vendored. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review fixes for the hosted sbt installed-copy fallback and the vendored
sbt / scala-cli liveness checks.
Hosted sbt (`vex/discover/sbt.rs`):
- Date evidence per record: the crawler now records each library
evidence file's date (`SbtEvidence::record_dates`, plus `generated`
and `newest_source`). Only records newer than the generated files and
every build source count, so a project or cross build not re-resolved
since the wiring no longer withdraws a valid pin.
- Never withdraw a pin that those records verify (`verify` over the
current view), and never run the bypass check when the lockfile basis
stands.
- A `resolvers` reassignment or `sbt.override.build.repos` alone is no
bypass: the generated override still forces the Socket-only version
and the load-time verifier checks its bytes. Only a
`dependencyOverrides` reassignment (the override replaced) or current
records do.
- Current records are judged by the hosted rewriter's own
`gate::check_existing` (base version shadowed, or other bytes from
outside the pin repository), with a hash predicate where an
unreadable file proves nothing.
- Build sources are scanned once and the reassignment reason is shared
with `evidence_blocker`.
- A file under a generated name without socket-patch's header is a root
definition sbt loads: the evidence walk lists it as a build source and
`scan_build_sources` reads it (`owned_file::is_foreign`).
Vendored liveness:
- sbt: a user's own `socket-patch.sbt` is `sbt_pin_conflict`, not
absent.
- scala-cli: liveness also applies the gate's post-vendoring
`resolved_elsewhere` check over non-stale Bloop evidence
(`coursier_gate::vendored_bypass`). The directive and elsewhere
checks return their detail, and only the gate wraps them in a
refusal. The tree check reuses `coursier_tree::{tree_dir, committed}`
and a shared `patch_rows` index parse.
- `entry_wired` takes the reference path (`attest: false`), since it
maps every `Err` to live anyway.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The resolver and forced-version decisions rely on the generated file's `onLoad` installer: its `update` verifier fails a build that does not resolve the pinned bytes, and its resolver order serves the pin repository first. A build source that reassigns `onLoad` (`:=`, `~=`, `--=`, in any scope) without keeping the previous value drops both. - `scan_build_sources` reports such edits as `BuildFindings::onload_assignment`. A `:=` whose right-hand side reads `onLoad`'s own `.value` (the generated idiom), or a `~=` that composes (`andThen` / `compose`), keeps the installer and is not reported. The finding round-trips through the resolution document. - VEX hosted sbt treats it like a `dependencyOverrides` reassignment (`pin_dropped`). It is an evidence blocker and, unless current evidence verifies the pin, it withdraws the installed-copy basis (`SbtPinBypassed`). - Vendored sbt liveness: `sbt_onload_reassigned`, over every build source the evidence walk reads. - Planners agree: the hosted rewriter refuses new pins and leaves existing ones unconfirmed (`redirect_sbt_onload_assignment`). The vendored planner refuses a new pin (`vendor_sbt_onload_assignment`). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
bugbot run Generated by Claude Code |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 68dec85. Configure here.
|
Ready for review at 68dec85.
Generated by Claude Code |
Final review brief (for the human approver)What it does
Risk: medium-low. Almost every path makes Look here
Verified
Changes I made: none. Open questions (non-blocking)
Auto-merge is not armed yet, because CI is red on the main-wide pdm failure. Once #2028 lands and this goes green, the next final-reviewer run arms it, and approving then sends it straight to the merge queue. Generated by Claude Code |
Second review round on the sbt installed-copy fallback. - `bypass` returned "no bypass" as soon as the current records verified the pin, before looking at a proven build edit (`dependencyOverrides` or `onLoad` reassignment). With only some projects re-resolved since the edit, the edit may apply to a project whose record is stale, so the installed copy was attested while that project builds unpatched. Verified evidence now overrules an edit only when every record is current and every declared project has one (`BuildState::whole`). - The `onLoad` scan's keep-check was loose: `:=` reading `.value` and dropping it, or `~= (_ => identity andThen f)`, counted as keeping the installer. `:=` now needs the `.value` read and a composition; `~=` must compose its own argument. sbt 0.13's `<<=` is recognized for `onLoad`, `dependencyOverrides` and the resolver settings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner rule: VEX must never attest a copy the build may not use. Review rule: withdraw a pin only when the build provably does not use the pinned bytes, never withdraw a pin that current evidence verifies, and never attest what isn't proven.
1. Hosted sbt: the installed-copy fallback
When the resolution evidence does not verify a hosted pin (
verify_with/evidence_blockerinvex/discover/sbt.rs), the pin keeps its ref and loses only its lockfile basis. Before this PR,vexthen attested it from the suffixed copy that sbt downloads into.socket/sbt-hosted(vex_consumed::maven_copies). That copy shows the hosted file loaded once. It does not show that the build uses those bytes.Discovery now marks the ref
Unattestedwith a new kind,UnattestedKind::SbtPinBypassed. The note andfailed[].reasonisvex_sbt_pin_bypassed. The ref stays discovered, so rollback, remove and list keep working. The mark is set only in two cases:dependencyOverridesreassignment (:=/~=/--=) in a build source. It replaces the generated override, so that project resolves its declared version, or the load-time verifier fails itsupdate. Either way the pinned bytes are not used. This is read from the build sources even when there is no evidence.onLoadreassignment that drops the generated installer (Global / onLoad :=,onLoad in Global :=, any scope,:=/~=/--=) in a build source. The installer holds theupdateverifier and the resolver order (pin repository first) that the resolver and forced-version decisions below rely on. A:=that readsonLoad's own.value(the generated idiom,(Global / onLoad).value andThen …), or a~=that composes (andThen/compose), keeps the installer and is fine. This is read from the build sources even when there is no evidence.gate::check_existing):A record is "current" when it is dated after the generated files and after every build source. The evidence walk now records a date for each record file (
SbtEvidence::record_dates,generated,newest_source). Current records that verify the pin (verifyover the current view) always keep it. The bypass check never runs when the lockfile basis stands.Decision for each blocker:
dependencyOverridesreassigned:= dependencyOverrides.valueis kept whenever current evidence verifies the pin.onLoadreassigned without keeping its previous valueupdateverifier, no pin-repository-first order. Nothing stops another copy or a forced version from building silently. It is also an evidence blocker.onLoadcomposed (:= (Global / onLoad).value andThen …,~= (_ andThen …))resolvers/externalResolvers/fullResolversreassignedonLoadis also droppedsbt.override.build.repos=trueonLoadis also droppedredirect_sbt_override_shadowed.redirect_sbt_resolved_elsewhere.onLoadis droppedupdate.build.sbt.lock, an evicted or unreadable artifactA file named
socket-patch.sbtorsocket-patch-vendor.sbtwithout socket-patch's header is a root definition that sbt loads. The evidence walk now lists it as a build source, andscan_build_sourcesreads it (owned_file::is_foreign). Socket-patch's own generated file is still never a source.2. sbt / scala-cli vendored liveness: committed files only
What I audited:
wired_checked(there is no sbtwired): a strict parse of the committedsocket-patch-vendor.sbtplus the pin row. It never re-plans, so the Stop vendored Gradle liveness attesting a version the lockfile overrides #2004 flaw (pending writes masking missing committed wiring) cannot occur.check_entry(vendor --check) re-plans and fails on any pending write, which is the safe direction.wired_checked: the index row plus byte-exactsocket-patch.scalaand guard. It never re-plans either.entry_wired_checked(VEX liveness). Neither backend checked whether the committed files still route the build to the vendored copy. These now fail with a reason; the VEX plan omits the entry asvendor_jvm_shape_unsupported("cannot establish JVM wiring: reason: …"):sbt_pin_conflict: the committed hostedsocket-patch.sbtdoes one of three things:.sbt.sbt_onload_reassigned: a build source (every one the evidence walk reads, or the planner's fixed set if the walk hits a cap) reassignsonLoadwithout keeping the previous value. That dropssocket-patch-vendor.sbt's load-time verifier and resolver order.scala_cli_tree_uncommitted: the tree's jar or pom is missing, has no index row of the patch, or does not hash to its row. Without the tree's pom, Coursier reads the module from the next repository (Central), unpatched jar included.scala_cli_repository_shadowed: the gate's conditions (coursier_gate::vendored_bypass). An input declares a repository, or a directurl=for the patched group, or non-stale Bloop evidence from a build after vendoring resolved the GAV outside the tree (resolved_elsewhere).vendor --revertandscan --prunekeep the plain wiring test.check_entryuses theattest: falsepath, and so doesentry_wired.SbtLineinstaller spelling: routing is the same on every line..gitignore/.gitattributes: never read by the build..sha1: fails closed.planwas not touched.Review findings
a_record_older_than_the_pin_never_withdraws_it(the 1.9.9 test-compile fixture, withbresolved before the pin and the other projects after) fails on the previous code.bypass_needs_proof_from_current_recordsand the e2ehosted_vex_never_withdraws_a_pin_current_evidence_verifies(.sbtoptsoverride.build.repos, and a self-referencing:=). Both fail with the guard removed.socket-patch.sbtmade vendored liveness attest. It is nowsbt_pin_conflict(liveness_refuses_another_committed_pin_of_the_ga). The evidence walk andscan_build_sourcesnow read a foreign generated-name file (a_foreign_generated_name_is_scanned_as_a_build_source, plus the crawler staleness test).resolved_elsewhereover non-stale evidence (liveness_needs_the_committed_tree_and_no_shadowing_repository, with on-disk Bloop evidence: current and outside the tree fails; stale passes; inside the tree passes).sbt.override.build.reposare no longer a bypass (see the table).dependencyOverridesreassignment is kept: it removes the override itself, which the review allows.entry_wiredmaps everyErrtotrue, so the attest path could not change its answer. Fixed anyway as wasted work: it now calls theattest: falsepath.build_stateand shared withevidence_blocker. ThedependencyOverridesreason string lives in one place (overrides_reassigned).bypassnow callsgate::check_existingwith a hash predicate. I tried wideningcheck_existingto flag every non-pin version. That changed the hosted rewriter's and the vendored gate's verdicts (a test expected no advisory on stale3.12.0evidence), so I reverted it. VEX and the rewriter now judge evidence identically: base version shadowed, or bytes from elsewhere.coursier_tree::{tree_dir, committed}and a newcoursier_tree::patch_rows, whichindexednow uses too.directive_detail/elsewhere_detailreturnOption<String>. Only the gate wraps them inrefuse(shadowed), so there are no deadGateStoparms.Follow-up:
onLoadreassignments. The decisions for findings 5 and 8 rely on the generatedonLoadinstaller. That installer was not detected anywhere before, not even in the planners. Now:scan_build_sourcesreports such edits asBuildFindings::onload_assignment, which round-trips through the resolution document.dependencyOverridesreassignment: an evidence blocker, plusSbtPinBypassedunless current evidence verifies the pin.sbt_onload_reassigned.build_refusalrefuses new pins and leaves existing pins unconfirmed (redirect_sbt_onload_assignment).vendor_sbt_onload_assignment), the same way it handles the other reassignments.Tests:
scan_finds_onload_reassignments_that_drop_the_installeran_onload_reassignment_withdraws_like_an_overrides_oneonLoadcase ofevidence_blocker_mirrors_the_new_pin_gateliveness_refuses_an_onload_reassignmentonLoadcase ofrefusals_come_before_any_writean_onload_reassignment_refuses_the_pinhosted_vex_installed_copy_is_withdrawn_by_an_overrides_reassignment(resolvers :=withonLoaddropped is withdrawn; a composedonLoadis kept)With the scan stubbed out, all six core tests fail.
Tests
Each new test fails with its fix stubbed out (I checked this per finding) and passes here. All of these are green:
cargo test -p socket-patch-core --lib -- vex::discover:: vendor::jvm:: formats::sbt crawlers::sbt_evidence patch::redirect::sbt hosted::sbt_readsredirect_sbt_goldene2e_vex_lockfile -- sbt--lib vex_sourcese2e_sbt_hostede2e_sbt_vendore2e_scala_cli_vendore2e_sbt_vendor_builde2e_sbt_buildcontract_gradle_codes-D warnings,fmt --checkNot done
onLoadscan is a static heuristic. A:=that reads the previous.valuebut then discards it, or a~=that mentionsandThenwithout keeping its argument, counts as a composition.Relation to #2008: this touches
vex/discover/sbt.rs(extract,hosted_ref,evidence_blocker's signature) andUnattestedKind. Expect textual conflicts there.Review findings, round 2 (
/code-review highon67b885d+68dec85, cloud routine)Fixed in
03408ab.FALSE ATTESTATION: partial fresh evidence overruled a proven edit. Confirmed and fixed.
bypassreturned "no bypass" as soon as the current records verified the pin, without first checking for a provendependencyOverridesoronLoadreassignment. Example: the root aggregatesaandb, the user addsdependencyOverrides := Seq()toa, then runs onlysbt b/update.b's fresh record verifies the pin,a's stale record is ignored, and the installed copy was attested even thoughabuilds unpatched. Verified evidence now overrules an edit only when it covers the whole build: every record is current and every declared project has one (BuildState::whole). Test:bypass_needs_proof_from_current_records(new "partial" case).The
onLoadkeep-check was loose, and<<=was missed: confirmed and fixed.:=now needs the previous.valueboth read and composed.{ val _ = (Global / onLoad).value; identity }is flagged.~=must compose its own argument.(_ => identity andThen f)is flagged;(_ andThen f)and(prev => prev.andThen(f))are kept.<<=is now recognized foronLoad,dependencyOverridesand the resolver settings.CLI_CONTRACT.mdis updated to match. Test:scan_finds_onload_reassignments_that_drop_the_installer(extended).Declined:
onLoad(ThisBuild /or project-scoped edits are flagged although sbt reads onlyGlobal). This only over-refuses, which is the fail-safe direction. Narrowing it needs sbt-version-specific scope rules. Left for a follow-up.update.Tests (cloud run):
cargo test -p socket-patch-core --lib -- formats::sbt vex::discover:: crawlers::sbt crawlers::scala vendor::jvm patch::redirect::sbtgives 792 passed, golden included. CLI--test e2e_vex_lockfile sbtand--lib vex_sourcespass. clippy and fmt are clean.Landing note: this PR conflicts with #2008 in
vex/discover/sbt.rsandUnattestedKind. Once #2008 lands, merge main in here and keep both PRs' behavior.Note
Medium Risk
Alters VEX attestation rules and JVM vendor liveness; wrong heuristics could under- or over-attest, though coverage is broad and behavior aligns with hosted rewriter gates.
Overview
Hosted sbt VEX. When resolution evidence does not verify a hosted pin,
vexno longer always attests from the suffixed copy under.socket/sbt-hosted. Discovery now marks the ref unattested withvex_sbt_pin_bypassedwhen the build provably bypasses the pin: adependencyOverridesreassignment, anonLoadreassignment that drops the generated installer (unless it keeps the previous.valueor composes withandThen/compose), or current resolution records dated after the wiring and sources that match the hosted rewriter’s shadowed-override or resolved-elsewhere cases. Per-record evidence timestamps (record_dates,generated,newest_source) decide what is “current”; verified current records always keep attestation.Static analysis plumbing.
scan_build_sourcesnow collectsonload_assignment, serialized through sbt resolution docs; hosted redirect refuses withredirect_sbt_onload_assignment, vendored planner withvendor_sbt_onload_assignment, and VEX evidence blockers reuse the same findings. Files namedsocket-patch.sbt/socket-patch-vendor.sbtwithout the socket-patch header are treated as foreign build definitions.Vendored sbt / scala-cli liveness.
entry_wired_checked(attestation path) adds attestability checks: conflicting or non-strict hosted pins, foreign hosted files, andonLoadbypass →vendor_jvm_shape_unsupported(sbt_pin_conflict,sbt_onload_reassigned); scala-cli checks committed tree rows vs index hashes and repository shadowing viacoursier_gate::vendored_bypass. Non-attest callers use a lighter wiring check.coursier_tree::patch_rowssupports shared indexing logic.Docs & CLI.
CLI_CONTRACT.md,docs/ecosystems.md, andvex_sourcesdocument the new unattested kind and codes; extensive new unit and e2e tests cover bypass scenarios, onLoad detection, and scala-cli shadowing.Reviewed by Cursor Bugbot for commit 68dec85. Configure here.
Generated by Claude Code