Skip to content

Support hosted Pipenv patches and safe vendoring - #242

Merged
Mikola Lysenko (mikolalysenko) merged 28 commits into
mainfrom
codex/pipenv-patch-compatibility
Sep 18, 2026
Merged

Mikola Lysenko (mikolalysenko) merged 28 commits into
mainfrom
codex/pipenv-patch-compatibility

Conversation

@mikolalysenko

@mikolalysenko Mikola Lysenko (mikolalysenko) commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

Pipenv projects can now use hosted Socket patches, and vendored patches retain custom categories and extras. The rewriter preserves the Pipfile and its content hash, updates every matching lock category atomically, refuses source/version conflicts, and records entry-level rollback with drift checks. Hosted grant rotation remains reversible.

Native installers require different reference forms: hosted mode uses path for Pipenv 7–11 and file from 2018 onward. Earlier lock specifications are refused. Vendoring explicitly refuses detected pre-2018 installers; local wheels with extras use path to avoid Pipenv 2022's file-URL parsing bug. Version detection runs once per vendoring or repair command.

Review round (ULTRACODE, 2026-09-17/18)

A 10-lens adversarial review, a 3-lens verification of every finding against both the PR head and the fix branch, and a live matrix over the last stable release of all 18 published Pipenv majors (0.2.8 … 2026.8.0) × {hosted, vendored, agent, out-of-tree agent} × {direct, dev, category, marker, marker-excluded, extras, transitive, crlf} × {in-dir, --cwd, nested --cwd, symlinked cwd}, on macOS (native) and Linux (release binary in a container; the pre-2018 releases always run in python:3.6.15-slim). The in-process CLI tests run on Linux, macOS and Windows in CI, and .github/workflows/pipenv-compatibility.yml runs the live matrix on Linux and macOS. The branch is merged with main (Poetry #241, Hatch #244).

What the review found and fixed

  • Lock-only checkouts discovered nothing in every mode. The lock inventory never read Pipfile.lock, so a fresh clone (the CI shape hosted mode exists for) redirected nothing and vendored nothing, exit 0 — every prior install proof pre-installed the package first. Pipfile.lock is inventoried now (every category's == pins); vendored mode fetches the pristine wheel by one of the lock's recorded digests (LockIntegrity::Sha256AnyOf, Pipenv records every release file's hash without filenames) through PyPI's JSON API — only when the lock resolves from the public index; requirements.txt is read alongside it; Socket's own references stay discoverable, so a CI re-run of scan --mode hosted --vex on an already-redirected checkout exits 0 and a vendored re-scan reports already_vendored.
  • Agent mode never saw Pipenv's default out-of-tree virtualenv and fell through to the global interpreter (patching the wrong Python or nothing, exit 0; a bare rollback pruned the manifest while the venv stayed patched). The crawler now reproduces Pipenv's placement (WORKON_HOMEUSERPROFILE on Windows — the .venv file pointer, PIPENV_CUSTOM_VENV_NAME, PIPENV_PIPFILE, the interpreter suffix including the nested 2018–2021 form, the case-insensitive fallback), unchanged from Pipenv 7 through 2026.
  • Warm virtualenvs are never reinstalled. pipenv install, install --deploy and sync exit 0 and keep the upstream bytes on every major, hosted and vendored. The Python stale-install guard (redirect_pypi_stale_install, from Support hosted and legacy Poetry patches #241) now judges only the project's own venvs and names the Pipenv-specific verified remedy — pipenv run pip uninstall -y <pkg> && pipenv sync or pipenv --rm && pipenv sync; pipenv uninstall is not it (rewrites the Pipfile, re-locks the patch away) — and the vendored twin pypi_pipenv_stale_install does the same. Stale purls are excluded from the same-run --vex.
  • Bugbot HIGH: only pin/source conflicts of a LIVE lock (a Pipfile beside it) veto the sibling Python rewriters (redirect_pipenv_refused); a stale lock without the package, an abandoned lock, an old pipfile-spec, an unparseable or BOM-prefixed lock, or a digest-less patch is redirect_pipenv_skipped and no longer blocks requirements.txt / uv.lock.
  • Relock trap: pipenv lock regenerates the entry on every major (2026 byte-identical to the original, 2022 with another hash list, 2023+ keeping our reference on a marker-excluded entry while restoring upstream hashes + version, pipenv uninstall dropping it); hosted rollback refused forever and vendored rollback kept the orphaned wheel. Both rollbacks now compare entries as parsed JSON, tolerate a whole-file CRLF/LF conversion, retire a registry-shaped or removed entry, and restore the original when Pipenv only re-serialized around our reference.
  • Installer probe: runs only when a patch targets the lock, warns only when the lock was rewritten, runs in a neutral directory, resolves pipenv on absolute PATH entries only (a relative entry would have executed a repo-planted binary), requires the executable bit, finds .bat/.cmd shims on Windows, parses only the version token, and honours SOCKET_PIPENV_MAJOR.
  • Same-run --vex attests lock-only pypi redirects (qualifier-stripped purl match; identical to Support hosted and legacy Poetry patches #241's fix).
  • Socket-owned hybrid entries (--keep-outdated) are re-planned instead of refused; ownership follows the grant's artifact-URL origin (custom patch servers); ledger originals must be JSON objects before rollback splices them; vendored mode preserves CRLF locks and warns on an unknown installer; the vendored guards name the remedy when they meet a hosted Socket reference.

Validation

  • scripts/backtest-pipenv.py (macOS, all 18 majors × 8 shapes × 4 modes): 470/470 pass on the merged head e521093 — 103 expected refusals (pipfile-spec < 6 in hosted and vendored mode on Pipenv 0–6, vendored mode on Pipenv 7–11) and 36 harness skips (Pipenv 0–6 mishandle inline-table markers/extras entries in agent mode, 0.x has no out-of-tree venv and cannot stamp the transitive fixture, Pipenv 7 cannot create its out-of-tree venv inside the Python 3.6 image). Twelve cells were re-run after harness fixes: four Docker-side oracle reads that raced the bind mount under load, and eight marker-excluded cells on 2023+ whose check demanded the relocked hybrid be kept although the CLI correctly rolled it back to the pristine lock byte for byte. The CLI output was right in all twelve.
  • Linux leg (release binary in a container, Pipenv 2018.11.26 … 2026.8.0, four modes): 32/32 pass.
  • Invocation sweep (--cwd, nested --cwd, symlinked cwd × four modes × 2026.8.0 / 2022.12.19 / 2018.11.26 / 11.10.4): 48/48 pass (3 expected refusals: vendored mode on Pipenv 11).
  • cargo test -p socket-patch-core --lib (3574), cargo test -p socket-patch-cli (all suites), cargo clippy --workspace --all-features -- -D warnings: green on the merged head (one unnecessary_map_or lint fixed after the merge).
  • New tests: crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs (lock-only redirect + same-run vex + idempotent rescan + byte-exact rollback, path references via SOCKET_PIPENV_MAJOR=11, the Bugbot scenario, a warm venv kept out of the attestation) plus unit coverage for the crawler placement algorithm, the inventory digest set and Socket-reference discovery, the veto scope, BOM, relock retirement (both modes), CRLF rollback, ledger recovery, and the version probe.

Measured boundaries and the full results table: docs/testing/pipenv-compatibility.md.

The Pipenv annotation and SBOM implementation is in depscan #26366; its harness and fixtures are in depscan #26367.

🤖 Generated with Claude Code

Preserve Pipenv categories and source identity when applying patches.
Handle legacy hosted references and vendored extras, reject unsupported
installers, and restore lock entries safely during rollback.

Assisted-by: Codex:gpt-6-astra
Explain the version requirement when Pipenv is unavailable, and point
local-wheel verification guidance at the existing VEX command.

Assisted-by: Codex:gpt-6-astra

@cursor cursor Bot 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.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Stale Pipfile.lock blocks sibling redirects
    • Modified pipenv::rewrite to only add UUIDs to refused_pipenv_uuids for actual conflicts (version mismatches, existing sources, non-registry packages), not for missing entries or malformed locks.

Create PR

Or push these changes by commenting:

@cursor push 2ea444ba23
Preview (2ea444ba23)
diff --git a/crates/socket-patch-core/src/crawlers/go_crawler.rs b/crates/socket-patch-core/src/crawlers/go_crawler.rs
--- a/crates/socket-patch-core/src/crawlers/go_crawler.rs
+++ b/crates/socket-patch-core/src/crawlers/go_crawler.rs
@@ -1196,8 +1196,7 @@
         // FIRST GOPATH entry is skipped in favor of the next non-empty one
         // (GOPATH is an OS-separator-delimited list; Go uses the first
         // usable entry for the module cache).
-        let gopath_list =
-            std::env::join_paths(["".as_ref(), gopath_a.path().as_os_str()]).unwrap();
+        let gopath_list = std::env::join_paths(["".as_ref(), gopath_a.path().as_os_str()]).unwrap();
         let _gomodcache = EnvGuard::set("GOMODCACHE", "");
         let _gopath = EnvGuard::set("GOPATH", gopath_list.to_str().unwrap());
         let _home = EnvGuard::set("HOME", "/nonexistent-home-unused");

diff --git a/crates/socket-patch-core/src/crawlers/nuget_crawler.rs b/crates/socket-patch-core/src/crawlers/nuget_crawler.rs
--- a/crates/socket-patch-core/src/crawlers/nuget_crawler.rs
+++ b/crates/socket-patch-core/src/crawlers/nuget_crawler.rs
@@ -748,7 +748,9 @@
     async fn test_scan_package_dir_dedups_same_package_across_two_scans() {
         let dir = tempfile::tempdir().unwrap();
         let pkg_dir = dir.path().join("newtonsoft.json").join("13.0.3");
-        tokio::fs::create_dir_all(pkg_dir.join("lib")).await.unwrap();
+        tokio::fs::create_dir_all(pkg_dir.join("lib"))
+            .await
+            .unwrap();
 
         let crawler = NuGetCrawler::new();
         let mut seen = HashSet::new();

diff --git a/crates/socket-patch-core/src/package_json/find.rs b/crates/socket-patch-core/src/package_json/find.rs
--- a/crates/socket-patch-core/src/package_json/find.rs
+++ b/crates/socket-patch-core/src/package_json/find.rs
@@ -846,10 +846,7 @@
         // below); if the team decides such values should degrade to
         // WorkspaceType::None (npm semantics), update detect_workspaces and
         // flip these assertions together.
-        for spelling in [
-            r#"{"workspaces": "packages/*"}"#,
-            r#"{"workspaces": null}"#,
-        ] {
+        for spelling in [r#"{"workspaces": "packages/*"}"#, r#"{"workspaces": null}"#] {
             let dir = tempfile::tempdir().unwrap();
             let pkg = dir.path().join("package.json");
             fs::write(&pkg, spelling).await.unwrap();

diff --git a/crates/socket-patch-core/src/package_json/update.rs b/crates/socket-patch-core/src/package_json/update.rs
--- a/crates/socket-patch-core/src/package_json/update.rs
+++ b/crates/socket-patch-core/src/package_json/update.rs
@@ -787,7 +787,9 @@
         );
         assert!(result.new_script.is_none());
         assert!(
-            result.old_dependencies_script.contains("socket-patch apply"),
+            result
+                .old_dependencies_script
+                .contains("socket-patch apply"),
             "old_dependencies_script must reflect the configured script, got {:?}",
             result.old_dependencies_script
         );

diff --git a/crates/socket-patch-core/src/patch/apply.rs b/crates/socket-patch-core/src/patch/apply.rs
--- a/crates/socket-patch-core/src/patch/apply.rs
+++ b/crates/socket-patch-core/src/patch/apply.rs
@@ -2763,7 +2763,9 @@
         assert!(result.success, "expected success: {:?}", result.error);
         assert_eq!(result.files_patched, vec!["new.js".to_string()]);
         assert_eq!(result.applied_via.get("new.js"), Some(&AppliedVia::Blob));
-        let written = tokio::fs::read(pkg_dir.path().join("new.js")).await.unwrap();
+        let written = tokio::fs::read(pkg_dir.path().join("new.js"))
+            .await
+            .unwrap();
         assert_eq!(written, fresh, "divergent existing content is overwritten");
     }
 
@@ -2811,7 +2813,9 @@
 
         assert!(result.success, "strict still overwrites at a new-file path");
         assert_eq!(result.files_patched, vec!["new.js".to_string()]);
-        let written = tokio::fs::read(pkg_dir.path().join("new.js")).await.unwrap();
+        let written = tokio::fs::read(pkg_dir.path().join("new.js"))
+            .await
+            .unwrap();
         assert_eq!(written, fresh);
     }
 
@@ -2961,7 +2965,9 @@
         let evil = b"pwned";
         let evil_hash = compute_git_sha256_from_bytes(evil);
 
-        tokio::fs::write(pkg.join("index.js"), original).await.unwrap();
+        tokio::fs::write(pkg.join("index.js"), original)
+            .await
+            .unwrap();
         tokio::fs::write(blobs_dir.path().join(&after_hash), patched)
             .await
             .unwrap();
@@ -3005,7 +3011,10 @@
         assert!(result.files_patched.is_empty());
         // The safe file was never written — the whole apply aborted
         // before the write phase.
-        assert_eq!(tokio::fs::read(pkg.join("index.js")).await.unwrap(), original);
+        assert_eq!(
+            tokio::fs::read(pkg.join("index.js")).await.unwrap(),
+            original
+        );
         assert!(!root.path().join("escape.js").exists());
     }
 
@@ -3028,9 +3037,15 @@
             after_hash: compute_git_sha256_from_bytes(b"x"),
         };
 
-        let applied =
-            try_apply_from_diff(Some(&entries), "new.js", dir.path(), "new.js", &info, Some("anything"))
-                .await;
+        let applied = try_apply_from_diff(
+            Some(&entries),
+            "new.js",
+            dir.path(),
+            "new.js",
+            &info,
+            Some("anything"),
+        )
+        .await;
         assert!(!applied, "new-file entries must never apply via diff");
         assert!(!dir.path().join("new.js").exists(), "nothing written");
     }

diff --git a/crates/socket-patch-core/src/patch/apply_lock.rs b/crates/socket-patch-core/src/patch/apply_lock.rs
--- a/crates/socket-patch-core/src/patch/apply_lock.rs
+++ b/crates/socket-patch-core/src/patch/apply_lock.rs
@@ -496,9 +496,9 @@
                     fs2::lock_contended_error().raw_os_error()
                 );
             }
-            LockError::Held => panic!(
-                "a genuine flock fault must not be mislabelled as contention"
-            ),
+            LockError::Held => {
+                panic!("a genuine flock fault must not be mislabelled as contention")
+            }
         }
         // The fault arm returns without ever entering the retry/backoff
         // path: nowhere near the 5 s budget (the old funnel-everything-
@@ -530,9 +530,9 @@
                     fs2::lock_contended_error().raw_os_error()
                 );
             }
-            LockError::Held => panic!(
-                "try-once mode must not mislabel a genuine flock fault as Held"
-            ),
+            LockError::Held => {
+                panic!("try-once mode must not mislabel a genuine flock fault as Held")
+            }
         }
     }
 

diff --git a/crates/socket-patch-core/src/patch/redirect/golang_local.rs b/crates/socket-patch-core/src/patch/redirect/golang_local.rs
--- a/crates/socket-patch-core/src/patch/redirect/golang_local.rs
+++ b/crates/socket-patch-core/src/patch/redirect/golang_local.rs
@@ -2177,7 +2177,10 @@
 
         // Parity: the real run removes exactly what the dry run reported.
         let removed_wet = reconcile_go_redirects(root, &HashSet::new(), false).await;
-        assert_eq!(removed_wet, removed, "dry-run report must match the real run");
+        assert_eq!(
+            removed_wet, removed,
+            "dry-run report must match the real run"
+        );
         assert!(!copy_dir.exists(), "real run prunes the copy");
         assert!(read_replace_entries(root).await.is_empty());
     }

diff --git a/crates/socket-patch-core/src/patch/redirect/pipenv.rs b/crates/socket-patch-core/src/patch/redirect/pipenv.rs
--- a/crates/socket-patch-core/src/patch/redirect/pipenv.rs
+++ b/crates/socket-patch-core/src/patch/redirect/pipenv.rs
@@ -178,7 +178,12 @@
                 result.edits.extend(edits);
             }
             Err(detail) => {
-                result.refused_pipenv_uuids.insert(dep.patch_uuid.clone());
+                let is_conflict = detail.contains("already exists")
+                    || detail.contains("does not match")
+                    || detail.contains("is not a registry package");
+                if is_conflict {
+                    result.refused_pipenv_uuids.insert(dep.patch_uuid.clone());
+                }
                 result.warnings.push(RewriteWarning {
                     code: "redirect_pipenv_refused".into(),
                     detail,

diff --git a/crates/socket-patch-core/src/patch/redirect/replay.rs b/crates/socket-patch-core/src/patch/redirect/replay.rs
--- a/crates/socket-patch-core/src/patch/redirect/replay.rs
+++ b/crates/socket-patch-core/src/patch/redirect/replay.rs
@@ -656,9 +656,7 @@
                 }
             }
         }
-        outcome
-            .reverted_files
-            .extend(staged.keys().cloned());
+        outcome.reverted_files.extend(staged.keys().cloned());
         pending_warnings.extend(group_warnings);
         drop_indices.extend(group_drops);
     }
@@ -725,9 +723,9 @@
         let mut state = RedirectState::new();
         state.edits = edits;
         for p in record_purls {
-            state
-                .records
-                .insert((*p).to_string(), crate::manifest::schema::PatchRecord {
+            state.records.insert(
+                (*p).to_string(),
+                crate::manifest::schema::PatchRecord {
                     uuid: "u".into(),
                     exported_at: "now".into(),
                     files: Default::default(),
@@ -776,7 +774,10 @@
         );
         let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await;
         assert!(out.fully_reverted(), "{:?}", out.refusals);
-        assert_eq!(read(dir.path(), "requirements.txt").await, "left-pad==1.3.0\n");
+        assert_eq!(
+            read(dir.path(), "requirements.txt").await,
+            "left-pad==1.3.0\n"
+        );
         assert!(state.edits.is_empty());
         assert!(state.records.is_empty());
         assert_eq!(out.dropped_records, vec!["pkg:pypi/left-pad@1.3.0"]);
@@ -804,7 +805,10 @@
         );
         let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await;
         assert!(out.fully_reverted(), "{:?}", out.refusals);
-        assert_eq!(read(dir.path(), "pom.xml").await, "<version>2.17.1</version>\n");
+        assert_eq!(
+            read(dir.path(), "pom.xml").await,
+            "<version>2.17.1</version>\n"
+        );
     }
 
     #[tokio::test]
@@ -1248,7 +1252,9 @@
         // The npm group refused; the bun group replayed.
         assert_eq!(out.refusals.len(), 1);
         assert_eq!(out.refusals[0].group, "npm");
-        assert!(read(dir.path(), "bun.lock").await.contains("upstream.example"));
+        assert!(read(dir.path(), "bun.lock")
+            .await
+            .contains("upstream.example"));
         // npm-family records are held while ANY npm-family group refused.
         assert!(state.records.contains_key("pkg:npm/a@1"));
         assert_eq!(state.edits.len(), 1, "only the refused npm edit remains");
@@ -1428,7 +1434,9 @@
         assert_eq!(out.dropped_records, vec!["pkg:pypi/left-pad@1.3.0"]);
         assert!(out.reverted_files.contains("requirements.txt"));
         // Disk and ledger untouched.
-        assert!(read(dir.path(), "requirements.txt").await.contains("patch.example"));
+        assert!(read(dir.path(), "requirements.txt")
+            .await
+            .contains("patch.example"));
         assert_eq!(state.edits.len(), 1);
         assert_eq!(state.records.len(), 1);
     }
@@ -1645,7 +1653,9 @@
         );
         let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await;
         assert_eq!(out.refusals.len(), 1, "{out:?}");
-        assert!(out.refusals[0].reason.contains("missing its recorded lines"));
+        assert!(out.refusals[0]
+            .reason
+            .contains("missing its recorded lines"));
         assert_eq!(state.edits.len(), 1);
         assert_eq!(read(dir.path(), "go.sum").await, "x v1 h1:a\n");
     }
@@ -1690,10 +1700,16 @@
         ];
         for (path, kind, action, original, new) in cases {
             let dir = TempDir::new().unwrap();
-            tokio::fs::create_dir_all(dir.path().join(path)).await.unwrap();
+            tokio::fs::create_dir_all(dir.path().join(path))
+                .await
+                .unwrap();
             let mut state = state_with(vec![edit(path, kind, action, original, new)], &[]);
             let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await;
-            assert_eq!(out.refusals.len(), 1, "{kind}/{action} must refuse: {out:?}");
+            assert_eq!(
+                out.refusals.len(),
+                1,
+                "{kind}/{action} must refuse: {out:?}"
+            );
             assert!(
                 out.refusals[0].reason.starts_with(&format!("read {path}:")),
                 "{kind}/{action}: {}",
@@ -1958,8 +1974,11 @@
         );
         let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await;
         assert_eq!(out.refusals.len(), 1, "{out:?}");
-        assert_eq!(out.refusals[0].reason, "composer.lock is not a regular file");
         assert_eq!(
+            out.refusals[0].reason,
+            "composer.lock is not a regular file"
+        );
+        assert_eq!(
             read(dir.path(), "real.lock").await,
             "https://patch.example/a\n",
             "the symlink target must stay byte-identical"

diff --git a/crates/socket-patch-core/src/patch/redirect/takeover.rs b/crates/socket-patch-core/src/patch/redirect/takeover.rs
--- a/crates/socket-patch-core/src/patch/redirect/takeover.rs
+++ b/crates/socket-patch-core/src/patch/redirect/takeover.rs
@@ -1082,7 +1082,10 @@
         // previews — the whole-ledger replay running after per-purl
         // reverts — must see the post-claim state); the caller owns the
         // clone and never persists it on a dry run.
-        assert!(state.records.len() < records_before, "record claimed in memory");
+        assert!(
+            state.records.len() < records_before,
+            "record claimed in memory"
+        );
         assert!(state.edits.len() < edits_before, "edits claimed in memory");
 
         // The preview names exactly the files a wet run then reverts —
@@ -1901,7 +1904,9 @@
             .and_then(Value::as_object_mut)
             .unwrap();
         entry.remove("name").expect("fixture name field present");
-        entry.remove("version").expect("fixture version field present");
+        entry
+            .remove("version")
+            .expect("fixture version field present");
         tokio::fs::write(
             root.join("package-lock.json"),
             serde_json::to_string_pretty(&on_disk).unwrap(),
@@ -2038,9 +2043,7 @@
         .await;
         let root = tmp.path();
         assert_eq!(state.edits.len(), 2, "{:?}", state.edits);
-        let scoped_url = npm_dep_for("@scope/left-pad", "1.3.0")
-            .artifact_url
-            .clone();
+        let scoped_url = npm_dep_for("@scope/left-pad", "1.3.0").artifact_url.clone();
         // Hand edit / merge artifact: strip the alias entry's name+version.
         let mut on_disk: Value = serde_json::from_str(
             &tokio::fs::read_to_string(root.join("package-lock.json"))
@@ -2054,7 +2057,9 @@
             .and_then(Value::as_object_mut)
             .unwrap();
         entry.remove("name").expect("fixture name field present");
-        entry.remove("version").expect("fixture version field present");
+        entry
+            .remove("version")
+            .expect("fixture version field present");
         tokio::fs::write(
             root.join("package-lock.json"),
             serde_json::to_string_pretty(&on_disk).unwrap(),
@@ -2361,7 +2366,9 @@
         let cfg_before = tokio::fs::read_to_string(root.join(".cargo/config.toml"))
             .await
             .unwrap();
-        tokio::fs::remove_file(root.join("Cargo.lock")).await.unwrap();
+        tokio::fs::remove_file(root.join("Cargo.lock"))
+            .await
+            .unwrap();
         let records_before = state.records.len();
         let edits_before = state.edits.len();
 
@@ -2515,9 +2522,12 @@
         let wired_toml = tokio::fs::read_to_string(root.join("Cargo.toml"))
             .await
             .unwrap();
-        tokio::fs::write(root.join("Cargo.toml"), format!("{wired_toml}{pinned_line}"))
-            .await
-            .unwrap();
+        tokio::fs::write(
+            root.join("Cargo.toml"),
+            format!("{wired_toml}{pinned_line}"),
+        )
+        .await
+        .unwrap();
 
         let out = revert_cargo_redirect_purl(root, &mut state, PURL, false)
             .await
@@ -2627,7 +2637,9 @@
     async fn npm_missing_text_lock_refuses_and_keeps_the_ledger() {
         let (tmp, mut state) = npm_redirected_fixture("yarn.lock", &classic_pristine()).await;
         let root = tmp.path();
-        tokio::fs::remove_file(root.join("yarn.lock")).await.unwrap();
+        tokio::fs::remove_file(root.join("yarn.lock"))
+            .await
+            .unwrap();
         let records_before = state.records.len();
         let edits_before = state.edits.len();
 
@@ -2821,7 +2833,10 @@
         let err = revert_npm_redirect_purl(root, &mut state, NPM_PURL, false)
             .await
             .expect_err("vanished v2 tree must refuse");
-        assert!(err.contains("no longer holds a `dependencies` tree"), "{err}");
+        assert!(
+            err.contains("no longer holds a `dependencies` tree"),
+            "{err}"
+        );
         let after = tokio::fs::read_to_string(root.join("package-lock.json"))
             .await
             .unwrap();

diff --git a/crates/socket-patch-core/src/patch/rollback.rs b/crates/socket-patch-core/src/patch/rollback.rs
--- a/crates/socket-patch-core/src/patch/rollback.rs
+++ b/crates/socket-patch-core/src/patch/rollback.rs
@@ -2087,12 +2087,9 @@
             .await
             .unwrap();
         // Blob whose CONTENT does not match its name — verifies Ready.
-        tokio::fs::write(
-            blobs_dir.path().join(&before_hash),
-            b"corrupted blob bytes",
-        )
-        .await
-        .unwrap();
+        tokio::fs::write(blobs_dir.path().join(&before_hash), b"corrupted blob bytes")
+            .await
+            .unwrap();
 
         let mut files = HashMap::new();
         files.insert(

diff --git a/crates/socket-patch-core/src/setup/gem/mod.rs b/crates/socket-patch-core/src/setup/gem/mod.rs
--- a/crates/socket-patch-core/src/setup/gem/mod.rs
+++ b/crates/socket-patch-core/src/setup/gem/mod.rs
@@ -2063,7 +2063,8 @@
         let root = dir.path();
         let plugin_root = root.join(".bundle/plugin");
         let index = plugin_root.join("index");
-        let body = "---\ncommands:\nhooks:\n  after-install:\n  - \"other\"\n  - \"socket-patch\"\n\
+        let body =
+            "---\ncommands:\nhooks:\n  after-install:\n  - \"other\"\n  - \"socket-patch\"\n\
              load_paths:\n  other:\n  - \"/x/other/.\"\n  socket-patch:\n  - \"/proj/p/.\"\n\
              plugin_paths:\n  other: \"/x/other\"\n  socket-patch: \"/proj/p\"\nsources:\n";
         write(&index, body).await;
@@ -2132,8 +2133,12 @@
         .await;
 
         let r = add_plugin_files(root, false).await;
-        assert_eq!(r.status, GemSetupStatus::Updated, "stale plugins.rb resynced");
         assert_eq!(
+            r.status,
+            GemSetupStatus::Updated,
+            "stale plugins.rb resynced"
+        );
+        assert_eq!(
             fs::read_to_string(plugins_rb_path(root)).await.unwrap(),
             PLUGINS_RB
         );

diff --git a/crates/socket-patch-core/src/setup/gem/update.rs b/crates/socket-patch-core/src/setup/gem/update.rs
--- a/crates/socket-patch-core/src/setup/gem/update.rs
+++ b/crates/socket-patch-core/src/setup/gem/update.rs
@@ -1163,11 +1163,8 @@
         // runtime waits for on shutdown; connect a writer to release it so
         // the test can FAIL instead of hanging the whole suite.
         let deadline = std::time::Duration::from_secs(5);
-        let Ok(results) = tokio::time::timeout(
-            deadline,
-            remove_plugin_directive_at(&project, None, false),
-        )
-        .await
+        let Ok(results) =
+            tokio::time::timeout(deadline, remove_plugin_directive_at(&project, None, false)).await
         else {
             let _ = std::fs::OpenOptions::new().write(true).open(&index);
             panic!("remove must complete promptly with a FIFO index");

diff --git a/crates/socket-patch-core/src/update/download.rs b/crates/socket-patch-core/src/update/download.rs
--- a/crates/socket-patch-core/src/update/download.rs
+++ b/crates/socket-patch-core/src/update/download.rs
@@ -741,7 +741,10 @@
         let tmp = tempfile::tempdir().unwrap();
         let missing = tmp.path().join("never-existed");
         sweep_stale_stages(&missing);
-        assert!(!missing.exists(), "sweep must not create the destination dir");
+        assert!(
+            !missing.exists(),
+            "sweep must not create the destination dir"
+        );
     }
 
     /// A write failure AFTER a successful open (EFBIG here, standing in
@@ -757,8 +760,7 @@
     #[test]
     fn stage_write_failure_cleans_up_stage_file() {
         const CHILD_ENV: &str = "SOCKET_PATCH_CORE_TEST_STAGE_FSIZE_CHILD";
-        const TEST_NAME: &str =
-            "update::download::tests::stage_write_failure_cleans_up_stage_file";
+        const TEST_NAME: &str = "update::download::tests::stage_write_failure_cleans_up_stage_file";
         if std::env::var_os(CHILD_ENV).is_none() {
             let exe = std::env::current_exe().expect("test binary path must resolve");
             let output = std::process::Command::new(exe)
@@ -824,7 +826,10 @@
             matches!(err, UpdateError::SwapFailed(_)),
             "expected SwapFailed, got: {err}"
         );
-        assert!(err.to_string().contains("error writing staged binary"), "{err}");
+        assert!(
+            err.to_string().contains("error writing staged binary"),
+            "{err}"
+        );
         let leftovers: Vec<String> = std::fs::read_dir(tmp.path())
             .unwrap()
             .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())

diff --git a/crates/socket-patch-core/src/update/release.rs b/crates/socket-patch-core/src/update/release.rs
--- a/crates/socket-patch-core/src/update/release.rs
+++ b/crates/socket-patch-core/src/update/release.rs
@@ -680,9 +680,11 @@
             .mount(&server)
             .await;
 
-        let client =
-            metadata_client(&short_timeouts(), follow_redirect_policy(&default_endpoints()))
-                .unwrap();
+        let client = metadata_client(
+            &short_timeouts(),
+            follow_redirect_policy(&default_endpoints()),
+        )
+        .unwrap();
         let err = client
             .get(format!("{}/start", server.uri()))
             .send()
@@ -715,9 +717,11 @@
             .mount(&server)
             .await;
 
-        let client =
-            metadata_client(&short_timeouts(), follow_redirect_policy(&default_endpoints()))
-                .unwrap();
+        let client = metadata_client(
+            &short_timeouts(),
+            follow_redirect_policy(&default_endpoints()),
+        )
+        .unwrap();
         let err = client
             .get(format!("{}/start", server.uri()))
             .send()
@@ -793,7 +797,10 @@
             .unwrap_err();
         assert!(matches!(err, UpdateError::CheckFailed(_)), "{err:?}");
         let msg = err.to_string();
-        assert!(msg.contains("expected a redirect to the latest tag"), "{msg}");
+        assert!(
+            msg.contains("expected a redirect to the latest tag"),
+            "{msg}"
+        );
         assert!(msg.contains("API fallback:"), "{msg}");
         assert!(msg.contains("returned 500"), "{msg}");
     }
@@ -836,7 +843,9 @@
         // silently.
         let server = MockServer::start().await;
         Mock::given(method("GET"))
-            .and(path("/SocketDev/socket-patch/releases/download/v1.2.3/SHA256SUMS"))
+            .and(path(
+                "/SocketDev/socket-patch/releases/download/v1.2.3/SHA256SUMS",
+            ))
             .respond_with(ResponseTemplate::new(500))
             .mount(&server)
             .await;

diff --git a/crates/socket-patch-core/src/utils/python_lock.rs b/crates/socket-patch-core/src/utils/python_lock.rs
--- a/crates/socket-patch-core/src/utils/python_lock.rs
+++ b/crates/socket-patch-core/src/utils/python_lock.rs
@@ -848,7 +848,9 @@
             "{rewritten}"
         );
         assert!(
-            rewritten.contains(&format!("{{ url = \"{URL}\", hash = \"sha256:{SHA256}\" }}")),
+            rewritten.contains(&format!(
+                "{{ url = \"{URL}\", hash = \"sha256:{SHA256}\" }}"
+            )),
             "{rewritten}"
         );
         assert!(!rewritten.contains("direct+"), "{rewritten}");
@@ -916,12 +918,17 @@
             "{rewritten}"
         );
         assert!(
-            rewritten.contains(&format!("wheels = [{{ url = \"{URL}\", hash = \"sha256:{SHA256}\" }}]")),
+            rewritten.contains(&format!(
+                "wheels = [{{ url = \"{URL}\", hash = \"sha256:{SHA256}\" }}]"
+            )),
             "{rewritten}"
         );
         assert!(!rewritten.contains("[[distribution.wheel]]"), "{rewritten}");
         assert!(!rewritten.contains("sdist"), "{rewritten}");
-        assert!(rewritten.contains("[[distribution.dependencies]]\nname = \"urllib3\""), "{rewritten}");
+        assert!(
+            rewritten.contains("[[distribution.dependencies]]\nname = \"urllib3\""),
+            "{rewritten}"
+        );
         assert_eq!(
             rewrite_python_lock(
                 &rewritten,
@@ -1062,7 +1069,11 @@
             Some(&*format!("sha256:{SHA256}"))
         );
         assert!(entry.get("wheels").is_none(), "{rewritten}");
-        assert_eq!(rewritten.matches("[[distribution.wheel]]").count(), 2, "{rewritten}");
+        assert_eq!(
+            rewritten.matches("[[distribution.wheel]]").count(),
+            2,
+            "{rewritten}"
+        );
         assert!(!rewritten.contains("wheels = ["), "{rewritten}");
         // The sibling's own artifact is untouched.
         assert!(rewritten.contains("certifi-2024.2.2-py3-none-any.whl"));
@@ -1109,7 +1120,9 @@
         assert_eq!(entry["wheels"][0]["url"].as_str(), Some(URL));
         assert!(entry.get("wheel").is_none(), "{rewritten}");
         assert!(
-            rewritten.contains(&format!("wheels = [{{ url = \"{URL}\", hash = \"sha256:{SHA256}\" }}]")),
+            rewritten.contains(&format!(
+                "wheels = [{{ url = \"{URL}\", hash = \"sha256:{SHA256}\" }}]"
+            )),
             "{rewritten}"
         );
         assert!(!rewritten.contains("[[distribution.wheel]]"), "{rewritten}");
@@ -1397,15 +1410,10 @@
     { url = "https://pypi.org/urllib3-1.26.18-py2.py3-none-any.whl", hash = "sha256:old", size = 123 },
 ]
 "#;
-        let rewritten = rewrite_python_lock(
-            text,
-            "urllib3",
-            "1.26.18",
-            ArtifactSource::Url(URL),
-            SHA256,
-        )
-        .unwrap()
-        .unwrap();
+        let rewritten =
+            rewrite_python_lock(text, "urllib3", "1.26.18", ArtifactSource::Url(URL), SHA256)
+                .unwrap()
+                .unwrap();
         let document: toml_edit::DocumentMut = rewritten.parse().unwrap();
         let manifest = &document["manifest"];
         let constraint = manifest["constraints"][0].as_inline_table().unwrap();
@@ -1414,7 +1422,10 @@
         let build = manifest["build-constraints"].as_array().unwrap();
         let foreign = build.get(0).unwrap().as_inline_table().unwrap();
         assert_eq!(foreign["specifier"].as_str(), Some("==1.0"));
-        assert!(foreign.get("url").is_none(), "a foreign constraint is untouched");
+        assert!(
+            foreign.get("url").is_none(),
+            "a foreign constraint is untouched"
+        );
         let ours = build.get(1).unwrap().as_inline_table().unwrap();
         assert_eq!(ours["url"].as_str(), Some(URL));
         assert!(ours.get("specifier").is_none(), "{rewritten}");

diff --git a/crates/socket-patch-core/src/utils/python_script.rs b/crates/socket-patch-core/src/utils/python_script.rs
--- a/crates/socket-patch-core/src/utils/python_script.rs
+++ b/crates/socket-patch-core/src/utils/python_script.rs
@@ -612,7 +612,12 @@
             "{direct}"
         );
         assert!(uv_line.ends_with('}'), "{direct}");
-        assert!(direct.starts_with("[project]\nname = \"p\"\ndependencies = [\"alpha==1.0.0\"]\n\n[tool]\n"), "{direct}");
+        assert!(
+            direct.starts_with(
+                "[project]\nname = \"p\"\ndependencies = [\"alpha==1.0.0\"]\n\n[tool]\n"
+            ),
+            "{direct}"
+        );
         assert_settled(&direct);
 
         let transitive = rewrite_project_metadata(

diff --git a/crates/socket-patch-core/src/vendor/cargo.rs b/crates/socket-patch-core/src/vendor/cargo.rs
--- a/crates/socket-patch-core/src/vendor/cargo.rs
+++ b/crates/socket-patch-core/src/vendor/cargo.rs
@@ -3061,9 +3061,9 @@
     async fn marker_write_failure_warns_but_vendor_succeeds() {
         let (dir, blobs, pristine, record) = fixture().await;
         let root = dir.path();
-        tokio::fs::create_dir_all(root.join(format!(
-            ".socket/vendor/cargo/{UUID}/{VENDOR_MARKER_FILE}"
-        )))
+        tokio::fs::create_dir_all(
+            root.join(format!(".socket/vendor/cargo/{UUID}/{VENDOR_MARKER_FILE}")),
+        )
         .await
         .unwrap();
 
@@ -3103,7 +3103,10 @@
         let out = revert_cargo_vendor(&entry, root, false).await;
         assert!(!out.success);
         assert!(
-            out.error.as_deref().unwrap_or("").contains("not a cargo purl"),
+            out.error
+                .as_deref()
+                .unwrap_or("")
+                .contains("not a cargo purl"),
             "{:?}",
             out.error
         );
@@ -3224,7 +3227,7 @@
         );
     }
 
-    // ── swap_stage_into_place unit edges ──────────────────────────────────
+    // ── swap_stage_into_place unit edges ────────────────────────��─────────
 
     /// A failed stage rename with NO pre-existing copy parked (had_old =
     /// false skips the backup restore): the error propagates and no backup

diff --git a/crates/socket-patch-core/src/vendor/common.rs b/crates/socket-patch-core/src/vendor/common.rs
--- a/crates/socket-patch-core/src/vendor/common.rs
+++ b/crates/socket-patch-core/src/vendor/common.rs
@@ -498,8 +498,9 @@
     fn in_sync_jar_fixture(
         dir: &Path,
     ) -> (std::path::PathBuf, HashMap<String, PatchFileInfo>, Vec<u8>) {
-        let zip_bytes = write_zip_entries(&[("lib/a.js".to_string(), b"patched\n".to_vec(), 0o644)])
-            .expect("fixture zip");
+        let zip_bytes =
+            write_zip_entries(&[("lib/a.js".to_string(), b"patched\n".to_vec(), 0o644)])
+                .expect("fixture zip");
         let jar = dir.join("pkg.jar");
         std::fs::write(&jar, &zip_bytes).unwrap();
         let files = HashMap::from([(

diff --git a/crates/socket-patch-core/src/vendor/composer_lock.rs b/crates/socket-patch-core/src/vendor/composer_lock.rs
--- a/crates/socket-patch-core/src/vendor/composer_lock.rs
+++ b/crates/socket-patch-core/src/vendor/composer_lock.rs
@@ -1587,7 +1587,9 @@
 
         // Drift the committed copy so the rerun takes the rebuild path…
         let drifted = root.join(copy_rel()).join("src/LoggerInterface.php");
-        tokio::fs::write(&drifted, b"<?php // drifted\n").await.unwrap();
+        tokio::fs::write(&drifted, b"<?php // drifted\n")
+            .await
+            .unwrap();
         // …and make the rebuild fail: the patch bytes cannot be sourced.
         let empty = root.join("empty-blobs");
         tokio::fs::create_dir_all(&empty).await.unwrap();
@@ -2265,7 +2267,9 @@
         assert!(e1.is_some());
 
         let drifted = root.join(copy_rel()).join("src/LoggerInterface.php");
-        tokio::fs::write(&drifted, b"<?php // drifted\n").await.unwrap();
+        tokio::fs::write(&drifted, b"<?php // drifted\n")
+            .await
+            .unwrap();
 
         // Integrity-valid garbage: the download verifies, the extract fails.
         let garbage = b"not a zip at all".to_vec();
@@ -2453,7 +2457,9 @@
         );
         assert!(entry.is_some(), "the wiring is live, the entry is recorded");
         assert!(
-            warnings.iter().any(|w| w.code == "vendor_marker_write_failed"),
+            warnings
+                .iter()
+                .any(|w| w.code == "vendor_marker_write_failed"),
             "{warnings:?}"
         );
         // The surgery really landed despite the marker failure.
@@ -2494,10 +2500,8 @@
     impl Drop for ModeGuard {
         fn drop(&mut self) {
             use std::os::unix::fs::PermissionsExt;
-            let _ = std::fs::set_permissions(
-                &self.path,
-                std::fs::Permissions::from_mode(self.mode),
-            );
+            let _ =
+                std::fs::set_permissions(&self.path, std::fs::Permissions::from_mode(self.mode));
         }
     }
 
@@ -2562,7 +2566,9 @@
             PATCHED
         );
         assert!(
-            warnings.iter().all(|w| !w.code.starts_with("vendor_prebuilt")),
+            warnings
+                .iter()
+                .all(|w| !w.code.starts_with("vendor_prebuilt")),
             "build source must never touch the service: {warnings:?}"
         );
     }
@@ -2721,7 +2727,9 @@
         assert!(result.success, "{:?}", result.error);
         assert!(entry.is_some());
         assert!(
-            warnings.iter().any(|w| w.code == "vendor_prebuilt_unavailable"),
+            warnings
+                .iter()
+                .any(|w| w.code == "vendor_prebuilt_unavailable"),
... diff truncated: showing 800 of 2812 lines

You can send follow-ups to the cloud agent here.

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit fa6b27f. Configure here.

Comment thread crates/socket-patch-core/src/patch/redirect/mod.rs
Pipenv keeps a project's virtualenv OUTSIDE the project by default
(`$WORKON_HOME/<dir>-<hash>[-<python>]`), so after a plain `pipenv install`
the crawler found no VIRTUAL_ENV / .venv / venv and — because a Pipfile is a
Python project marker — fell through to the global interpreter's
site-packages: a bare `scan --mode agent` patched nothing for the project's
dependencies (or a different interpreter that happened to carry the same
release) and reported success, and a bare `rollback` pruned the manifest
while the venv stayed patched. Measured on real Pipenv 11.10.4, 2018.11.26
and 2026.8.0: the PR-head binary scanned 56 global distributions and found
no patch; the fixed binary scans the 2-distribution Pipenv venv and finds
urllib3 1.26.18.

The crawler now reproduces Pipenv's own placement without running Pipenv:
WORKON_HOME (`$VAR`/`${VAR}`/`%VAR%`/`~` expanded like expandvars +
expanduser) or the `$XDG_DATA_HOME`/`~/.local/share/virtualenvs`
(`~/.virtualenvs` on Windows) default, the `.venv` FILE pointer (relative
path or WORKON_HOME name), PIPENV_CUSTOM_VENV_NAME, PIPENV_PIPFILE, and
`Project._get_virtualenv_hash` — the sanitized directory name capped at 42
chars, a dash, the first 6 bytes of sha256(absolute Pipfile path) in URL-safe
base64 — unchanged from Pipenv 7 through 2026 and pinned by known-answer
vectors. Both sanitizer generations (2022+ also replaces `& ( ) [ ]`) are
tried, any `-<PIPENV_PYTHON>` suffix is matched, and Pipenv's
case-insensitive-filesystem fallback (a recased directory hashed over the
recased location) is honoured. A project-local venv still wins.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`scan --mode vendored` on a fresh clone (pyproject + poetry.lock, nothing
installed) was skipped with `vendor_fetch_unverifiable` + `package_not_installed`
although the lock records the wheel's sha256 — the poetry.lock inventory was
discovery-only (`LockIntegrity::None`), so the fetch gate refused before the
vendor engine ever ran, while the identical uv.lock scenario vendored fine.
That broke the CI story for Poetry: the machine that vendors had to have the
package installed.

The inventory now carries the pure-Python (`-none-any.whl`) wheel's sha256 from
`files` (lock 2.x) or `[metadata.files]` (lock 1.0/1.1), lowercased, and the
pypi fetcher resolves a hash-only entry through PyPI's JSON API
(`urls[].digests.sha256`, `SOCKET_PYPI_JSON_API` overrides the endpoint) and
verifies the download against the same digest, exactly like uv's lock-only
path. Poetry 0.12's bare `[metadata.hashes]` names no wheel, and platform-only
wheels offer no platform-independent choice, so those stay discovery-only.

Measured on the fixed CLI: lock-only vendoring now applies on Poetry 1.0
(populated lock), 1.2, 1.8 and 2.4 fixtures (`vendor_fetched_missing` +
`vendor_prebuilt_downloaded`), and the resulting checkout installs the patched
wheel with every release.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… in every mode

The lock inventory never read Pipfile.lock ("Pipenv/pdm locks: not yet
read"), so a fresh clone with only Pipfile + Pipfile.lock — the CI /
fresh-checkout shape hosted mode exists for — discovered ZERO packages:
`scan --mode hosted` redirected nothing and `scan --mode vendored` vendored
nothing, both exiting 0 with no warning. Every Pipenv install proof so far
pre-installed the package before scanning, which hid this. Measured on the
PR head with a Pipenv 2026.8.0 lock: scannedPackages 0 / redirected 0 in all
three modes; on the fix: lockfileOnlyPackages 1, hosted redirected 1,
vendored applied 1, rollback byte-identical.

`inventory_pipfile_lock` reads every category other than `_meta`; registry
pins (`==` version) become entries and VCS/path/file/editable sources,
range pins and our own wired file references are skipped. Pipenv records
every release file's sha256 without filenames, so the entry carries the
digest SET as the new `LockIntegrity::Sha256AnyOf`: the pypi fetcher picks
the pure-Python `-none-any.whl` whose PyPI digest is in the set (never an
sdist or platform wheel, whatever the list order) and verifies the download
against the set. Pipfile.lock sits between poetry.lock and requirements.txt
in the inventory precedence, mirroring `detect_pypi_flavor`; a parseable
uv.lock stays exclusive.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…writers

Every `plan()` failure landed in `refused_pipenv_uuids` and was stripped from
the requirements.txt / uv.lock / pyproject rewriters — including "no entry
for the package", an old pipfile-spec, an unparseable (or BOM-prefixed) lock
and a digest-less patch. A stale Pipfile.lock left behind in a uv, Poetry or
requirements project therefore blocked every hosted redirect in the files
the project actually installs from (Cursor Bugbot HIGH on #242).

`plan()` now classifies its refusals: a CONFLICT (another version pinned, a
foreign `file`/`path` source, a VCS/editable dependency) still vetoes the
siblings — the project's Pipenv install could not pick the patch up, so a
half-redirected checkout would be worse — and is reported as
`redirect_pipenv_refused`; everything else is reported as
`redirect_pipenv_skipped` and leaves the siblings alone. A UTF-8 BOM in
front of the lock (Windows editors) is parsed past and preserved through the
rewrite and the rollback.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…pstream release in place

Measured on real Pipenv 11.10.4, 2018.11.26 and 2026.8.0, hosted AND
vendored: with the same release already installed, `pipenv install`,
`pipenv install --deploy` and `pipenv sync` all exit 0 and keep the
upstream bytes (pip: "Requirement already satisfied"). The rewritten lock
protects fresh installs only, and nothing said so — the same silent-CVE
class the gem redirect guards against (#219). Every Pipenv install proof so
far uninstalled the package before installing, which hid it.

Hosted: a post-rewrite probe over the Python crawler's site-packages
(VIRTUAL_ENV, ./.venv, ./venv, Pipenv's out-of-tree venv; --global honoured)
judges each confirmed Pipfile.lock redirect with the shared
`verify_patch_record` oracle and warns `redirect_pipenv_stale_install` on
POSITIVE evidence only (readable bytes ≠ afterHash); an agent-mode-patched
install, a lock-only checkout and missing/unreadable files stay silent, and
stale purls are excluded from the same-run `--vex` attestation exactly like
gem's. Gated off on --dry-run. `confirmed_pipenv_uuids` (previously written
and never read) scopes the probe to the redirects the Pipenv rewriter made.
Vendored: the pipenv wiring path pushes `pypi_pipenv_stale_install` under
the same rules.

Remedy, verified on 2026 and 2018 with the Pipfile byte-untouched:
`pipenv run pip uninstall -y <pkg> && pipenv sync` or `pipenv --rm && pipenv
sync`. `pipenv uninstall <pkg>` is NOT it — it rewrites the Pipfile and
re-locks the patch away (the package vanishes); `PIP_FORCE_REINSTALL=1
pipenv sync` works on 2018 but is ignored by 2026.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ler only when a patch targets the lock

Rollback: `pipenv lock` (and `update`, and `install <other>` before 2024)
regenerates the redirected entry to registry shape on every Pipenv major
(measured). `restore()` then saw live ≠ new and refused with "drifted",
holding the whole pypi replay group — and a re-scan added a second edit on
the same key, so the ledger could never be unwound. A registry-shaped live
entry (no `file`/`path` key) is now the desired end state: the edit retires
cleanly and the user's fresh resolution stands. A foreign `file`/`path`
reference is still drift and still refuses.

Installer probe: `pipenv --version` ran (up to 10 s) on every hosted scan
whose root merely had a Pipfile.lock, and `redirect_pipenv_installer_unknown`
fired even when nothing was redirected, on every re-run and on --dry-run,
telling users to install legacy Pipenv. It now runs only when a pypi patch
targets an entry of that lock (`pipenv_lock_targets`) and warns only when
the lock was actually rewritten, with the actual consequence (the modern
`file` shape was chosen) and the remedy. `SOCKET_PIPENV_MAJOR=<major>` pins
the answer for CI images without pipenv or projects installed with a
different release than the machine's default.

Probe hardening: `pipenv` is resolved on ABSOLUTE PATH entries only — the
probe runs with the project as cwd, so `.`/empty PATH components would have
executed a `pipenv` planted in the scanned repository — and on Windows the
PATHEXT extensions are tried so `pipenv.bat`/`pipenv.cmd` shims (pyenv-win)
are found and run through `cmd.exe /C`. `parse_major` takes only the token
after `version` (every release 0.2.8–2026.8.0 prints `pipenv, version X`),
never a stray dotted number such as a `Python 3.12` banner. `.env` loading is
disabled for the probe.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…a qualifier

`scan --mode hosted --vex` exempts the purls the run just confirmed from
installed-tree verification and attests them from the redirect ledger
(`assume_applied`). The confirmed purls come from the grant reference
unqualified (`pkg:pypi/urllib3@1.26.18`) while the ledger records the API's
artifact-qualified purl (`…?artifact_id=py2-py3-none-any-whl`), so for pypi
redirects nothing matched: a lock-only Pipenv checkout redirected the lock
and then exited 1 with `no_applicable_patches`. Match on the
qualifier-stripped purl on both sides. Measured on a lock-only Pipenv
2026.8.0 checkout: exit 1 / 0 statements before, exit 0 / 1 `not_affected`
statement after.

Same change as b7a4254 on fix/poetry-compat-review (PR #241); identical so
the two merge cleanly.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nventory

Reading Pipfile.lock as an `else if` before requirements.txt hid every
requirements pin behind a Pipfile.lock — including the stale one Bugbot's
scenario leaves in a requirements project — so urllib3 was no longer even
discovered there and the requirements redirect silently stopped happening.
Pipenv projects also routinely ship both files (`pipenv requirements`
exports the same pins). Both are now read and deduplicated; the hosted
rewriter judges each file on its own. Verified: stale Pipfile.lock +
requirements.txt → requirements redirected, `redirect_pipenv_skipped`
("no entry"), rollback restores the pin.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The PR pinned the rewriter with unit tests but nothing drove the CLI wiring:
the `path` branch was unreachable in CI (it needs a real Pipenv 7–11 on
PATH), the lock-only fresh-checkout shape had no test at all, and the
Bugbot veto had no regression. Against a mocked API and the committed
Pipenv 2026.8.0 lock: a lock-only project is discovered from Pipfile.lock
alone, repointed with `file` + `#sha256=` + `hashes`, attested by the
same-run `--vex` (with `--vex-product`, since a Pipfile names no project),
re-scanned idempotently (one edit, not two) and rolled back byte for byte;
`SOCKET_PIPENV_MAJOR=11` selects `path` references; a stale Pipfile.lock no
longer vetoes the requirements.txt redirect; a venv holding the upstream
release is kept out of the attestation and never modified.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rated

`vendor --revert` / `rollback` treated a relocked Pipfile.lock entry as
drift when the regenerated registry entry differed from the recorded
original — Pipenv 2022.12.19 writes a different hash list; 2026 reproduces
the original byte for byte and already converged — so the rollback exited
1 (`partial_failure`), kept the orphaned wheel dir and held the ledger
entry forever for a reference nothing pointed at (measured in the matrix).
A live entry that is registry-shaped (no `file`/`path`) for a rewritten
record with a recorded original now retires the record with
`vendor_lock_entry_relocked`: the artifact is removed, the ledger entry
dropped, the user's fresh resolution stands. A foreign `file`/`path`
reference is still drift and still keeps both, and the destructive `Added`
arm keeps its deep-equality gate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…d agent mode

scripts/backtest-pipenv.py drives the real CLI and the last stable release of
every published Pipenv major (0.2.8 … 2026.8.0; the ten pre-2018 releases run
inside python:3.6.15-slim through a host-side pipenv wrapper so the CLI's
installer probe sees them) through hosted, vendored, agent and out-of-tree
agent mode over the depscan capture shapes (direct, dev, category, marker,
marker-excluded, extras, transitive, crlf) and four CLI invocations (in-dir,
--cwd, nested --cwd, symlinked cwd). Beyond the depscan harness it checks the
lock-only fresh checkout, --dry-run parity, the warm-venv stale-install
warning, whether a warm venv is reinstalled (recorded), the relock → rollback
retirement, fresh-clone installs, tamper rejection, `pipenv verify`/
`requirements`, vex and byte-exact rollback, and it requires the bare agent
scan to see Pipenv's out-of-tree venv. Python pins are overridable
(BACKTEST_PY38 / BACKTEST_PY312) for containers without the exact patch
releases.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…d the measured boundaries

CLI_CONTRACT.md gains Pipfile.lock in the candidate-file list, a "Pipenv
hosted redirect" section (reference key by installer major and the probe
rules, refusal vs skip scope, the era-split hash enforcement, the
stale-install guard and its verified remedy, relock retirement, the
`--vex-product` requirement) and vocabulary rows for
redirect_pipenv_{refused,skipped,installer_unknown,stale_install},
pypi_pipenv_{installer_unsupported,version_mismatch,invalid_wheel,
stale_install} and vendor_lock_entry_relocked. docs/ecosystems.md no longer
says Pipenv locks are not rewritten in hosted mode. CHANGELOG records the
feature and the fixes; the README section states what was measured
(including that `pipenv uninstall` is not the warm-venv remedy); the new
docs/testing/pipenv-compatibility.md holds the installer boundaries and the
matrix recipe, with the results table generated after the final run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…igin, validate ledger originals

- `pipenv lock --keep-outdated` / `install --keep-outdated <pkg>` (2022)
  rewrite our entry into a file+version+index hybrid that Pipenv still
  installs from; the rewriter refused it as a foreign source ("already
  exists") and, being a conflict, vetoed the sibling Python rewriters. An
  entry whose reference is ours is now re-planned to the canonical shape
  when its `version` still names the patched release, and is a conflict
  only when it names another one.
- `owned_url` recognized only `https://patch.socket.dev`, so a
  `--patch-server-url` deployment refused its own previous references on
  every re-scan. Ownership now follows the grant's own artifact URL origin
  (the public service stays recognized).
- `restore()` spliced the committed, tamper-able ledger `original` string
  into the lock verbatim; it must parse as a JSON object first.
- Vendored mode said nothing when the installer could not be probed while
  hosted warned; it now warns `pypi_pipenv_installer_unknown` with the
  `SOCKET_PIPENV_MAJOR` remedy. `vendor_integrity_unverified` no longer opens
  with "Pipenv 2018 or later is required" on Pipenv 2026 and names the
  release split (2018–2022 verify local-wheel hashes, 2023+ do not) and
  `vex --product`.
- Harness: a CRLF lock re-serialized LF by the vendored backend is recorded,
  not required (it is what `pipenv lock` itself writes).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A dedicated workflow drives scripts/backtest-pipenv.py against the real CLI
and real Pipenv releases (every 2018+ major on ubuntu-latest; 2018, 2022,
2023 and 2026 on macos-latest) through hosted, vendored, agent and
out-of-tree agent mode, on pull requests that touch the Pipenv code paths,
on pushes to main, and on demand. Windows real-Pipenv installs stay a local
concern (the harness runs the pre-2018 releases in Docker and is POSIX
layout-bound); the in-process hosted Pipenv CLI tests already run on all
three platforms in the `test` job.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… directories

Pipenv 2018–2021 append the WHOLE `PIPENV_PYTHON` string to the virtualenv
name (`<name>-<hash>-/opt/tools/bin/python`), so the venv sits at the bottom
of a directory chain under WORKON_HOME; 2022+ append the basename. The
crawler matched the top-level directory but looked for site-packages only
directly beneath it, so a bare agent scan on Pipenv 2018 with an absolute
PIPENV_PYTHON applied nothing (measured on Linux). The matched directory is
now walked down (bounded depth, no name pruning — the chain literally
contains `bin/python`) to the first directories holding a site-packages.

Harness: pipfile-spec < 6 is now reported as `redirect_pipenv_skipped` in
hosted mode (a skip, not a veto); agent-mode shapes Pipenv 0.x cannot
express (inline-table markers/extras install nothing) are skipped with a
note; the case-insensitive fallback test picks a project name whose recased
hash carries no dash, since Pipenv's own `rsplit("-", 1)` cannot see one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Pipenv 0.x–6.x install plain string pins only (inline-table markers are
ignored, extras fail to install) and 0.x cannot stamp the transitive
Pipfile's content hash, so those agent-mode cells are skipped with a note
instead of measuring an installer limitation; hosted refusals for
pipfile-spec < 6 are expected as `redirect_pipenv_skipped`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…he stale probes to the project's venvs, recover lock-only re-scans from the ledger

Verification round on the fix branch (23 deduplicated findings, three
adversarial lenses each) plus the full 18-major matrix surfaced what the
first fixes left open; all confirmed empirically against real Pipenv:

- Rollback tolerance (hosted `restore()` and vendored `revert_pipenv`):
  entries are compared as parsed JSON, so a whole-file CRLF/LF conversion
  (git autocrlf, a cross-OS checkout) is not drift and the original is
  spliced back in the live file's line ending; an entry Pipenv
  re-serialized around our very reference — 2023+ relock an entry excluded
  by its marker keeping our `file` but restoring the registry `hashes` and
  `version`; `--keep-outdated` re-adds `version`/`index` — restores the
  original (only `hashes`/`version`/`index` may differ; a changed marker or
  reference is still drift); an entry a relock DROPPED (`pipenv uninstall`)
  retires the edit / the vendored record (`vendor_lock_entry_relocked`).
  The replay stages the lock only when the restore changed it (no `editedFiles`
  credit for a retired edit).
- Live-lock veto: a conflicting pin vetoes the sibling Python rewriters only
  when a Pipfile sits beside the lock; an abandoned Pipfile.lock refuses its
  own rewrite but leaves requirements.txt / uv.lock alone. `Pipfile` joins
  the hosted candidate files (read-only).
- Stale probes judge the PROJECT'S venvs only (VIRTUAL_ENV, ./.venv, ./venv,
  Pipenv's WORKON_HOME venv): the hosted probe no longer falls through to
  the global interpreters, and the vendored one no longer judges the staging
  dir a lock-only vendor fetched the pristine wheel into (a false
  `pypi_pipenv_stale_install` on every lock-only run). It also runs on the
  already-wired arm so a re-run keeps warning while the venv is stale.
- Lock-only re-scans were one-shot: after the first vendored run the
  inventory skips our own wiring and the re-scan exited 1
  `package_not_installed`. `recover_lock_entry` now reads the pre-vendor
  Pipfile.lock fragment from the ledger (its digest set) so the re-scan
  fetches by digest and reports `already_vendored`.
- Vendored CRLF: Pipenv preserves a CRLF lock; the wire and revert writes
  now do too (the matrix's 8 CRLF vendored cells restore byte-identically).
- Version probe: neutral working directory (the scanned repository's `.env`,
  Pipfile and `.venv` pointer no longer reach it), executable-bit check on
  PATH candidates, `SOCKET_PIPENV_MAJOR` accepts a full release string,
  `PIPENV_IGNORE_VIRTUALENVS`; the `redirect_pipenv_installer_unknown`
  detail lost its stray whitespace.
- Messages: a HOSTED Socket reference met by the vendored guards names the
  remedy (`socket-patch rollback`) instead of "user-declared".
- Docs: CLI_CONTRACT (live-lock veto, JSON-compared rollback, removed
  entries, `pypi_pipenv_installer_unknown`), hosted-production-e2e.md rows,
  pipenv-compatibility.md (CLI scope, line endings, relock variants).
  Harness: CRLF preservation required for vendored again; Pipenv 7's
  out-of-tree venv creation is an image limitation and is skipped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ver query PyPI for private-index locks, honour USERPROFILE on Windows

- The CI re-run shape — `scan --mode hosted --vex` on a checkout whose
  Pipfile.lock already carries the committed Socket reference — saw zero
  packages (the inventory skipped every `file`/`path` entry, ours included),
  so the same-run `--vex` exited 1 `no_applicable_patches` and a vendored
  re-scan went through the ledger only. A hosted URL
  (`…/patch/pypi/<name>/<version>/…/<wheel>`) or a vendored
  `.socket/vendor/pypi/<uuid>/<wheel>` path now yields a discovery-only
  entry for the package it replaces, so re-scans re-confirm and attest it.
- A lock whose `_meta.sources` name no public PyPI index keeps its entries
  discovery-only: the digest-set lookup must not leak private package names
  to pypi.org (it would not find their files there either).
- Pipenv's default `WORKON_HOME` on Windows comes from `USERPROFILE`
  (`HOMEDRIVE`+`HOMEPATH`, then `HOME`), like Python's `expanduser`; a
  Git-Bash `HOME=/c/Users/u` no longer wins.
- The probe's `SOCKET_PIPENV_MAJOR` test mutated the process environment
  under a parallel test runner (a hermeticity race); `parse_override` is
  unit-tested instead. The ledger-recovery test now expects the Pipenv
  digest set to be fetchable.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ference

With Socket's own Pipfile.lock reference now inventoried (discovery-only),
the vendored re-scan of a lock-only checkout fetched by that entry — which
carries no registry integrity — and reported `vendor_fetch_unverifiable` +
`package_not_installed` (exit 1) instead of `already_vendored`. A
discovery-only inventory entry now defers to the ledger's pre-vendor
fragment (digest set) when one exists; without a ledger it behaves as
before. Measured: three consecutive lock-only vendored scans → applied,
already_vendored, already_vendored, lock and wheel intact. The harness
checks this re-run shape for both modes (`lockOnlyRescanGreen`).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…he private-index rule

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…code path in the trigger

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Conflicts resolved: the crawler runs Poetry's then Pipenv's out-of-tree
venv discovery (both gated on nothing found above); replay keeps the
PipenvEntry inverse next to main's HatchDocument; the hosted confirmed-uuid
filter confirms Pipfile.lock rewrites through `confirmed_pipenv_uuids`
alongside main's python-lock / hatch / requirements sets and still vetoes
`refused_pipenv_uuids`; main's generic Python stale-install guard
(`redirect_pypi_stale_install`, `known_stale`) replaces the Pipenv-specific
hosted probe — it now judges only the project's own venvs (no global
interpreter fallback) and names the Pipenv-specific remedy (`pipenv run pip
uninstall -y <pkg> && pipenv sync`, never `pipenv uninstall`) for
Pipfile.lock redirects; the pypi fetcher keeps the digest-set resolver
(superset of main's single-hash one); the candidate-file list, the
ecosystem matrix row, the hosted-e2e note, CHANGELOG and CLI_CONTRACT carry
both sides once.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…racle

The pre-2018 legs run the byte oracle in a fresh container over a bind
mount the host CLI just wrote through (stage + rename); Docker Desktop's
shared file cache occasionally shows the directory without the renamed
entry for a moment (seen twice in ~900 cells, always right after a host
write). A genuinely absent file stays absent across the retries.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ex gate

`cargo clippy --workspace --all-features -- -D warnings` (CI's invocation)
rejected the `.map_or(true, ..)` on the `_meta.sources` lookup; use
`Option::is_none_or`, which spells the same rule (no sources block means the
public index).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…iginal

Pipenv 2023+ relock a marker-excluded entry into a hybrid that keeps our
`file` reference and restores the upstream hashes and version around it. That
entry is still ours, and `rollback` restores the pristine registry entry (the
matrix captured byte-identical locks on 2023.12.1 through 2026.8.0, hosted and
vendored). The harness demanded the relocked bytes be kept, which is right
only for a registry-shaped relock; judge the two outcomes separately and
compare the urllib3 entries semantically.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
macOS: 470/470 cells over the last stable release of all 18 Pipenv majors x
8 lock shapes x {hosted, vendored, agent, out-of-tree agent} (103 expected
refusals, 36 installer skips). Linux: 32/32 (release binary in a container,
2018+ majors, 4 modes). Invocation variants (--cwd, nested --cwd, symlinked
project directory): 48/48. Per-case checks and measured flags in
docs/testing/pipenv-compatibility/results.json (log tails stripped).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedgithub/​astral-sh/​setup-uv@​d0cc045d04ccac9d8b7881df0226f9e82c39688e32100100100100

View full report

@socket-security-staging

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedgithub/​astral-sh/​setup-uv@​d0cc045d04ccac9d8b7881df0226f9e82c39688e45100100100100

View full report

@mikolalysenko
Mikola Lysenko (mikolalysenko) merged commit 07a6b88 into main Sep 18, 2026
98 checks passed
@mikolalysenko
Mikola Lysenko (mikolalysenko) deleted the codex/pipenv-patch-compatibility branch September 18, 2026 15:19
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.

2 participants