Skip to content

Commit 4e63fa8

Browse files
committed
Patch every PyPI copy in agent apply
The Python crawler can find one release in several site-packages dirs, but apply patched only the first. The copy the interpreter actually imports could stay vulnerable while vex attested it. Apply now patches every distinct copy, like gem and npm; paths that alias one directory through a symlink count once. Fixes #529, #501 Assisted-by: Claude Code:claude-opus-5-5
1 parent 13cc82d commit 4e63fa8

3 files changed

Lines changed: 54 additions & 17 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,11 @@ limits, and required install commands.
102102

103103
### Fixed
104104

105+
- Agent-mode PyPI `apply` patches every installed copy of a release, not just
106+
the first one found. A Pipenv project with both a WORKON_HOME venv and a
107+
`./.venv`, or a global install with the same release in the user site and a
108+
system dir, no longer keeps the copy Python imports unpatched while `vex`
109+
attests it (#529, #501).
105110
- Gem hosted and vendored modes wire only the manifest Bundler loads. A `gems.rb`
106111
twin or a `BUNDLE_GEMFILE` setting (environment or `.bundle/config`) no longer
107112
leads to an edit of an ignored `Gemfile` that reports success and attests an

‎crates/socket-patch-cli/CLI_CONTRACT.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -502,7 +502,11 @@ the model is **not uniform** today:
502502
**coexisting physical copies of one `gem@version`** (bundler-2's scoped store beside bundler-1's
503503
flat store), `apply`/`rollback` patch/restore **every copy** — one summary event per copy,
504504
mirroring npm's multi-copy fan-out — while single-representative consumers (`get`, `vendor`,
505-
`vex`) use the highest-precedence copy.
505+
`vex`) use the highest-precedence copy. PyPI follows the same rule: when the crawler resolves
506+
one release in several site-packages dirs (a Pipenv WORKON_HOME venv beside an auto-detected
507+
`./.venv`, or the user site beside a system dir in global scope), agent `apply` patches **every**
508+
copy, one summary event per copy, because any of them may be the one the interpreter imports.
509+
Paths that resolve to the same directory (a symlinked site-packages) count as one copy.
506510

507511
*Copy classes (additive to the multi-copy vocabulary):* a copy under a **bundle-path store**
508512
(config/env/default root) is PRIMARY — a variant mismatch or write failure there fails the run,

‎crates/socket-patch-cli/src/commands/apply.rs‎

Lines changed: 44 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,25 @@ pub(crate) fn variant_matches_installed(first_file_status: Option<&VerifyStatus>
627627
}
628628
}
629629

630+
/// `paths` in order with every path that resolves to an already-listed
631+
/// directory dropped: two discovered site-packages paths can name ONE
632+
/// directory (a `lib64 -> lib` symlink, a symlinked venv), and patching it
633+
/// twice would report the second pass `already_patched`. A path that can't
634+
/// be canonicalized is kept as-is.
635+
async fn distinct_install_dirs(paths: &[PathBuf]) -> Vec<PathBuf> {
636+
let mut seen: HashSet<PathBuf> = HashSet::new();
637+
let mut out = Vec::with_capacity(paths.len());
638+
for path in paths {
639+
let key = tokio::fs::canonicalize(path)
640+
.await
641+
.unwrap_or_else(|_| path.clone());
642+
if seen.insert(key) {
643+
out.push(path.clone());
644+
}
645+
}
646+
out
647+
}
648+
630649
/// The file whose verify status decides whether a release variant
631650
/// describes the installed distribution (fed to
632651
/// [`variant_matches_installed`]).
@@ -1881,22 +1900,31 @@ async fn apply_patches_inner(
18811900
continue;
18821901
}
18831902

1884-
// Patch EVERY coexisting gem store copy (the npm multi-copy
1885-
// precedent): leaving the other store pristine is a silent
1886-
// false "applied" for whichever bundler loads it, and the
1887-
// per-copy results below make the JSON summary count each
1888-
// patched copy — the signal a second copy exists. PyPI/Maven
1889-
// keep the one-representative contract: their crawlers resolve
1890-
// one install dir per version, and a second path can only
1891-
// alias the same logical install (re-patching it would produce
1892-
// the spurious `already_patched` double-patch the nuget
1893-
// first-wins restoration fixed).
1894-
let copy_paths: &[PathBuf] =
1895-
if matches!(Ecosystem::from_purl(purl), Some(Ecosystem::Gem)) {
1896-
pkg_paths.as_slice()
1897-
} else {
1898-
std::slice::from_ref(pkg_path)
1899-
};
1903+
// Patch EVERY coexisting gem store copy and every PyPI
1904+
// site-packages copy (the npm multi-copy precedent): leaving
1905+
// the other copy pristine is a silent false "applied" for
1906+
// whichever bundler / interpreter loads it, and the per-copy
1907+
// results below make the JSON summary count each patched copy
1908+
// — the signal a second copy exists. The Python crawler
1909+
// resolves one release in several candidate envs when it
1910+
// can't tell which one the project's tool runs (a Pipenv
1911+
// WORKON_HOME venv beside `./.venv`, #529) or which one
1912+
// `sys.path` shadows (the user site beside a system dir in
1913+
// global scope, #501), and rollback already restores every
1914+
// copy. A PyPI path that only ALIASES another (a symlinked
1915+
// site-packages) is collapsed by canonical path, so one
1916+
// install is never patched twice. Maven keeps the
1917+
// one-representative contract: its crawler resolves one
1918+
// install dir per version.
1919+
let pypi_copies: Vec<PathBuf>;
1920+
let copy_paths: &[PathBuf] = match Ecosystem::from_purl(purl) {
1921+
Some(Ecosystem::Gem) => pkg_paths.as_slice(),
1922+
Some(Ecosystem::Pypi) => {
1923+
pypi_copies = distinct_install_dirs(pkg_paths).await;
1924+
pypi_copies.as_slice()
1925+
}
1926+
_ => std::slice::from_ref(pkg_path),
1927+
};
19001928

19011929
// Copy CLASS decides FAILURE semantics (never write scope —
19021930
// patching a shared home's vulnerable copy is fine when it

0 commit comments

Comments
 (0)