GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — Fix Python env discovery ignoring uv/PDM env (#502, #525, #528) by mikolalysenko · Pull Request #540 · SocketDev/socket-patch

Conversation

@mikolalysenko

@mikolalysenko Mikola Lysenko (mikolalysenko) commented Oct 2, 2026 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown
Collaborator

LLM Description written by Claude Code:claude-opus-5-5

Fixes #502
Fixes #525
Fixes #528

Summary

For the Python project env, socket-patch now uses the env that uv or PDM actually installs into: a PDM venv, a PEP 582 __pypackages__ dir, or a uv UV_PROJECT_ENVIRONMENT. It no longer guesses ./.venv or falls back to the PATH interpreter. Agent mode patches that env. The hosted stale-install warning and vex check it, so vex no longer reports not_affected for an install that is still unpatched.

Root cause

find_local_venv_site_packages_with (crates/socket-patch-core/src/crawlers/python_crawler.rs) picks the project env from VIRTUAL_ENV, Pipenv, Poetry, ./.venv and ./venv. It never asks the project's own package manager where it installed:

  • uv (uv projects whose env is set by UV_PROJECT_ENVIRONMENT are never probed: agent scan patches the PATH interpreter and uv tool envs instead, the real env stays vulnerable, and vex attests not_affected #525): UV_PROJECT_ENVIRONMENT moves the project env, and uv sync / uv run use it in place of ./.venv or an activated VIRTUAL_ENV.
  • PDM (Agent and hosted mode ignore the interpreter PDM records in .pdm-python (venv.in_project = false, pdm use <venv>), so the real env is skipped or a stray .venv / the PATH python is patched, and VEX attests an unpatched install #502): .pdm-python (or the older .pdm.toml [python] path) records the interpreter PDM installs into. That can be an out-of-tree venv (venv.in_project = false) or one picked with pdm use. PDM prefers it over an activated venv.
  • PDM PEP 582 (In a PDM __pypackages__ (PEP 582) project, hosted mode gives no stale-install warning and vex attests not_affected while the copy pdm run imports is still unpatched #528): when the recorded interpreter isn't a venv, or a PDM 0.x/1.x project saved none and has no venv, PDM installs into __pypackages__/<X.Y>/lib, which nothing probed.

So discovery fell through to a stray ./.venv, or to the PATH interpreter through the is_python_project fallback. All three consumers read this one function: agent apply, the hosted redirect_pypi_stale_install probe (scan/hosted/python.rs), and vex. All three issues are fixed in this one place.

Fix

A new step 0, package_manager_recorded_site_packages, runs ahead of the generic probes, and a PEP 582 step 5 runs after them:

  • PDM applies only to a PDM project (.pdm-python, pdm.lock, .pdm.toml or [tool.pdm]) that has no uv.lock or poetry.lock, since those drive installs ahead of pdm.lock, as the hosted rewriters already assume. If there's an interpreter to follow (PDM_PYTHON, else the saved one unless PDM_IGNORE_SAVED_PYTHON), step 0 uses its venv's site-packages, or __pypackages__/*/lib for a base interpreter. With no interpreter, PDM picks an activated or in-project venv first, so the generic probes decide. __pypackages__ is the last resort (step 5) when they find nothing.
  • uv: uses UV_PROJECT_ENVIRONMENT (absolute, or relative to the project) only for a project uv drives, so the ambient variable can't hijack another manager's project. That means a project with uv.lock, or a pyproject.toml with no [tool.poetry] or PDM settings ([tool.pdm] beyond build, .pdm.toml) and no poetry.lock, poetry.toml, pdm.lock, .pdm-python or Pipfile.
  • When the recorded env exists, it wins and a stray ./.venv is ignored. When it doesn't exist yet (not synced), the existing probes and fallback decide, so behavior there is unchanged.
  • Every file read goes through read_regular_to_string, so a FIFO in place of pyproject.toml can't wedge the scan.

Docs: CLI_CONTRACT probe list, the PDM page (replaces the "__pypackages__ not covered" limit), and a uv UV_PROJECT_ENVIRONMENT note.

Out of scope, unchanged: when no project env exists at all, the is_python_project → global-interpreter fallback still applies. That's the open maintainer question in #504.

Per-issue test checklist (red on main → green here)

  • Agent and hosted mode ignore the interpreter PDM records in .pdm-python (venv.in_project = false, pdm use <venv>), so the real env is skipped or a stray .venv / the PATH python is patched, and VEX attests an unpatched install #502: python_crawler::tests::pdm_saved_interpreter_venv_is_the_project_env (also covers PDM_PYTHON, PDM_IGNORE_SAVED_PYTHON, legacy .pdm.toml, and uv.lock/poetry.lock precedence), in_process_python_envs::pdm_saved_interpreter_venv_is_scanned_not_a_stray_dot_venv, in_process_redirect_pdm::pdm_recorded_env_is_probed_for_stale_hosted_installs (out-of-tree venv case)
  • uv projects whose env is set by UV_PROJECT_ENVIRONMENT are never probed: agent scan patches the PATH interpreter and uv tool envs instead, the real env stays vulnerable, and vex attests not_affected #525: python_crawler::tests::uv_project_environment_is_the_project_env (absolute, relative, activated VIRTUAL_ENV, lock-less, lockless-Poetry, lockless-PDM, [tool.pdm.build]-only, other-manager and non-project controls, unsynced env), in_process_python_envs::uv_project_environment_is_scanned_not_a_stray_dot_venv
  • In a PDM __pypackages__ (PEP 582) project, hosted mode gives no stale-install warning and vex attests not_affected while the copy pdm run imports is still unpatched #528: python_crawler::tests::pdm_pep582_pypackages_is_the_project_env (also covers activated or in-project venv precedence with no saved interpreter, and a lockless PDM project beside an ambient uv env), in_process_python_envs::pdm_pep582_pypackages_is_scanned_not_a_stray_dot_venv, in_process_redirect_pdm::pdm_recorded_env_is_probed_for_stale_hosted_installs (PEP 582 case: stale warning plus no VEX file, then attests once patched)

To show red, I disabled step 0. All 3 core tests and all 4 CLI tests then fail. On main, the hosted PDM test reproduces the false attestation (exit 0, "warnings":[], vex.statements: 1 over upstream bytes).

Review rounds

  • Bugbot on ab7aeb6 found two issues, both real and both fixed in 60dfb81 with tests: PDM_PYTHON was ignored, and a leftover PDM record could shadow a uv.lock project. Bugbot's re-review of 60dfb81 found no new issues.
  • Review on 60dfb81 found two more issues, both fixed in 5ecac79 with tests: a lockless Poetry project ([tool.poetry] / poetry.toml) could be taken over by an ambient UV_PROJECT_ENVIRONMENT, and with no saved PDM interpreter __pypackages__ beat an activated or in-project venv. PEP 582 is now the last resort there, as in PDM.
  • Re-review of 5ecac79 found one regression, fixed in 2a22d4e with tests: the uv probe now keeps lockless PDM projects ([tool.pdm], .pdm.toml) out of UV_PROJECT_ENVIRONMENT, and uv.lock still wins. A [tool.pdm.build]-only table (the pdm-backend build backend) doesn't count as PDM-managed.
  • Bugbot on 5ecac79 flagged the same lockless-PDM case (already fixed in 2a22d4e), plus PDM_IGNORE_ACTIVE_VENV being ignored. That second one is fixed in c1710a9 with a test: for a PDM project, the VIRTUAL_ENV probe now honors the opt-out.
  • Re-review of c1710a9: PDM_IGNORE_ACTIVE_VENV=0 and other false values were read as true. Fixed in aabaf5b: PDM's boolean env settings are now parsed like PDM's ensure_boolean, with tests for the false values.
  • Bugbot on aabaf5b: a base PDM_PYTHON displaced the saved venv and meant PEP 582 even with PDM's default use_venv = true, and conda envs weren't recognized. Fixed in ca62351: the first recorded interpreter that is an env wins, conda envs (conda-meta/) count as envs, and a base interpreter means PEP 582 only when python.use_venv is off. Bugbot's autofix (8c9d28d) for the same finding was merged in 2b5c881, keeping ca62351's logic.

Verification

  • CI on 60dfb81: all 340 check runs green (334 success, 6 skipped matrix/conditional jobs). c1710a9 conflicted with main once Fix Poetry venv selection ignoring envs.toml (#476, #526) #527 (Poetry env selection) landed. db9160b merges main, keeping its poetry_active_prefix with the PDM_IGNORE_ACTIVE_VENV gate added on top. CI on 2b5c881: all 340 check runs green (334 success, 6 skipped).
  • On db9160b: core lib 4725 passed (only the 4 root-only failures that also fail on main here), plus in_process_python_envs 17/17, in_process_redirect_pdm 5/5, in_process_redirect_poetry 7/7, in_process_redirect_pipenv 6/6, in_process_pypi_apply 5/5, in_process_pypi_multi_release 4/4 and scan 104/104; clippy clean. Earlier, on c1710a9: core python_crawler 57/57, in_process_python_envs 17/17, in_process_redirect_pdm 5/5, scan 104/104; cargo clippy --workspace --all-features -- -D warnings clean.
  • Earlier full run, cargo test --workspace --all-features --no-fail-fast: 9474 passed. The 13 failures, besides one FIFO case, are all write-failure or unremovable-file tests that can't fail when running as root (uid 0) in this container. I confirmed the core ones fail identically on unmodified main in this container. The FIFO case (scan::hosted_symlinked_files::hosted_scan_returns_with_fifo_candidate) was a real regression in an earlier commit, also caught by CI coverage. ab7aeb6 fixes it.
  • cargo fmt --all -- --check isn't clean on main with this toolchain either, and CI doesn't run it, so I kept fmt changes to my own hunks.
  • npm/pypi/gem wrappers: no change needed (they only dispatch the binary).

🤖 Generated with Claude Code


Generated by Claude Code

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
@claude
Start fix for #502, #525, #528
ccb0347
Assisted-by: Claude Code:claude-opus-5-5
This was referenced Oct 2, 2026
Agent and hosted mode ignore the interpreter PDM records in .pdm-python (venv.in_project = false, pdm use <venv>), so the real env is skipped or a stray .venv / the PATH python is patched, and VEX attests an unpatched install #502
Closed
uv projects whose env is set by UV_PROJECT_ENVIRONMENT are never probed: agent scan patches the PATH interpreter and uv tool envs instead, the real env stays vulnerable, and vex attests not_affected #525
Closed
In a PDM __pypackages__ (PEP 582) project, hosted mode gives no stale-install warning and vex attests not_affected while the copy pdm run imports is still unpatched #528
Closed
Claude (claude) added 4 commits October 2, 2026 07:48
@claude
Find the env uv and PDM install a project into
1883c61
socket-patch picked a Python project's environment from VIRTUAL_ENV,
Pipenv, Poetry, ./.venv and ./venv. It never asked uv or PDM where they
installed the project. With UV_PROJECT_ENVIRONMENT set, a PDM
.pdm-python pointing at an out-of-tree venv, or a PEP 582
__pypackages__ layout, agent mode patched a stray .venv or the PATH
Python. It also skipped the package as not installed, the hosted
stale-install warning stayed silent, and vex attested installs that
were still unpatched.

Discovery now checks the env the project's manager records before
the generic probes: PDM's saved interpreter (its venv, or
__pypackages__/<X.Y>/lib for a base interpreter or PDM 1.x), then
UV_PROJECT_ENVIRONMENT for a project uv drives.

Fixes #502, #525, #528.

Assisted-by: Claude Code:claude-opus-5-5
@claude
Document uv and PDM project env discovery
ce6aee0
Assisted-by: Claude Code:claude-opus-5-5
@claude
Honor PDM_IGNORE_SAVED_PYTHON in env discovery
06cba3f
PDM disregards the saved .pdm-python interpreter when
PDM_IGNORE_SAVED_PYTHON is set, so discovery does too and falls back
to the generic probes.

Assisted-by: Claude Code:claude-opus-5-5
@claude
Don't block on a FIFO pyproject in PDM probes
ab7aeb6
The new PDM env probe opened pyproject.toml, .pdm-python and
.pdm.toml with a plain read, so a FIFO in their place wedged scan
forever (caught by hosted_scan_returns_with_fifo_candidate). Read them
with read_regular_to_string, as the Poetry probe already does.

Assisted-by: Claude Code:claude-opus-5-5
@mikolalysenko
Mikola Lysenko (mikolalysenko) marked this pull request as ready for review October 2, 2026 08:18
@mikolalysenko

Mikola Lysenko (mikolalysenko) commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

BugBot review


Generated by Claude Code

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

cursor[bot]
cursor Bot reviewed Oct 2, 2026
View reviewed changes

@cursor cursor Bot left a comment •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown

There was a problem hiding this comment.

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 2 potential issues.

Autofix Details

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

  • ✅ Fixed: PDM env shadows uv projects
    • Added priority check in pdm_project_site_packages to return None when uv.lock or poetry.lock exists, ensuring uv and Poetry environments take precedence over PDM's saved interpreter.

Create PR

Or push these changes by commenting:

@cursor push 810f39b188
Preview (810f39b188)
diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs
--- a/crates/socket-patch-cli/src/commands/apply.rs
+++ b/crates/socket-patch-cli/src/commands/apply.rs
@@ -2,9 +2,7 @@
 use socket_patch_core::api::blob_fetcher::get_missing_blobs;
 use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClient};
 use socket_patch_core::crawlers::ruby_crawler::config_path_ignored_warning;
-use socket_patch_core::crawlers::{
-    detect_npm_pkg_manager, Ecosystem, NpmPkgManager, RubyCrawler,
-};
+use socket_patch_core::crawlers::{detect_npm_pkg_manager, Ecosystem, NpmPkgManager, RubyCrawler};
 use socket_patch_core::manifest::operations::read_manifest;
 use socket_patch_core::manifest::schema::{PatchFileInfo, PatchManifest, PatchRecord};
 use socket_patch_core::patch::apply::{

diff --git a/crates/socket-patch-cli/src/commands/list.rs b/crates/socket-patch-cli/src/commands/list.rs
--- a/crates/socket-patch-cli/src/commands/list.rs
+++ b/crates/socket-patch-cli/src/commands/list.rs
@@ -431,7 +431,10 @@
                 detail: detail.clone(),
             });
         } else if !args.common.silent {
-            eprintln!("Warning: {}", crate::commands::rollback::capitalize_first(detail));
+            eprintln!(
+                "Warning: {}",
+                crate::commands::rollback::capitalize_first(detail)
+            );
         }
     }
     let vendor_state = crate::commands::vendor_state_lenient(&loaded.vendor, args.common.silent);
@@ -773,12 +776,18 @@
         let listings = HostedListing::from_pins(
             &[
                 pin("pkg:npm/[email protected]", &record.uuid),
-                pin("pkg:npm/[email protected]", "33333333-3333-4333-8333-333333333333"),
+                pin(
+                    "pkg:npm/[email protected]",
+                    "33333333-3333-4333-8333-333333333333",
+                ),
             ],
             Some(&legacy),
         );
         assert_eq!(listings[0].record, record);
-        assert_eq!(listings[1].record.uuid, "33333333-3333-4333-8333-333333333333");
+        assert_eq!(
+            listings[1].record.uuid,
+            "33333333-3333-4333-8333-333333333333"
+        );
         assert!(listings[1].record.vulnerabilities.is_empty());
         assert_eq!(listings[1].lockfiles, vec!["yarn.lock".to_string()]);
     }

diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs
--- a/crates/socket-patch-cli/src/commands/mod.rs
+++ b/crates/socket-patch-cli/src/commands/mod.rs
@@ -1,7 +1,7 @@
 pub mod apply;
 pub(crate) mod bun_preflight;
+pub(crate) mod composer_hints;
 pub(crate) mod context;
-pub(crate) mod composer_hints;
 pub(crate) mod fetch_stage;
 pub mod get;
 pub mod hosted_bundle;
@@ -9,11 +9,11 @@
 pub(crate) mod lock_cli;
 pub mod remove;
 pub mod repair;
-pub(crate) mod vendored_backend;
 pub mod rollback;
 pub mod scan;
 pub mod update;
 pub mod vendor;
+pub(crate) mod vendored_backend;
 pub mod vex;
 pub(crate) mod vex_consumed;
 pub(crate) mod vex_sources;
@@ -135,9 +135,11 @@
     common: &crate::args::GlobalArgs,
     root: &Path,
 ) -> socket_patch_core::patch::redirect::RedirectState {
-    hosted_state_from_pins(&socket_patch_core::patch::redirect::upstream::HostedPin::all(
-        &discover_wiring(common, root).await,
-    ))
+    hosted_state_from_pins(
+        &socket_patch_core::patch::redirect::upstream::HostedPin::all(
+            &discover_wiring(common, root).await,
+        ),
+    )
 }
 
 /// [`hosted_state_from_lockfiles`] over already-discovered pins. A purl
@@ -147,10 +149,8 @@
 ) -> socket_patch_core::patch::redirect::RedirectState {
     let mut state = socket_patch_core::patch::redirect::RedirectState::new();
     for pin in pins {
-        state
-            .records
-            .entry(pin.purl.clone())
-            .or_insert_with(|| socket_patch_core::manifest::schema::PatchRecord {
+        state.records.entry(pin.purl.clone()).or_insert_with(|| {
+            socket_patch_core::manifest::schema::PatchRecord {
                 uuid: pin.uuid.clone(),
                 exported_at: String::new(),
                 files: Default::default(),
@@ -158,7 +158,8 @@
                 description: String::new(),
                 license: String::new(),
                 tier: String::new(),
-            });
+            }
+        });
     }
     state
 }
@@ -185,4 +186,3 @@
         }
     }
 }
-

diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs
--- a/crates/socket-patch-cli/src/commands/remove.rs
+++ b/crates/socket-patch-cli/src/commands/remove.rs
@@ -17,9 +17,9 @@
     pin_before_hash_blobs, rollback_patches_inner, run_hosted_leg, sweep_failure,
     sweep_unused_artifacts, HostedLegOutcome, InnerSelection,
 };
-use crate::commands::vendored_backend::{RevertedEntry, VendorRevertStep, VendoredBackend};
 use crate::args::{apply_env_toggles, GlobalArgs};
 use crate::commands::lock_cli::acquire_or_emit;
+use crate::commands::vendored_backend::{RevertedEntry, VendorRevertStep, VendoredBackend};
 use crate::json_envelope::{Command, Envelope, EnvelopeError, PatchAction, PatchEvent, Status};
 use crate::ui::plural;
 

diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs
--- a/crates/socket-patch-cli/src/commands/rollback.rs
+++ b/crates/socket-patch-cli/src/commands/rollback.rs
@@ -10,13 +10,13 @@
 };
 use socket_patch_core::manifest::schema::{PatchFileInfo, PatchManifest, PatchRecord};
 use socket_patch_core::patch::apply::select_installed_variants;
+use socket_patch_core::patch::redirect::upstream::HostedPin;
 use socket_patch_core::patch::rollback::{
     cannot_rollback_error, rollback_package_patch, verify_file_rollback, RollbackResult,
     VerifyRollbackResult, VerifyRollbackStatus,
 };
 use socket_patch_core::telemetry::{track_patch_rollback_failed, track_patch_rolled_back};
 use socket_patch_core::utils::purl::{patch_matches, strip_purl_qualifiers};
-use socket_patch_core::patch::redirect::upstream::HostedPin;
 use socket_patch_core::vendor::{purl_keys_cover, RevertOpts, VendorState};
 use std::collections::{HashMap, HashSet};
 use std::path::{Path, PathBuf};
@@ -1026,7 +1026,8 @@
             .iter()
             .map(|(code, detail)| (code.to_string(), detail.clone())),
     );
-    out.edited_files.extend(outcome.reverted_files.iter().cloned());
+    out.edited_files
+        .extend(outcome.reverted_files.iter().cloned());
     let unwound: Vec<_> = vlt_targets
         .into_iter()
         .filter(|t| out.reverted.iter().any(|p| p == &t.purl))
@@ -1170,7 +1171,11 @@
             } else if !args.common.silent {
                 println!(
                     "{} the pre-v5 hosted ledger {}: no lockfile pins a hosted patch.",
-                    if args.common.dry_run { "Would remove" } else { "Removed" },
+                    if args.common.dry_run {
+                        "Would remove"
+                    } else {
+                        "Removed"
+                    },
                     socket_patch_core::patch::redirect::REDIRECT_STATE_REL
                 );
             }

diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs
--- a/crates/socket-patch-cli/src/commands/scan/hosted.rs
+++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs
@@ -934,7 +934,8 @@
             socket_patch_core::utils::fs::read_regular_to_string_sync(path).ok()
         })
     };
-    let rewrite_options = || RewriteOptions {
+    let rewrite_options = || {
+        RewriteOptions {
         dry_run: common.dry_run,
         targets_pipenv_lock,
         pipenv_major,
@@ -946,6 +947,7 @@
         npm_allow_remote_config: !common.no_npm_allow_remote_config,
         npm_outer: &npm_outer,
         blocking: true,
+    }
     };
     // The rollout gate plans again without its deferred rows: keep what
     // the second pass needs.
@@ -2171,13 +2173,19 @@
 /// artifacts, then verify with `vex`. After a vendored→hosted takeover
 /// (`vendored_removed`) the commit also has to carry the deleted vendored
 /// ledger entries and artifacts.
-fn format_next_steps(files: &[String], edits: &[socket_patch_core::patch::redirect::FileEdit], vendored_removed: bool) -> Vec<String> {
+fn format_next_steps(
+    files: &[String],
+    edits: &[socket_patch_core::patch::redirect::FileEdit],
+    vendored_removed: bool,
+) -> Vec<String> {
     if files.is_empty() && !vendored_removed {
         return Vec::new();
     }
     let mut commit: Vec<String> = Vec::new();
     if vendored_removed {
-        commit.push(".socket/vendor/ (the removed vendored ledger entries and artifacts)".to_string());
+        commit.push(
+            ".socket/vendor/ (the removed vendored ledger entries and artifacts)".to_string(),
+        );
     }
     commit.extend(files.iter().cloned());
     let npm = files
@@ -4162,19 +4170,43 @@
         use super::npm_allow_remote_one_line;
         let hosts = ["patch.socket.dev"];
         let cases = [
-            (npm_allow_remote_configured_detail(&hosts, true, false), "Note: set"),
-            (npm_allow_remote_configured_detail(&hosts, false, false), "Note: set"),
-            (npm_allow_remote_configured_detail(&hosts, true, true), "Note: would set"),
-            (npm_allow_remote_already_detail(&hosts), "Note: .npmrc already"),
-            (npm_allow_remote_user_set_detail(&hosts, "none"), "Warning: npm >=12"),
-            (npm_allow_remote_env_set_detail(&hosts, "npm_config_allow_remote", "none"), "Warning: npm >=12"),
+            (
+                npm_allow_remote_configured_detail(&hosts, true, false),
+                "Note: set",
+            ),
+            (
+                npm_allow_remote_configured_detail(&hosts, false, false),
+                "Note: set",
+            ),
+            (
+                npm_allow_remote_configured_detail(&hosts, true, true),
+                "Note: would set",
+            ),
+            (
+                npm_allow_remote_already_detail(&hosts),
+                "Note: .npmrc already",
+            ),
+            (
+                npm_allow_remote_user_set_detail(&hosts, "none"),
+                "Warning: npm >=12",
+            ),
+            (
+                npm_allow_remote_env_set_detail(&hosts, "npm_config_allow_remote", "none"),
+                "Warning: npm >=12",
+            ),
             (npm_allow_remote_manual_detail(&hosts), "Warning: npm >=12"),
-            (npm_allow_remote_unreadable_detail(&hosts, "is a symlink"), "Warning: npm >=12"),
+            (
+                npm_allow_remote_unreadable_detail(&hosts, "is a symlink"),
+                "Warning: npm >=12",
+            ),
         ];
         for (detail, start) in cases {
             let line = npm_allow_remote_one_line(&detail);
             assert!(line.starts_with(start), "{line}");
-            assert!(!line.contains('\n') && line.ends_with("(details: --verbose)."), "{line}");
+            assert!(
+                !line.contains('\n') && line.ends_with("(details: --verbose)."),
+                "{line}"
+            );
         }
     }
 }

diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs
--- a/crates/socket-patch-cli/src/commands/scan/mod.rs
+++ b/crates/socket-patch-cli/src/commands/scan/mod.rs
@@ -35,17 +35,17 @@
 
 use super::get::{download_and_apply_patches_with, DownloadParams, DownloadRun};
 
+use self::policy::{load_invocation_policy, InvocationPolicy, PolicyLoadError, ScanPolicy};
 pub use self::socket_yml_args::{SocketYmlArgs, MIN_SEVERITY_ENV};
-use self::policy::{load_invocation_policy, InvocationPolicy, PolicyLoadError, ScanPolicy};
 
 mod discovery;
 mod gc;
 pub(crate) mod hosted;
 pub(crate) mod policy;
-mod socket_yml_args;
 pub(crate) mod render;
 pub(crate) mod rollout;
 pub mod rollout_args;
+mod socket_yml_args;
 pub(crate) mod vendor_flow;
 
 use self::discovery::{
@@ -65,13 +65,13 @@
 pub(crate) use self::hosted::boxed_run_redirect_selected;
 use self::hosted::run_redirect;
 pub(crate) use self::hosted::{vlt_rollback_heal, vlt_takeover_heal};
-pub(crate) use self::vendor_flow::{
-    boxed_vendor_step, preview_vendor_json, print_dry_run_refusals, VendorStep,
-};
 use self::vendor_flow::{
     boxed_vendor_interactive_path, boxed_vendor_json_path, fold_vendored_skips_into_apply,
     partition_skipped_selected,
 };
+pub(crate) use self::vendor_flow::{
+    boxed_vendor_step, preview_vendor_json, print_dry_run_refusals, VendorStep,
+};
 
 /// Packages per batch request on the authenticated API when `--batch-size`
 /// is not given: the server's own per-request maximum
@@ -318,11 +318,7 @@
     /// `requests`), or a purl with or without its version
     /// (`pkg:npm/lodash`, `pkg:pypi/[email protected]`). Repeat the flag or
     /// separate with commas
-    #[arg(
-        long = "package",
-        env = "SOCKET_SCAN_PACKAGES",
-        value_delimiter = ','
-    )]
+    #[arg(long = "package", env = "SOCKET_SCAN_PACKAGES", value_delimiter = ',')]
     pub packages: Vec<String>,
 
     /// On a successful scan, also generate an OpenVEX 0.2.0 document.
@@ -500,9 +496,10 @@
     telemetry.flush().await;
     let error_count = failures.len();
     if error_count > 0 && error_count == packages.len() {
-        let err = failures
-            .last()
-            .map_or_else(|| "all patch-detail queries failed".to_string(), |(_, e)| e.clone());
+        let err = failures.last().map_or_else(
+            || "all patch-detail queries failed".to_string(),
+            |(_, e)| e.clone(),
+        );
         let message = format!("all {error_count} patch-detail queries failed: {err}");
         if detail_error_line {
             eprintln!("{}", render::fetch_details_failed(&failures));
@@ -568,7 +565,11 @@
     packages: &[BatchPackagePatches],
     result: Option<&mut serde_json::Value>,
 ) -> Vec<rollout::Row> {
-    let failed: Vec<String> = discovered.failed.iter().map(|(purl, _)| purl.clone()).collect();
+    let failed: Vec<String> = discovered
+        .failed
+        .iter()
+        .map(|(purl, _)| purl.clone())
+        .collect();
     stage.incomplete = rollout::lookup_incomplete(&recorded.index, &failed, batch_failed);
     let rows = rollout::classify(&discovered.offers, &recorded.index, &stage.project);
     if let Some(result) = result {
@@ -1317,7 +1318,8 @@
         let joined = cwd.join(raw);
         if raw.contains(['*', '?', '[']) {
             let pattern = joined.to_string_lossy().into_owned();
-            let matches = glob::glob(&pattern).map_err(|e| format!("invalid path pattern `{raw}`: {e}"))?;
+            let matches =
+                glob::glob(&pattern).map_err(|e| format!("invalid path pattern `{raw}`: {e}"))?;
             let before = dirs.len();
             dirs.extend(
                 matches
@@ -1390,7 +1392,10 @@
     }
     // One budget per invocation (§5.2): the directories spend it in sorted
     // order, and a package admitted in one is admitted free in the next.
-    let configured = match args.rollout.resolve_from_env(invocation.policy.max_new_patches()) {
+    let configured = match args
+        .rollout
+        .resolve_from_env(invocation.policy.max_new_patches())
+    {
         Ok(max) => max,
         Err(message) => {
             eprintln!("Error: {message}");
@@ -1491,7 +1496,10 @@
     // error.
     let configured_cap = match args.rollout.carry.as_ref() {
         Some(carry) => carry.lock().configured,
-        None => match args.rollout.resolve_from_env(invocation.policy.max_new_patches()) {
+        None => match args
+            .rollout
+            .resolve_from_env(invocation.policy.max_new_patches())
+        {
             Ok(max) => max,
             Err(message) => {
                 eprintln!("Error: {message}");
@@ -1499,11 +1507,8 @@
             }
         },
     };
-    let mut stage = rollout::Stage::new(
-        configured_cap,
-        args.rollout.carry.clone(),
-        &args.common.cwd,
-    );
+    let mut stage =
+        rollout::Stage::new(configured_cap, args.rollout.carry.clone(), &args.common.cwd);
 
     // Strict airgap (CLI_CONTRACT.md `--offline`): scan's patch discovery
     // is remote data, so refuse before the crawl and before the API client
@@ -1689,8 +1694,11 @@
         .filter(|pkg| args.common.purl_ecosystem_selected(&pkg.purl))
         .collect();
 
-    let package_specs: Vec<&String> =
-        args.packages.iter().filter(|s| !s.trim().is_empty()).collect();
+    let package_specs: Vec<&String> = args
+        .packages
+        .iter()
+        .filter(|s| !s.trim().is_empty())
+        .collect();
     let filtered_crawled: Vec<_> = if package_specs.is_empty() {
         filtered_crawled
     } else {
@@ -1828,13 +1836,12 @@
                 // `redirectState` rides the empty-discovery envelope too
                 // (same rule as the ≥1-package path). `wiringLive` is empty
                 // by construction: this run covered zero packages.
-                let redirect_state = (!args.common.is_global()).then_some(
-                    crate::commands::hosted_state_from_pins(
+                let redirect_state =
+                    (!args.common.is_global()).then_some(crate::commands::hosted_state_from_pins(
                         &socket_patch_core::patch::redirect::upstream::HostedPin::all(
                             ctx.discovery().await,
                         ),
-                    ),
-                );
+                    ));
                 if let Some(state) = redirect_state_json(redirect_state.as_ref(), &[]) {
                     result["redirectState"] = state;
                 }
@@ -2190,7 +2197,8 @@
         // A report-only run selects nothing, but a severity floor or
         // `enabled: false` still hides candidates; report them like the
         // human arm does (the detail fetch runs only then).
-        if !apply && !vendor && policy.reports_selection() && !all_packages_with_patches.is_empty() {
+        if !apply && !vendor && policy.reports_selection() && !all_packages_with_patches.is_empty()
+        {
             if let Err((code, message)) = discover_selected(
                 &api_client,
                 &all_packages_with_patches,
@@ -2483,12 +2491,7 @@
                     &all_packages_with_patches,
                     None,
                 );
-                updates = offer_updates(
-                    &rows,
-                    &discovered,
-                    &recorded,
-                    &all_packages_with_patches,
-                );
+                updates = offer_updates(&rows, &discovered, &recorded, &all_packages_with_patches);
                 rows
             }
             // `discover_selected` already printed the failure to stderr.
@@ -2950,14 +2953,20 @@
             dirs.iter()
                 .map(|(d, explicit)| {
                     (
-                        d.strip_prefix(tmp.path()).unwrap().to_string_lossy().replace('\\', "/"),
+                        d.strip_prefix(tmp.path())
+                            .unwrap()
+                            .to_string_lossy()
+                            .replace('\\', "/"),
                         *explicit,
                     )
                 })
                 .collect()
         };
-        let got = project_dirs(tmp.path(), &["apps/*".into(), "libs/core".into(), "apps/web".into()])
-            .unwrap();
+        let got = project_dirs(
+            tmp.path(),
+            &["apps/*".into(), "libs/core".into(), "apps/web".into()],
+        )
+        .unwrap();
         // Named literally = explicit (also when a glob matches it too).
         assert_eq!(
             rel(got),

diff --git a/crates/socket-patch-cli/src/commands/scan/policy.rs b/crates/socket-patch-cli/src/commands/scan/policy.rs
--- a/crates/socket-patch-cli/src/commands/scan/policy.rs
+++ b/crates/socket-patch-cli/src/commands/scan/policy.rs
@@ -11,9 +11,9 @@
 use socket_patch_core::api::types::PatchSearchResult;
 use socket_patch_core::manifest::schema::PatchManifest;
 use socket_patch_core::policy::{
-    canon, find_repo_root_with_warnings, policy_block, FilteredEntry, RetainedEntry, patch_severity_order, repo_relative_checked, sanitize, severity_name,
-    DiskPolicyFs, FilterReason, Offers, PolicyError, PolicySource, PolicyWarning, Root, SelectionPolicy,
-    PATCHES_DISABLED,
+    canon, find_repo_root_with_warnings, patch_severity_order, policy_block, repo_relative_checked,
+    sanitize, severity_name, DiskPolicyFs, FilterReason, FilteredEntry, Offers, PolicyError,
+    PolicySource, PolicyWarning, RetainedEntry, Root, SelectionPolicy, PATCHES_DISABLED,
 };
 use socket_patch_core::utils::purl::normalize_purl;
 
@@ -42,12 +42,18 @@
 /// Load the policy for `args` (4.5): `--global` scans have no repo and read
 /// no file; everything else reads the repo root's socket.yml.
 pub(crate) fn load_invocation_policy(args: &ScanArgs) -> Result<InvocationPolicy, PolicyLoadError> {
-    let overrides = args.socket_yml.overrides().map_err(PolicyLoadError::Usage)?;
+    let overrides = args
+        .socket_yml
+        .overrides()
+        .map_err(PolicyLoadError::Usage)?;
     let cwd = std::fs::canonicalize(&args.common.cwd).unwrap_or_else(|_| args.common.cwd.clone());
     if args.common.is_global() {
-        let policy = SelectionPolicy::load(&socket_patch_core::policy::MemoryPolicyFs::default(), &overrides)
-            .map_err(PolicyLoadError::Policy)?
-            .0;
+        let policy = SelectionPolicy::load(
+            &socket_patch_core::policy::MemoryPolicyFs::default(),
+            &overrides,
+        )
+        .map_err(PolicyLoadError::Policy)?
+        .0;
         return Ok(InvocationPolicy {
             policy,
             repo_root: cwd,
@@ -56,8 +62,8 @@
         });
     }
     let (repo_root, mut warnings) = find_repo_root_with_warnings(&cwd);
-    let (policy, load_warnings) =
-        SelectionPolicy::load(&DiskPolicyFs::new(&repo_root), &overrides).map_err(PolicyLoadError::Policy)?;
+    let (policy, load_warnings) = SelectionPolicy::load(&DiskPolicyFs::new(&repo_root), &overrides)
+        .map_err(PolicyLoadError::Policy)?;
     warnings.extend(load_warnings);
     Ok(InvocationPolicy {
         policy,
@@ -138,7 +144,12 @@
 
 impl ScanPolicy {
     /// The policy for the project rooted at `root_dir`.
-    pub(crate) fn for_root(invocation: &InvocationPolicy, root_dir: &Path, explicit: bool, global: bool) -> Self {
+    pub(crate) fn for_root(
+        invocation: &InvocationPolicy,
+        root_dir: &Path,
+        explicit: bool,
+        global: bool,
+    ) -> Self {
         let root_dir = std::fs::canonicalize(root_dir).unwrap_or_else(|_| root_dir.to_path_buf());
         let project = repo_relative_checked(&invocation.repo_root, &root_dir).unwrap_or_default();
         let root_verdict = if global {
@@ -171,7 +182,9 @@
                 severity: None,
             });
         }
-        let announce_warnings = !invocation.warned.swap(true, std::sync::atomic::Ordering::Relaxed);
+        let announce_warnings = !invocation
+            .warned
+            .swap(true, std::sync::atomic::Ordering::Relaxed);
         Self {
             policy: invocation.policy.clone(),
             warnings,
@@ -224,7 +237,10 @@
     /// exclude stays in the query (so `upgradeAvailable` can be reported)
     /// but joins the retained set, which never reaches a writer.
     pub(crate) fn admit_crawled(&self, purl: &str) -> bool {
-        let verdict = self.root_verdict.clone().and_then(|()| self.policy.admits_purl(purl));
+        let verdict = self
+            .root_verdict
+            .clone()
+            .and_then(|()| self.policy.admits_purl(purl));
         let reason = match verdict {
             Ok(()) => return true,
             Err(reason) => reason,
@@ -334,7 +350,8 @@
             // (not when a lower-ranked admitted patch simply wins).
             let top_withheld = self.policy.admits_severity(patch_severity_order(&group[0]));
             if let Err(reason) = top_withheld {
-                let upgrade_withheld = chosen.is_some() && chosen == recorded_at && recorded_at != Some(0);
+                let upgrade_withheld =
+                    chosen.is_some() && chosen == recorded_at && recorded_at != Some(0);
                 if chosen.is_none() || upgrade_withheld {
                     report.filtered.push(FilteredEntry {
                         purl: Some(canon(&purl)),
@@ -522,17 +539,20 @@
         let verdict = if !policy.enabled() {
             Err(FilterReason::Disabled)
         } else {
-            root_verdict.clone().and_then(|()| policy.admits_purl(purl)).and_then(|()| {
-                // The floor only hides a package when none of its patches pass.
-                match group
-                    .iter()
-                    .map(|p| policy.admits_severity(patch_severity_order(p)))
-                    .find(Result::is_ok)
-                {
-                    Some(ok) => ok,
-                    None => policy.admits_severity(patch_severity_order(group[0])),
-                }
-            })
+            root_verdict
+                .clone()
+                .and_then(|()| policy.admits_purl(purl))
+                .and_then(|()| {
+                    // The floor only hides a package when none of its patches pass.
+                    match group
+                        .iter()
+                        .map(|p| policy.admits_severity(patch_severity_order(p)))
+                        .find(Result::is_ok)
+                    {
+                        Some(ok) => ok,
+                        None => policy.admits_severity(patch_severity_order(group[0])),
+                    }
+                })
         };
         if let Err(reason) = verdict {
             out.push((

diff --git a/crates/socket-patch-cli/src/commands/scan/render.rs b/crates/socket-patch-cli/src/commands/scan/render.rs
--- a/crates/socket-patch-cli/src/commands/scan/render.rs
+++ b/crates/socket-patch-cli/src/commands/scan/render.rs
@@ -726,7 +726,10 @@
 
     #[test]
     fn report_only_hint_names_agent_mode() {
-        assert_eq!(report_only_hint()[0], "To apply these patches in place, run:");
+        assert_eq!(
+            report_only_hint()[0],
+            "To apply these patches in place, run:"
+        );
         assert!(report_only_hint()[1].contains("--mode agent"));
     }
 

diff --git a/crates/socket-patch-cli/src/commands/scan/rollout.rs b/crates/socket-patch-cli/src/commands/scan/rollout.rs
--- a/crates/socket-patch-cli/src/commands/scan/rollout.rs
+++ b/crates/socket-patch-cli/src/commands/scan/rollout.rs
@@ -4,8 +4,10 @@
 
 use std::collections::{BTreeMap, BTreeSet, HashSet};
 
-use socket_patch_core::rollout::{canonical_base_purl, severity_label, MaxNew, MaxNewSource, Recorded, RolloutPlan};
 pub(crate) use socket_patch_core::rollout::stage::*;
+use socket_patch_core::rollout::{
+    canonical_base_purl, severity_label, MaxNew, MaxNewSource, Recorded, RolloutPlan,
+};
 
 use super::discovery::UpdateInfo;
 
@@ -208,11 +210,11 @@
 mod tests {
     use super::*;
     use socket_patch_core::api::types::PatchSearchResult;
+    use socket_patch_core::api::types::VulnerabilityResponse;
     use socket_patch_core::manifest::schema::PatchManifest;
-    use std::path::Path;
-    use socket_patch_core::api::types::VulnerabilityResponse;
     use socket_patch_core::manifest::schema::PatchRecord;
     use std::collections::HashMap;
+    use std::path::Path;
 
     fn offer(purl: &str, uuid: &str, published: &str, severities: &[&str]) -> PatchSearchResult {
         PatchSearchResult {
@@ -357,13 +359,21 @@
         let stored = manifest(&[("pkg:composer/psr/[email protected]", "old")]);
         let recorded = RecordedIndex::new(Some(&stored), &[]);
         let offers = offers_from_results(
-            &[offer("pkg:composer/psr/[email protected]", "new", "2026-02-01T00:00:00Z", &["high"])],
+            &[offer(
+                "pkg:composer/psr/[email protected]",
+                "new",
+                "2026-02-01T00:00:00Z",
+                &["high"],
+            )],
             false,
         );
         let rows = classify(&offers, &recorded, "");
         let plan = socket_patch_core::rollout::plan_rollout(
             rows.into_iter().map(|row| row.candidate).collect(),
-            &MaxNew { value: Some(0), source: MaxNewSource::Flag },
+            &MaxNew {
+                value: Some(0),
+                source: MaxNewSource::Flag,
+            },
             false,
             &BTreeSet::new(),
         );

diff --git a/crates/socket-patch-cli/src/commands/scan/rollout_args.rs b/crates/socket-patch-cli/src/commands/scan/rollout_args.rs
--- a/crates/socket-patch-cli/src/commands/scan/rollout_args.rs
+++ b/crates/socket-patch-cli/src/commands/scan/rollout_args.rs
@@ -1,7 +1,6 @@
 //! `scan --max-new-patches` (see the rollout guide,
 //! `docs/configuration.md#gradual-rollout`).
 
-
 use clap::Args;
 pub(crate) use socket_patch_core::rollout::stage::RolloutCarry;
 use socket_patch_core::rollout::{resolve_max_new, MaxNew};
@@ -77,7 +76,6 @@
     }
 }
 
-
 #[cfg(test)]
 mod tests {
     use super::*;

diff --git a/crates/socket-patch-cli/tests/apply/apply_network.rs b/crates/socket-patch-cli/tests/apply/apply_network.rs
--- a/crates/socket-patch-cli/tests/apply/apply_network.rs
+++ b/crates/socket-patch-cli/tests/apply/apply_network.rs
@@ -940,7 +940,10 @@
         "a legacy package archive must not cover the patch; stdout={stdout}\nstderr={stderr}"
     );
     let content = std::fs::read(tmp.path().join("node_modules/pkgcache/index.js")).unwrap();
-    assert_eq!(content, before, "the file must not be patched from the legacy archive");
+    assert_eq!(
+        content, before,
+        "the file must not be patched from the legacy archive"
+    );
 
     let requests = mock.received_requests().await.unwrap_or_default();
     let blob_path = format!("/v0/orgs/{ORG_SLUG}/patches/blob/{after_hash}");
@@ -1043,10 +1046,7 @@
         v["summary"]["applied"], 1,
         "the drifted nested copy must be warn-overwritten.\nstdout={v:#}"
     );
-    assert_eq!(
-        v["summary"]["failed"], 0,
-        "no copy may fail.\nstdout={v:#}"
-    );
+    assert_eq!(v["summary"]["failed"], 0, "no copy may fail.\nstdout={v:#}");
 
     // The nested copy's blob was fetched on demand…
     let requests = mock.received_requests().await.unwrap();

diff --git a/crates/socket-patch-cli/tests/apply/in_process_gem_config_warning.rs b/crates/socket-patch-cli/tests/apply/in_process_gem_config_warning.rs
--- a/crates/socket-patch-cli/tests/apply/in_process_gem_config_warning.rs
+++ b/crates/socket-patch-cli/tests/apply/in_process_gem_config_warning.rs
@@ -201,7 +201,9 @@
         "non-silent stderr must carry the {CODE} warning; got:\n{stderr}"
     );
     assert_eq!(
-        stderr.matches("Warning: bundler app config BUNDLE_PATH").count(),
+        stderr
+            .matches("Warning: bundler app config BUNDLE_PATH")
+            .count(),
         1,
         "exactly ONE warning line (not one per discovery call); got:\n{stderr}"
     );

diff --git a/crates/socket-patch-cli/tests/cli/covgap_output.rs b/crates/socket-patch-cli/tests/cli/covgap_output.rs
--- a/crates/socket-patch-cli/tests/cli/covgap_output.rs
+++ b/crates/socket-patch-cli/tests/cli/covgap_output.rs
@@ -168,9 +168,8 @@
         .expect("spawn socket-patch in PTY");
     drop(pair.slave);
 
-    let reader_handle = crate::pty_io::PtyOutput::spawn(
-        pair.master.try_clone_reader().expect("clone reader"),
-    );
+    let reader_handle =
+        crate::pty_io::PtyOutput::spawn(pair.master.try_clone_reader().expect("clone reader"));
 
     // Watchdog: detached kill after `timeout`; a no-op if the child exits
     // naturally first.
@@ -261,7 +260,10 @@
         "\n",
         Duration::from_secs(15),
     );
-    assert_eq!(code, 0, "remove with bare Enter must succeed; got: {output}");
+    assert_eq!(
+        code, 0,
+        "remove with bare Enter must succeed; got: {output}"
+    );
     // The interactive confirm MUST have run — otherwise this test passes
     // vacuously against a regression that drops the TTY gate and
     // auto-proceeds. Match the distinctive prompt verbatim (the loose

diff --git a/crates/socket-patch-cli/tests/cli/interactive_prompts_e2e.rs b/crates/socket-patch-cli/tests/cli/interactive_prompts_e2e.rs
--- a/crates/socket-patch-cli/tests/cli/interactive_prompts_e2e.rs
+++ b/crates/socket-patch-cli/tests/cli/interactive_prompts_e2e.rs
@@ -112,9 +112,8 @@
     // closed. The previous design used a chunked read+mpsc loop
     // because it interleaved with a try_wait poll; the simplified
     // design serializes wait → drop master → read_to_end joins.
-    let reader_handle = crate::pty_io::PtyOutput::spawn(
-        pair.master.try_clone_reader().expect("clone reader"),
-    );
+    let reader_handle =
+        crate::pty_io::PtyOutput::spawn(pair.master.try_clone_reader().expect("clone reader"));
 
     // Watchdog: detach a thread that kills the child after `timeout`.
     // The cloned ChildKiller is independent of the main `child`

diff --git a/crates/socket-patch-cli/tests/cli_config_fallback.rs b/crates/socket-patch-cli/tests/cli_config_fallback.rs
--- a/crates/socket-patch-cli/tests/cli_config_fallback.rs
+++ b/crates/socket-patch-cli/tests/cli_config_fallback.rs
@@ -59,8 +59,7 @@
     let mut cmd = Command::new(BINARY);
     // Human mode: core's proxy advisory (the oracle below) is muted under
     // `--json`/`--silent`.
-    cmd.args(["scan", "-e", "npm", "--cwd"])
-        .arg(project);
+    cmd.args(["scan", "-e", "npm", "--cwd"]).arg(project);
     for (key, _) in std::env::vars_os() {
... diff truncated: showing 800 of 6421 lines

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

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
Comment thread crates/socket-patch-core/src/crawlers/python_crawler.rs Resolved

Uh oh!

There was an error while loading. Please reload this page.

Comment thread crates/socket-patch-core/src/crawlers/python_crawler.rs Resolved

Uh oh!

There was an error while loading. Please reload this page.

@claude
Honor PDM_PYTHON; let uv and Poetry locks win
60dfb81
PDM_PYTHON outranks .pdm-python, so discovery now follows it too. A
project with uv.lock or poetry.lock is installed by uv or Poetry
(they drive hosted installs ahead of pdm.lock), so a leftover PDM
record or __pypackages__ there no longer decides the env. Found by
Bugbot review.

Assisted-by: Claude Code:claude-opus-5-5
@mikolalysenko

Mikola Lysenko (mikolalysenko) commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

BugBot review


Generated by Claude Code

cursor[bot] reacted with thumbs up emoji
All reactions
  • 👍 1 reaction

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

cursor[bot]
cursor Bot reviewed Oct 2, 2026
View reviewed changes

@cursor cursor Bot left a comment •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown

There was a problem hiding this comment.

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
@mikolalysenko Mikola Lysenko (mikolalysenko) added the Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review label Oct 2, 2026
@mikolalysenko

Mikola Lysenko (mikolalysenko) commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

[agent] Ready for review at head 60dfb81.

  • CI: 340/340 check runs completed green (success/skipped) on 60dfb81; branch is 0 behind main, no conflicts.
  • Bugbot: reviewed 60dfb81 — no new issues. Both earlier findings on python_crawler.rs (lines ~425, ~485) were addressed and their threads are resolved.
  • Reviewer focus: the new uv UV_PROJECT_ENVIRONMENT and PDM .pdm-python / __pypackages__ env discovery paths in crates/socket-patch-core/src/crawlers/python_crawler.rs.

Slack announcement: not sent this run (Slack send tool unavailable to the agent); will retry next run.


Generated by Claude Code

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@mikolalysenko Mikola Lysenko (mikolalysenko) mentioned this pull request Oct 2, 2026
Pipenv venv discovery ignores the project's .env, so a PIPENV_CUSTOM_VENV_NAME or WORKON_HOME set there leaves the Pipenv venv unpatched, patches the system Python instead, and VEX attests not_affected #546
Closed
Tanmay182003
Tanmay Singla (Tanmay182003) approved these changes Oct 2, 2026
View reviewed changes
@mikolalysenko

Mikola Lysenko (mikolalysenko) commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

Reviewed 60dfb81867f5de8f88132f20a829a4c18989dc16. Recommendation: fix before merging.

  • [P2] Exclude lockless Poetry projects from the uv override — crates/socket-patch-core/src/crawlers/python_crawler.rs:530. [tool.poetry] / poetry.toml already identify Poetry projects, but other_manager ignores them. With no poetry.lock, an ambient UV_PROJECT_ENVIRONMENT now wins over the installed Poetry .venv, so apply and stale-install/VEX checks inspect an unrelated environment. Reuse manager detection before assuming a lockless pyproject belongs to uv.
  • [P2] Resolve PDM virtualenv precedence before PEP 582 — crates/socket-patch-core/src/crawlers/python_crawler.rs:466. With PDM_IGNORE_SAVED_PYTHON=1 (or no saved interpreter), any leftover __pypackages__ now wins over VIRTUAL_ENV. PDM checks active/project virtualenvs first when python.use_venv is enabled. A migrated project can patch its stale tree and leave the active environment untouched.

Validation: the existing crawler suite passed (57 tests). Two focused review tests for these layouts both pass against the merge-base crawler and fail on this head, returning the unrelated uv env / stale PEP 582 directory respectively. Full workspace/platform matrix not rerun.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@claude
Respect Poetry and PDM venv order in env probes
5ecac79
A Poetry project without poetry.lock ([tool.poetry] or poetry.toml)
is Poetry's, so an ambient UV_PROJECT_ENVIRONMENT no longer takes it
over. With no saved PDM interpreter, PDM uses an activated or
in-project venv before PEP 582, so __pypackages__ is now the last
resort instead of beating VIRTUAL_ENV and ./.venv. Found in review.

Assisted-by: Claude Code:claude-opus-5-5
@mikolalysenko

Mikola Lysenko (mikolalysenko) commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

[agent] Both review findings are confirmed and fixed in 5ecac79.

  • [P2] Lockless Poetry vs the uv override: the uv probe now treats poetry.toml and a [tool.poetry] table in pyproject.toml as Poetry's, along with the existing lock and record markers. So an ambient UV_PROJECT_ENVIRONMENT no longer takes over a lockless Poetry project, and its .venv stays the env. New assertions are in uv_project_environment_is_the_project_env.
  • [P2] PDM venv precedence before PEP 582: step 0 now handles PDM only when there is an interpreter to follow (PDM_PYTHON, or the saved one unless PDM_IGNORE_SAVED_PYTHON). With no interpreter, the generic probes decide (VIRTUAL_ENV, then ./.venv, matching PDM's active/project venv order), and __pypackages__ is only the last resort when they find nothing (PDM 1.x). A saved base interpreter still means PEP 582. New assertions in pdm_pep582_pypackages_is_the_project_env cover an activated venv, an in-project .venv, PDM_IGNORE_SAVED_PYTHON with a .venv, and a saved base interpreter.

Validation on 5ecac79: core python_crawler tests 57/57, plus the CLI suites in_process_python_envs 17/17, in_process_redirect_pdm 5/5 and scan 104/104. cargo clippy --workspace --all-features -- -D warnings is clean. The PDM docs now describe the new order.


Generated by Claude Code

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@mikolalysenko

Mikola Lysenko (mikolalysenko) commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

BugBot review


Generated by Claude Code

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@mikolalysenko

Mikola Lysenko (mikolalysenko) commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

Re-reviewed 5ecac794d24527c40ec14ba2876c9d46ab9e948e. Both earlier findings are fixed, and both independent regression probes now pass. One follow-up regression remains before merge:

  • [P2] Preserve lockless PDM ownership before probing the uv override — crates/socket-patch-core/src/crawlers/python_crawler.rs:541. The PEP 582 fallback now runs after generic discovery, but the intervening uv probe still does not recognize [tool.pdm] / .pdm.toml. A lockless PDM project with no saved interpreter and an installed __pypackages__/3.12/lib is therefore redirected to an unrelated ambient UV_PROJECT_ENVIRONMENT. This worked on 60dfb818; the same focused test now returns the uv environment instead of the PDM package directory. Reuse PDM project detection in the uv ownership check, retaining explicit uv.lock precedence.

Validation: 57 crawler tests passed, both original reviewer probes passed, and the new lockless-PDM probe passes on 60dfb818 but fails on this head. Full workspace/platform matrix not rerun. Recommendation: fix this remaining case before merging.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@claude
Keep lockless PDM projects out of the uv probe
2a22d4e
A PDM project with no lock and no saved interpreter ([tool.pdm] or
.pdm.toml) is still PDM's, so an ambient UV_PROJECT_ENVIRONMENT no
longer claims it before the PEP 582 fallback; uv.lock still wins. A
[tool.pdm.build] table alone (the pdm-backend build backend) no longer
marks a project as PDM-managed. Found in review.

Assisted-by: Claude Code:claude-opus-5-5
@mikolalysenko

Mikola Lysenko (mikolalysenko) commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

[agent] Confirmed and fixed in 2a22d4e: lockless PDM projects now keep ownership before the uv probe runs.

  • The uv ownership check now reuses is_pdm_project (pdm.lock, .pdm.toml, or [tool.pdm]), so an ambient UV_PROJECT_ENVIRONMENT no longer claims a lockless PDM project ahead of the PEP 582 fallback. Explicit uv.lock still wins.
  • is_pdm_project no longer treats a [tool.pdm.build] table on its own as PDM-managed, since that only configures the pdm-backend build backend, which uv projects use too. Without this, the change above would have dropped UV_PROJECT_ENVIRONMENT for those uv projects.
  • New assertions: pdm_pep582_pypackages_is_the_project_env covers a lockless [tool.pdm] project with __pypackages__/3.11/lib plus an ambient uv env, which now resolves to the PDM lib dir. uv_project_environment_is_the_project_env covers lockless [tool.pdm] (the uv env is ignored) and [tool.pdm.build]-only (the uv env is used).

Validation on 2a22d4e: core python_crawler 57/57, in_process_python_envs 17/17, in_process_redirect_pdm 5/5, scan 104/104. cargo clippy --workspace --all-features -- -D warnings is clean.


Generated by Claude Code

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@mikolalysenko

Mikola Lysenko (mikolalysenko) commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

BugBot review


Generated by Claude Code

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

cursor[bot]
cursor Bot reviewed Oct 2, 2026
View reviewed changes

@cursor cursor Bot left a comment •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown

There was a problem hiding this comment.

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
Comment thread crates/socket-patch-core/src/crawlers/python_crawler.rs Resolved

Uh oh!

There was an error while loading. Please reload this page.

Comment thread crates/socket-patch-core/src/crawlers/python_crawler.rs Resolved

Uh oh!

There was an error while loading. Please reload this page.

@claude
Honor PDM_IGNORE_ACTIVE_VENV in env discovery
c1710a9
PDM skips an activated venv when PDM_IGNORE_ACTIVE_VENV is set (CI,
tox), so for a PDM project the VIRTUAL_ENV probe now does too, and
the project venv or __pypackages__ decides. Found by Bugbot.

Assisted-by: Claude Code:claude-opus-5-5
@mikolalysenko

Mikola Lysenko (mikolalysenko) commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

BugBot review


Generated by Claude Code

cursor[bot] reacted with thumbs up emoji
All reactions
  • 👍 1 reaction

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

cursor[bot]
cursor Bot reviewed Oct 2, 2026
View reviewed changes

@cursor cursor Bot left a comment •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown

There was a problem hiding this comment.

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
@mikolalysenko

Mikola Lysenko (mikolalysenko) commented Oct 2, 2026 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown
Collaborator Author

Closing follow-up for aabaf5b59cca24ca7d107fe437b69baf81aa5b0e: the remaining false-valued PDM flag finding is fixed; code review is clear, pending CI. The narrow final commit routes both PDM flags through boolean parsing that matches PDM's parser; I also verified that PDM uses it for both flags. Added discovery assertions cover false values for the active environment and saved interpreter.

Validation scope: the preceding reviewed heads passed 57 crawler tests and all three earlier independent precedence probes. The false-flag reproduction failed at db9160b3, establishing the final change's cause. I reviewed the complete final single-file delta and its regression assertions against upstream semantics; I did not rerun the local suite for this last commit. Let the checks at this SHA finish before merging. This supersedes the earlier changes-needed recommendation.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@claude
Merge main into agent/fix-python-manager-project-env
db9160b
Resolve the python_crawler.rs conflict with the Poetry env selection
fix (#527): keep main's poetry_active_prefix for VIRTUAL_ENV and add
the PDM_IGNORE_ACTIVE_VENV gate on top.

Assisted-by: Claude Code:claude-opus-5-5
@mikolalysenko

Mikola Lysenko (mikolalysenko) commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

BugBot review


Generated by Claude Code

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@claude
Parse PDM boolean env settings like PDM does
aabaf5b
PDM treats 0, false and no (any case) as false for
PDM_IGNORE_ACTIVE_VENV and PDM_IGNORE_SAVED_PYTHON. Discovery treated
any non-empty value as true, so PDM_IGNORE_ACTIVE_VENV=0 skipped the
activated venv PDM uses. Found in review.

Assisted-by: Claude Code:claude-opus-5-5
@mikolalysenko

Mikola Lysenko (mikolalysenko) commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

[agent] Confirmed and fixed in aabaf5b: false-valued PDM_IGNORE_ACTIVE_VENV is now parsed the way PDM parses it.

  • A new pdm_env_flag helper mirrors PDM's ensure_boolean: the variable is set, non-empty, and not false / no / 0 (any case). It now backs both PDM_IGNORE_ACTIVE_VENV and PDM_IGNORE_SAVED_PYTHON, which had the same non-empty-string bug.
  • New assertions:
    • pdm_pep582_pypackages_is_the_project_env: PDM_IGNORE_ACTIVE_VENV set to 0, false, No or empty keeps the activated venv. 1 still skips it.
    • pdm_saved_interpreter_venv_is_the_project_env: PDM_IGNORE_SAVED_PYTHON set to 0, false or NO keeps .pdm-python.

Validation on aabaf5b, which includes the main merge db9160b: core python_crawler 60/60, in_process_python_envs 17/17, in_process_redirect_pdm 5/5 and scan 104/104. cargo clippy --workspace --all-features -- -D warnings is clean.


Generated by Claude Code

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@mikolalysenko

Mikola Lysenko (mikolalysenko) commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

BugBot review


Generated by Claude Code

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

cursor[bot]
cursor Bot reviewed Oct 2, 2026
View reviewed changes

@cursor cursor Bot left a comment •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown

There was a problem hiding this comment.

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.

Autofix Details

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

  • ✅ Fixed: Base PDM_PYTHON skips project venv
    • Restructured the logic to return None when PDM_PYTHON points to a base interpreter, allowing generic discovery to find the actual project venv instead of incorrectly falling back to PEP 582 mode.

Create PR

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

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
Comment thread crates/socket-patch-core/src/crawlers/python_crawler.rs Resolved

Uh oh!

There was an error while loading. Please reload this page.

Cursor Agent (cursoragent) and others added 3 commits October 2, 2026 15:15
@cursoragent
Fix PDM environment detection when PDM_PYTHON points to base interpreter
8c9d28d
When PDM_PYTHON is set to a base/system Python (not a venv), the code
previously fell back to PEP 582 mode (__pypackages__), which is incorrect.

PDM resolves the interpreter and install environment separately:
- If the interpreter is already a venv/conda env, PDM installs there
- Otherwise, with default python.use_venv=true, PDM uses the project venv
- PEP 582 is only used when no interpreter is saved and use_venv=false

The fix restructures the logic to:
1. Check PEP 582 only when there's NO interpreter at all
2. Use a venv if the interpreter is detected as one
3. Return None (for generic discovery) if the interpreter is a base Python

This allows generic probes to find the actual project venv instead of
incorrectly returning stale PEP 582 dirs or missing out-of-tree venvs.
@claude
Treat base PDM interpreters by PDM's use_venv
ca62351
A base interpreter in PDM_PYTHON (CI's system Python) no longer
overrides the saved venv: the first recorded interpreter that is an
environment wins, and conda envs (conda-meta/) count as environments.
A base interpreter means PEP 582 only when python.use_venv is off
(PDM_USE_VENV, or pdm.toml / .pdm.toml; off by default only on PDM
1.x). Otherwise the venv probes decide. Found by Bugbot.

Assisted-by: Claude Code:claude-opus-5-5
@claude
Merge Bugbot autofix, keeping the use_venv-aware fix
2b5c881
Bugbot's autofix (8c9d28d) and ca62351 fix the same finding. Keep
ca62351: the autofix returned __pypackages__ ahead of an activated or
in-project venv when no interpreter is recorded, and dropped the
PEP 582 case for a base interpreter with python.use_venv off (#528).

Assisted-by: Claude Code:claude-opus-5-5
@mikolalysenko

Mikola Lysenko (mikolalysenko) commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

BugBot review


Generated by Claude Code

cursor[bot] reacted with thumbs up emoji
All reactions
  • 👍 1 reaction

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@mikolalysenko Mikola Lysenko (mikolalysenko) mentioned this pull request Oct 2, 2026
Global scan (-g) ignores PDM's site-wide config, so a global project relocated in /etc/xdg/pdm/config.toml is never crawled and get -g reports "applied" while the copy PDM runs stays unpatched #566
Closed
cursor[bot]
cursor Bot reviewed Oct 2, 2026
View reviewed changes

@cursor cursor Bot left a comment

Copy link
Copy Markdown

There was a problem hiding this comment.

Choose a reason for hiding this comment

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

✅ 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 2b5c881. Configure here.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
@mikolalysenko
Mikola Lysenko (mikolalysenko) merged commit b0a32db into main Oct 2, 2026
340 checks passed

Uh oh!

There was an error while loading. Please reload this page.

@mikolalysenko
Mikola Lysenko (mikolalysenko) deleted the agent/fix-python-manager-project-env branch October 2, 2026 16:15
@mikolalysenko Mikola Lysenko (mikolalysenko) mentioned this pull request Oct 2, 2026
PDM PEP 582 detection misses python.use_venv = false as PDM 2.27+ writes it (a TOML string) and in the user config, so agent mode patches an activated or stray venv and leaves __pypackages__ unpatched #609
Closed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Reviewers

@cursor cursor[bot] cursor[bot] left review comments
@Tanmay182003 Tanmay Singla (Tanmay182003) Tanmay182003 approved these changes

Uh oh!

There was an error while loading. Please reload this page.

Assignees

No one assigned

Labels

Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

In a PDM __pypackages__ (PEP 582) project, hosted mode gives no stale-install warning and vex attests not_affected while the copy pdm run imports is still unpatched uv projects whose env is set by UV_PROJECT_ENVIRONMENT are never probed: agent scan patches the PATH interpreter and uv tool envs instead, the real env stays vulnerable, and vex attests not_affected Agent and hosted mode ignore the interpreter PDM records in .pdm-python (venv.in_project = false, pdm use <venv>), so the real env is skipped or a stray .venv / the PATH python is patched, and VEX attests an unpatched install

Uh oh!

There was an error while loading. Please reload this page.

4 participants

@mikolalysenko @Tanmay182003 @claude @cursoragent
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
You are visitor 01402503 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.